From 666bebd2784ab10140a841a658fffcf85502073b Mon Sep 17 00:00:00 2001 From: Timothy Trowbridge Date: Thu, 17 Sep 2026 22:58:20 -0300 Subject: [PATCH 1/4] Generalise into resource-first workload identity with managed identity and Blob Storage Rebuilds the model of PR #2 on the hardened token provider: - WorkloadIdentityOptions holds one section per resource (AzureSql, BlobStorage), each selecting a Provider (ManagedIdentity or Google) with that provider's settings; validated at host start with messages that name the offending path - internal IWorkloadIdentityTokenExchanger with a Google-federated and a managed-identity implementation; credentials are created once per identity behind the IClientAssertionCredentialFactory and IManagedIdentityCredentialFactory seams - one internal ResourceTokenProvider holds each resource's token (coalescing, refresh-ahead window, forced refresh); AzureSqlTokenProvider and BlobStorageTokenProvider are thin wrappers, both interfaces expose GetAccessTokenAsync and WorkloadIdentityTokenCredential adapts a provider for Azure SDK clients - TokenRefreshService runs the expiry-driven refresh loop once per configured resource and skips a consumer-substituted provider that is not refreshable - AddWorkloadIdentity replaces AddAzureSqlFederatedIdentity; both providers are always registered and an unconfigured resource fails with a message naming its section - Demo, CI smoke test, package metadata and README follow the new configuration shape; changeset describes the migration Co-Authored-By: Claude Fable 5.1 --- .changeset/workload-identity.md | 44 +++ .github/workflows/ci.yml | 6 +- Csag.WorkloadIdentity.Demo/Program.cs | 2 +- Csag.WorkloadIdentity.Demo/appsettings.json | 11 +- ...lFederatedIdentityOptionsValidatorTests.cs | 149 -------- .../AzureSqlTokenExchangerTests.cs | 130 ------- .../AzureSqlTokenProviderTests.cs | 235 ++---------- .../BlobStorageTokenProviderTests.cs | 107 ++++++ ...dentityServiceCollectionExtensionsTests.cs | 199 ---------- .../GoogleFederatedTokenExchangerTests.cs | 228 +++++++++++ .../GoogleIdTokenProviderTests.cs | 55 ++- .../ManagedIdentityTokenExchangerTests.cs | 200 ++++++++++ .../ResourceTokenProviderTests.cs | 285 ++++++++++++++ ...ceTests.cs => TokenRefreshServiceTests.cs} | 175 +++++++-- .../TokenScopeTests.cs | 62 +++ .../WorkloadIdentityOptionsValidatorTests.cs | 329 ++++++++++++++++ ...dentityServiceCollectionExtensionsTests.cs | 356 ++++++++++++++++++ .../WorkloadIdentityTokenCredentialTests.cs | 88 +++++ .../Abstractions/IAccessTokenProvider.cs | 18 + .../Abstractions/IAzureSqlTokenExchanger.cs | 19 - .../Abstractions/IAzureSqlTokenProvider.cs | 11 +- .../Abstractions/IBlobStorageTokenProvider.cs | 15 + .../Abstractions/IGoogleIdTokenProvider.cs | 9 +- .../AzureSqlTokenProvider.cs | 164 ++------ .../BlobStorageTokenProvider.cs | 56 +++ .../Csag.WorkloadIdentity.csproj | 4 +- ...atedIdentityServiceCollectionExtensions.cs | 94 ----- .../Internal/AzureSqlTokenExchanger.cs | 106 ------ .../Internal/GoogleFederatedTokenExchanger.cs | 149 ++++++++ .../Internal/GoogleIdTokenProvider.cs | 33 +- .../Internal/IAzureSqlTokenRefresher.cs | 17 - .../IManagedIdentityCredentialFactory.cs | 20 + .../Internal/ITokenRefresher.cs | 17 + .../IWorkloadIdentityTokenExchanger.cs | 25 ++ .../ManagedIdentityCredentialFactory.cs | 26 ++ .../Internal/ManagedIdentityTokenExchanger.cs | 103 +++++ .../Internal/ResourceTokenProvider.cs | 181 +++++++++ .../Internal/ResourceTokenProviderFactory.cs | 68 ++++ ...freshService.cs => TokenRefreshService.cs} | 122 ++++-- .../Internal/TokenScopeExtensions.cs | 39 ++ .../WorkloadIdentityOptionsExtensions.cs | 41 ++ .../AzureSqlFederatedIdentityOptions.cs | 42 --- ...ureSqlFederatedIdentityOptionsValidator.cs | 48 --- .../Options/GoogleOptions.cs | 19 +- .../Options/ManagedIdentityOptions.cs | 21 ++ .../Options/WorkloadIdentityOptions.cs | 42 +++ .../WorkloadIdentityOptionsValidator.cs | 117 ++++++ .../Options/WorkloadIdentityProvider.cs | 19 + .../WorkloadIdentityResourceOptions.cs | 27 ++ Csag.WorkloadIdentity/README.md | 52 ++- Csag.WorkloadIdentity/TokenScope.cs | 22 ++ .../TokenScopeMetadataAttribute.cs | 25 ++ ...loadIdentityServiceCollectionExtensions.cs | 98 +++++ .../WorkloadIdentityTokenCredential.cs | 57 +++ 54 files changed, 3258 insertions(+), 1329 deletions(-) create mode 100644 .changeset/workload-identity.md delete mode 100644 Csag.WorkloadIdentity.UnitTests/AzureSqlFederatedIdentityOptionsValidatorTests.cs delete mode 100644 Csag.WorkloadIdentity.UnitTests/AzureSqlTokenExchangerTests.cs create mode 100644 Csag.WorkloadIdentity.UnitTests/BlobStorageTokenProviderTests.cs delete mode 100644 Csag.WorkloadIdentity.UnitTests/FederatedIdentityServiceCollectionExtensionsTests.cs create mode 100644 Csag.WorkloadIdentity.UnitTests/GoogleFederatedTokenExchangerTests.cs create mode 100644 Csag.WorkloadIdentity.UnitTests/ManagedIdentityTokenExchangerTests.cs create mode 100644 Csag.WorkloadIdentity.UnitTests/ResourceTokenProviderTests.cs rename Csag.WorkloadIdentity.UnitTests/{AzureSqlTokenRefreshServiceTests.cs => TokenRefreshServiceTests.cs} (56%) create mode 100644 Csag.WorkloadIdentity.UnitTests/TokenScopeTests.cs create mode 100644 Csag.WorkloadIdentity.UnitTests/WorkloadIdentityOptionsValidatorTests.cs create mode 100644 Csag.WorkloadIdentity.UnitTests/WorkloadIdentityServiceCollectionExtensionsTests.cs create mode 100644 Csag.WorkloadIdentity.UnitTests/WorkloadIdentityTokenCredentialTests.cs create mode 100644 Csag.WorkloadIdentity/Abstractions/IAccessTokenProvider.cs delete mode 100644 Csag.WorkloadIdentity/Abstractions/IAzureSqlTokenExchanger.cs create mode 100644 Csag.WorkloadIdentity/Abstractions/IBlobStorageTokenProvider.cs create mode 100644 Csag.WorkloadIdentity/BlobStorageTokenProvider.cs delete mode 100644 Csag.WorkloadIdentity/FederatedIdentityServiceCollectionExtensions.cs delete mode 100644 Csag.WorkloadIdentity/Internal/AzureSqlTokenExchanger.cs create mode 100644 Csag.WorkloadIdentity/Internal/GoogleFederatedTokenExchanger.cs delete mode 100644 Csag.WorkloadIdentity/Internal/IAzureSqlTokenRefresher.cs create mode 100644 Csag.WorkloadIdentity/Internal/IManagedIdentityCredentialFactory.cs create mode 100644 Csag.WorkloadIdentity/Internal/ITokenRefresher.cs create mode 100644 Csag.WorkloadIdentity/Internal/IWorkloadIdentityTokenExchanger.cs create mode 100644 Csag.WorkloadIdentity/Internal/ManagedIdentityCredentialFactory.cs create mode 100644 Csag.WorkloadIdentity/Internal/ManagedIdentityTokenExchanger.cs create mode 100644 Csag.WorkloadIdentity/Internal/ResourceTokenProvider.cs create mode 100644 Csag.WorkloadIdentity/Internal/ResourceTokenProviderFactory.cs rename Csag.WorkloadIdentity/Internal/Services/{AzureSqlTokenRefreshService.cs => TokenRefreshService.cs} (58%) create mode 100644 Csag.WorkloadIdentity/Internal/TokenScopeExtensions.cs create mode 100644 Csag.WorkloadIdentity/Internal/WorkloadIdentityOptionsExtensions.cs delete mode 100644 Csag.WorkloadIdentity/Options/AzureSqlFederatedIdentityOptions.cs delete mode 100644 Csag.WorkloadIdentity/Options/AzureSqlFederatedIdentityOptionsValidator.cs create mode 100644 Csag.WorkloadIdentity/Options/ManagedIdentityOptions.cs create mode 100644 Csag.WorkloadIdentity/Options/WorkloadIdentityOptions.cs create mode 100644 Csag.WorkloadIdentity/Options/WorkloadIdentityOptionsValidator.cs create mode 100644 Csag.WorkloadIdentity/Options/WorkloadIdentityProvider.cs create mode 100644 Csag.WorkloadIdentity/Options/WorkloadIdentityResourceOptions.cs create mode 100644 Csag.WorkloadIdentity/TokenScope.cs create mode 100644 Csag.WorkloadIdentity/TokenScopeMetadataAttribute.cs create mode 100644 Csag.WorkloadIdentity/WorkloadIdentityServiceCollectionExtensions.cs create mode 100644 Csag.WorkloadIdentity/WorkloadIdentityTokenCredential.cs diff --git a/.changeset/workload-identity.md b/.changeset/workload-identity.md new file mode 100644 index 0000000..e550073 --- /dev/null +++ b/.changeset/workload-identity.md @@ -0,0 +1,44 @@ +--- +"@neolution-ch/csag-workload-identity": minor +--- + +Generalise the library into a resource-first workload identity model: access tokens for Azure SQL and Azure Blob Storage, each obtained either with the Azure managed identity the application runs as or with its Google identity through workload identity federation. + +- **Configuration is resource-first (breaking).** Each resource has its own section under `Csag.WorkloadIdentity` that selects a `Provider` (`ManagedIdentity` or `Google`) and carries that provider's settings. For the existing Azure SQL over Google federation case: + + Before: + + ```json + { + "Csag.WorkloadIdentity": { + "TenantId": "", + "ClientId": "", + "Google": { "ServiceAccountEmail": "@.iam.gserviceaccount.com" } + } + } + ``` + + After: + + ```json + { + "Csag.WorkloadIdentity": { + "AzureSql": { + "Provider": "Google", + "Google": { + "TenantId": "", + "ClientId": "", + "ServiceAccountEmail": "@.iam.gserviceaccount.com" + } + } + } + } + ``` + + `RefreshAheadWindow` and `EnableBackgroundRefresh` stay at the root and apply to every resource. Startup validation requires at least one resource section and reports every missing value by its path, for example `AzureSql:Google:ServiceAccountEmail must be provided.` +- **Managed identity.** `"Provider": "ManagedIdentity"` with `"ManagedIdentity": { "UseSystemAssignedIdentity": true }` or `"ManagedIdentity": { "ClientId": "" }` obtains the token from the managed identity endpoint of the Azure resource the application runs on. +- **Blob Storage.** A `BlobStorage` section (same shape as `AzureSql`) registers `IBlobStorageTokenProvider` with `GetBlobStorageAccessTokenAsync`, requesting the `https://storage.azure.com/.default` scope. +- **`AddWorkloadIdentity` replaces `AddAzureSqlFederatedIdentity`** with the same three overloads (host configuration, `Action`, `IConfiguration`). `WorkloadIdentityOptions` replaces `AzureSqlFederatedIdentityOptions`; the section name `Csag.WorkloadIdentity` is unchanged and exposed as `WorkloadIdentityOptions.ConfigurationSectionName`. Both providers are always registered; resolving the provider of a resource whose section is absent throws an `InvalidOperationException` naming the missing section. +- **`TokenCredential` adapter.** Both provider interfaces gain `GetAccessTokenAsync`, which returns the token together with its real expiry, and `WorkloadIdentityTokenCredential` presents a provider to Azure SDK clients, for example `new BlobServiceClient(uri, new WorkloadIdentityTokenCredential(blobStorageTokenProvider))`. +- `IGoogleIdTokenProvider.GetIdTokenAsync` takes the service account email, so resources can federate through different service accounts. `IAzureSqlTokenExchanger` is replaced by internal per-provider exchangers. +- Unchanged: `IAzureSqlTokenProvider.GetAzureSqlAccessTokenAsync`; the token is held in memory and concurrent callers share a single request; the background service refreshes ahead of expiry with exponential backoff, now in one loop per configured resource; every service is registered with `TryAdd`, so a consumer-registered provider wins and is left alone by the background refresh. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f0a6f1..accc31c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,9 +94,9 @@ jobs: - name: Smoke test Demo image run: | docker run -d -p 8080:8080 --name csag-demo \ - -e Csag.WorkloadIdentity__TenantId=placeholder \ - -e Csag.WorkloadIdentity__ClientId=placeholder \ - -e Csag.WorkloadIdentity__Google__ServiceAccountEmail=placeholder@example.invalid \ + -e Csag.WorkloadIdentity__AzureSql__Google__TenantId=placeholder \ + -e Csag.WorkloadIdentity__AzureSql__Google__ClientId=placeholder \ + -e Csag.WorkloadIdentity__AzureSql__Google__ServiceAccountEmail=placeholder@example.invalid \ csag-demo curl --fail --silent --show-error --retry 10 --retry-connrefused --retry-all-errors --retry-delay 1 http://localhost:8080/ test "$(docker exec csag-demo id -u)" != "0" diff --git a/Csag.WorkloadIdentity.Demo/Program.cs b/Csag.WorkloadIdentity.Demo/Program.cs index 428ec5c..86fd17e 100644 --- a/Csag.WorkloadIdentity.Demo/Program.cs +++ b/Csag.WorkloadIdentity.Demo/Program.cs @@ -7,7 +7,7 @@ builder.Services.AddScoped(); -builder.Services.AddAzureSqlFederatedIdentity(builder.Configuration); +builder.Services.AddWorkloadIdentity(builder.Configuration); var app = builder.Build(); diff --git a/Csag.WorkloadIdentity.Demo/appsettings.json b/Csag.WorkloadIdentity.Demo/appsettings.json index a9d7c15..a8b121f 100644 --- a/Csag.WorkloadIdentity.Demo/appsettings.json +++ b/Csag.WorkloadIdentity.Demo/appsettings.json @@ -7,10 +7,13 @@ }, "AllowedHosts": "*", "Csag.WorkloadIdentity": { - "TenantId": "", - "ClientId": "", - "Google": { - "ServiceAccountEmail": "" + "AzureSql": { + "Provider": "Google", + "Google": { + "TenantId": "", + "ClientId": "", + "ServiceAccountEmail": "" + } } }, "ConnectionStrings": { diff --git a/Csag.WorkloadIdentity.UnitTests/AzureSqlFederatedIdentityOptionsValidatorTests.cs b/Csag.WorkloadIdentity.UnitTests/AzureSqlFederatedIdentityOptionsValidatorTests.cs deleted file mode 100644 index 78088a1..0000000 --- a/Csag.WorkloadIdentity.UnitTests/AzureSqlFederatedIdentityOptionsValidatorTests.cs +++ /dev/null @@ -1,149 +0,0 @@ -namespace Csag.WorkloadIdentity.UnitTests -{ - using Csag.WorkloadIdentity.Options; - using Shouldly; - - /// - /// Unit tests for the class. - /// - public class AzureSqlFederatedIdentityOptionsValidatorTests - { - /// - /// The validator under test. - /// - private readonly AzureSqlFederatedIdentityOptionsValidator validator = new(); - - /// - /// Verifies that complete options pass validation. - /// - [Fact] - public void Given_CompleteOptions_When_Validate_Then_Succeeds() - { - // Arrange - var options = CreateCompleteOptions(); - - // Act - var result = this.validator.Validate(null, options); - - // Assert - result.Succeeded.ShouldBeTrue(); - } - - /// - /// Verifies that a blank tenant ID fails validation. - /// - /// The blank tenant ID. - [Theory] - [InlineData("")] - [InlineData(" ")] - public void Given_BlankTenantId_When_Validate_Then_Fails(string tenantId) - { - // Arrange - var options = CreateCompleteOptions(); - options.TenantId = tenantId; - - // Act - var result = this.validator.Validate(null, options); - - // Assert - result.Failed.ShouldBeTrue(); - result.FailureMessage.ShouldNotBeNull().ShouldContain(nameof(AzureSqlFederatedIdentityOptions.TenantId)); - } - - /// - /// Verifies that a blank client ID fails validation. - /// - /// The blank client ID. - [Theory] - [InlineData("")] - [InlineData(" ")] - public void Given_BlankClientId_When_Validate_Then_Fails(string clientId) - { - // Arrange - var options = CreateCompleteOptions(); - options.ClientId = clientId; - - // Act - var result = this.validator.Validate(null, options); - - // Assert - result.Failed.ShouldBeTrue(); - result.FailureMessage.ShouldNotBeNull().ShouldContain(nameof(AzureSqlFederatedIdentityOptions.ClientId)); - } - - /// - /// Verifies that a missing Google section fails validation. - /// - [Fact] - public void Given_MissingGoogleSection_When_Validate_Then_Fails() - { - // Arrange - var options = CreateCompleteOptions(); - options.Google = null; - - // Act - var result = this.validator.Validate(null, options); - - // Assert - result.Failed.ShouldBeTrue(); - result.FailureMessage.ShouldNotBeNull().ShouldContain("Google section"); - } - - /// - /// Verifies that a blank service account email fails validation. - /// - /// The blank service account email. - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public void Given_BlankServiceAccountEmail_When_Validate_Then_Fails(string? serviceAccountEmail) - { - // Arrange - var options = CreateCompleteOptions(); - options.Google = new GoogleOptions { ServiceAccountEmail = serviceAccountEmail }; - - // Act - var result = this.validator.Validate(null, options); - - // Assert - result.Failed.ShouldBeTrue(); - result.FailureMessage.ShouldNotBeNull().ShouldContain("Google:ServiceAccountEmail"); - } - - /// - /// Verifies that a refresh-ahead window that is not positive fails validation. - /// - /// The window length in minutes. - [Theory] - [InlineData(0)] - [InlineData(-1)] - public void Given_NonPositiveRefreshAheadWindow_When_Validate_Then_Fails(int minutes) - { - // Arrange - var options = CreateCompleteOptions(); - options.RefreshAheadWindow = TimeSpan.FromMinutes(minutes); - - // Act - var result = this.validator.Validate(null, options); - - // Assert - result.Failed.ShouldBeTrue(); - result.FailureMessage.ShouldNotBeNull().ShouldContain(nameof(AzureSqlFederatedIdentityOptions.RefreshAheadWindow)); - } - - /// - /// Creates options with every required value set. - /// - /// The options. - private static AzureSqlFederatedIdentityOptions CreateCompleteOptions() - { - return new AzureSqlFederatedIdentityOptions - { - TenantId = "tenant", - ClientId = "client", - Google = new GoogleOptions { ServiceAccountEmail = "sa@example.iam.gserviceaccount.com" }, - }; - } - } -} diff --git a/Csag.WorkloadIdentity.UnitTests/AzureSqlTokenExchangerTests.cs b/Csag.WorkloadIdentity.UnitTests/AzureSqlTokenExchangerTests.cs deleted file mode 100644 index 41ed033..0000000 --- a/Csag.WorkloadIdentity.UnitTests/AzureSqlTokenExchangerTests.cs +++ /dev/null @@ -1,130 +0,0 @@ -namespace Csag.WorkloadIdentity.UnitTests -{ - using Azure.Core; - using Csag.WorkloadIdentity.Abstractions; - using Csag.WorkloadIdentity.Internal; - using Microsoft.Extensions.Logging.Abstractions; - using NSubstitute; - using Shouldly; - - /// - /// Unit tests for the class. - /// - public class AzureSqlTokenExchangerTests - { - /// - /// The Azure AD tenant ID passed to the exchanger. - /// - private const string TenantId = "tenant"; - - /// - /// The Azure AD client ID passed to the exchanger. - /// - private const string ClientId = "client"; - - /// - /// The scope that grants access to Azure SQL. - /// - private const string AzureSqlScope = "https://database.windows.net/.default"; - - /// - /// The substituted Google ID token provider. - /// - private readonly IGoogleIdTokenProvider googleIdTokenProvider = Substitute.For(); - - /// - /// The substituted credential factory. - /// - private readonly IClientAssertionCredentialFactory credentialFactory = Substitute.For(); - - /// - /// The substituted credential the factory hands out. - /// - private readonly TokenCredential credential = Substitute.For(); - - /// - /// The exchanger under test. - /// - private readonly AzureSqlTokenExchanger exchanger; - - /// - /// The client assertion callback the exchanger handed to the factory, once a credential was created. - /// - private Func>? assertionCallback; - - /// - /// Initializes a new instance of the class. - /// - public AzureSqlTokenExchangerTests() - { - this.credentialFactory - .Create(TenantId, ClientId, Arg.Do>>(callback => this.assertionCallback = callback)) - .Returns(this.credential); - this.exchanger = new AzureSqlTokenExchanger(NullLogger.Instance, this.googleIdTokenProvider, this.credentialFactory); - } - - /// - /// Verifies that the exchanger requests the Azure SQL scope and returns the credential's token. - /// - /// A task that represents the asynchronous operation. - [Fact] - public async Task Given_Credential_When_ExchangeClientAssertionForAzureTokenAsync_Then_RequestsAzureSqlScopeAndReturnsToken() - { - // Arrange - var expected = new AccessToken("access-token", DateTimeOffset.UtcNow.AddHours(1)); - this.credential.GetTokenAsync(Arg.Any(), Arg.Any()).Returns(expected); - - // Act - var result = await this.exchanger.ExchangeClientAssertionForAzureTokenAsync(TenantId, ClientId, CancellationToken.None); - - // Assert - result.Token.ShouldBe(expected.Token); - result.ExpiresOn.ShouldBe(expected.ExpiresOn); - await this.credential.Received(1).GetTokenAsync(Arg.Is(context => context.Scopes.Single() == AzureSqlScope), Arg.Any()); - } - - /// - /// Verifies that repeated exchanges for the same tenant and client reuse one credential. - /// - /// A task that represents the asynchronous operation. - [Fact] - public async Task Given_TwoExchangesForSameTenantAndClient_When_ExchangeClientAssertionForAzureTokenAsync_Then_CreatesCredentialOnce() - { - // Arrange - this.credential.GetTokenAsync(Arg.Any(), Arg.Any()) - .Returns(new AccessToken("access-token", DateTimeOffset.UtcNow.AddHours(1))); - - // Act - await this.exchanger.ExchangeClientAssertionForAzureTokenAsync(TenantId, ClientId, CancellationToken.None); - await this.exchanger.ExchangeClientAssertionForAzureTokenAsync(TenantId, ClientId, CancellationToken.None); - - // Assert - this.credentialFactory.Received(1).Create(TenantId, ClientId, Arg.Any>>()); - } - - /// - /// Verifies that the client assertion callback forwards the cancellation token the credential passes to it, - /// not the token of the call that created the credential. - /// - /// A task that represents the asynchronous operation. - [Fact] - public async Task Given_CreatedCredential_When_CredentialInvokesAssertionCallback_Then_GoogleProviderReceivesCredentialsCancellationToken() - { - // Arrange - this.credential.GetTokenAsync(Arg.Any(), Arg.Any()) - .Returns(new AccessToken("access-token", DateTimeOffset.UtcNow.AddHours(1))); - this.googleIdTokenProvider.GetIdTokenAsync(Arg.Any()).Returns("id-token"); - using var callerCancellation = new CancellationTokenSource(); - using var credentialCancellation = new CancellationTokenSource(); - await this.exchanger.ExchangeClientAssertionForAzureTokenAsync(TenantId, ClientId, callerCancellation.Token); - - // Act - var assertion = await this.assertionCallback.ShouldNotBeNull()(credentialCancellation.Token); - - // Assert - assertion.ShouldBe("id-token"); - await this.googleIdTokenProvider.Received(1).GetIdTokenAsync(credentialCancellation.Token); - await this.googleIdTokenProvider.DidNotReceive().GetIdTokenAsync(callerCancellation.Token); - } - } -} diff --git a/Csag.WorkloadIdentity.UnitTests/AzureSqlTokenProviderTests.cs b/Csag.WorkloadIdentity.UnitTests/AzureSqlTokenProviderTests.cs index 5c06e12..836b8f7 100644 --- a/Csag.WorkloadIdentity.UnitTests/AzureSqlTokenProviderTests.cs +++ b/Csag.WorkloadIdentity.UnitTests/AzureSqlTokenProviderTests.cs @@ -1,262 +1,87 @@ namespace Csag.WorkloadIdentity.UnitTests { using Azure.Core; - using Csag.WorkloadIdentity.Abstractions; using Csag.WorkloadIdentity.Internal; - using Csag.WorkloadIdentity.Options; using Microsoft.Extensions.Logging.Abstractions; - using Microsoft.Extensions.Options; using Microsoft.Extensions.Time.Testing; using NSubstitute; using Shouldly; /// - /// Unit tests for the class. + /// Unit tests for the class, which wraps a . /// public class AzureSqlTokenProviderTests { - /// - /// The Azure AD tenant ID the provider under test is configured with. - /// - private const string TenantId = "tenant"; - - /// - /// The Azure AD client ID the provider under test is configured with. - /// - private const string ClientId = "client"; - - /// - /// The value of the first token the exchanger hands out. - /// - private const string FirstToken = "first-token"; - - /// - /// The value of the second token the exchanger hands out. - /// - private const string SecondToken = "second-token"; - - /// - /// The refresh-ahead window the provider under test is configured with. - /// - private static readonly TimeSpan RefreshAheadWindow = TimeSpan.FromMinutes(5); - /// /// The instant at which every test starts. /// private static readonly DateTimeOffset StartTime = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero); - /// - /// The controllable clock the provider under test reads. - /// - private readonly FakeTimeProvider timeProvider = new(StartTime); - /// /// The substituted token exchanger. /// - private readonly IAzureSqlTokenExchanger exchanger = Substitute.For(); - - /// - /// Verifies that the first call exchanges a token and returns it. - /// - /// A task that represents the asynchronous operation. - [Fact] - public async Task Given_NoHeldToken_When_GetAzureSqlAccessTokenAsync_Then_ExchangesAndReturnsToken() - { - // Arrange - this.SetupExchange(new AccessToken(FirstToken, StartTime.AddHours(1))); - using var provider = this.CreateProvider(); - - // Act - var result = await provider.GetAzureSqlAccessTokenAsync(CancellationToken.None); - - // Assert - result.ShouldBe(FirstToken); - await this.ShouldHaveExchangedAsync(1); - } - - /// - /// Verifies that a held token outside the refresh-ahead window is returned without another exchange. - /// - /// A task that represents the asynchronous operation. - [Fact] - public async Task Given_HeldTokenOutsideRefreshWindow_When_GetAzureSqlAccessTokenAsync_Then_ReturnsHeldTokenWithoutExchange() - { - // Arrange - this.SetupExchange(new AccessToken(FirstToken, StartTime.AddHours(1))); - using var provider = this.CreateProvider(); - await provider.GetAzureSqlAccessTokenAsync(CancellationToken.None); - - // Act - var result = await provider.GetAzureSqlAccessTokenAsync(CancellationToken.None); - - // Assert - result.ShouldBe(FirstToken); - await this.ShouldHaveExchangedAsync(1); - } + private readonly IWorkloadIdentityTokenExchanger exchanger = Substitute.For(); /// - /// Verifies that a token with one tick more than the refresh-ahead window left is still returned. + /// Verifies that the string overload returns the token value obtained for Azure SQL. /// /// A task that represents the asynchronous operation. [Fact] - public async Task Given_HeldTokenOneTickOutsideRefreshWindow_When_GetAzureSqlAccessTokenAsync_Then_ReturnsHeldToken() + public async Task Given_Token_When_GetAzureSqlAccessTokenAsync_Then_ReturnsTokenValue() { // Arrange - var expiresOn = StartTime.AddHours(1); - this.SetupExchange(new AccessToken(FirstToken, expiresOn), new AccessToken(SecondToken, expiresOn.AddHours(1))); + this.SetupExchange(new AccessToken("sql-token", StartTime.AddHours(1))); using var provider = this.CreateProvider(); - await provider.GetAzureSqlAccessTokenAsync(CancellationToken.None); - this.timeProvider.SetUtcNow(expiresOn - RefreshAheadWindow - TimeSpan.FromTicks(1)); // Act var result = await provider.GetAzureSqlAccessTokenAsync(CancellationToken.None); // Assert - result.ShouldBe(FirstToken); - await this.ShouldHaveExchangedAsync(1); + result.ShouldBe("sql-token"); + await this.exchanger.Received(1).ExchangeAsync(TokenScope.AzureSql, Arg.Any()); } /// - /// Verifies that a token with exactly the refresh-ahead window left is replaced. + /// Verifies that the token overload returns the token together with its real expiry. /// /// A task that represents the asynchronous operation. [Fact] - public async Task Given_HeldTokenEnteringRefreshWindow_When_GetAzureSqlAccessTokenAsync_Then_ExchangesNewToken() + public async Task Given_Token_When_GetAccessTokenAsync_Then_ReturnsTokenWithExpiry() { // Arrange var expiresOn = StartTime.AddHours(1); - this.SetupExchange(new AccessToken(FirstToken, expiresOn), new AccessToken(SecondToken, expiresOn.AddHours(1))); - using var provider = this.CreateProvider(); - await provider.GetAzureSqlAccessTokenAsync(CancellationToken.None); - this.timeProvider.SetUtcNow(expiresOn - RefreshAheadWindow); - - // Act - var result = await provider.GetAzureSqlAccessTokenAsync(CancellationToken.None); - - // Assert - result.ShouldBe(SecondToken); - await this.ShouldHaveExchangedAsync(2); - } - - /// - /// Verifies that a token which arrives with less than the refresh-ahead window left is used for the call - /// that fetched it and replaced by the next call, with exactly one exchange per call. - /// - /// A task that represents the asynchronous operation. - [Fact] - public async Task Given_ExchangedTokenAlreadyInsideRefreshWindow_When_GetAzureSqlAccessTokenAsync_Then_UsesItAndRefreshesOnNextCall() - { - // Arrange - var shortLivedExpiry = StartTime.AddMinutes(2); - this.SetupExchange(new AccessToken(FirstToken, shortLivedExpiry), new AccessToken(SecondToken, shortLivedExpiry)); - using var provider = this.CreateProvider(); - - // Act - var first = await provider.GetAzureSqlAccessTokenAsync(CancellationToken.None); - var second = await provider.GetAzureSqlAccessTokenAsync(CancellationToken.None); - - // Assert - first.ShouldBe(FirstToken); - second.ShouldBe(SecondToken); - await this.ShouldHaveExchangedAsync(2); - } - - /// - /// Verifies that concurrent callers on a cold provider share a single exchange. - /// - /// A task that represents the asynchronous operation. - [Fact] - public async Task Given_FiftyConcurrentCallersAndNoHeldToken_When_GetAzureSqlAccessTokenAsync_Then_ExchangesExactlyOnce() - { - // Arrange - var exchange = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - this.exchanger.ExchangeClientAssertionForAzureTokenAsync(TenantId, ClientId, Arg.Any()).Returns(exchange.Task); - using var provider = this.CreateProvider(); - - // Act - var calls = Enumerable.Range(0, 50) - .Select(_ => Task.Run(() => provider.GetAzureSqlAccessTokenAsync(CancellationToken.None))) - .ToArray(); - await Task.Delay(TimeSpan.FromMilliseconds(200)); - exchange.SetResult(new AccessToken(FirstToken, StartTime.AddHours(1))); - var results = await Task.WhenAll(calls); - - // Assert - results.ShouldAllBe(token => token == FirstToken); - await this.ShouldHaveExchangedAsync(1); - } - - /// - /// Verifies that a failed exchange is not retained and the next call exchanges again. - /// - /// A task that represents the asynchronous operation. - [Fact] - public async Task Given_FailedExchange_When_GetAzureSqlAccessTokenAsyncAgain_Then_RetriesExchange() - { - // Arrange - this.exchanger.ExchangeClientAssertionForAzureTokenAsync(TenantId, ClientId, Arg.Any()) - .Returns( - _ => throw new InvalidOperationException("exchange failed"), - _ => new AccessToken(FirstToken, StartTime.AddHours(1))); + this.SetupExchange(new AccessToken("sql-token", expiresOn)); using var provider = this.CreateProvider(); // Act - var failure = await Should.ThrowAsync(() => provider.GetAzureSqlAccessTokenAsync(CancellationToken.None)); - var result = await provider.GetAzureSqlAccessTokenAsync(CancellationToken.None); + var result = await provider.GetAccessTokenAsync(CancellationToken.None); // Assert - failure.Message.ShouldBe("exchange failed"); - result.ShouldBe(FirstToken); - await this.ShouldHaveExchangedAsync(2); + result.Token.ShouldBe("sql-token"); + result.ExpiresOn.ShouldBe(expiresOn); } /// - /// Verifies that a forced refresh replaces a token that is not yet due and reports the new expiry. + /// Verifies that a forced refresh replaces the held token and reports the new expiry. /// /// A task that represents the asynchronous operation. [Fact] - public async Task Given_HeldTokenOutsideRefreshWindow_When_RefreshAsync_Then_ExchangesNewTokenAndReturnsItsExpiry() + public async Task Given_HeldToken_When_RefreshAsync_Then_ReplacesItAndReturnsNewExpiry() { // Arrange var secondExpiry = StartTime.AddHours(2); - this.SetupExchange(new AccessToken(FirstToken, StartTime.AddHours(1)), new AccessToken(SecondToken, secondExpiry)); + this.SetupExchange(new AccessToken("first", StartTime.AddHours(1)), new AccessToken("second", secondExpiry)); using var provider = this.CreateProvider(); await provider.GetAzureSqlAccessTokenAsync(CancellationToken.None); // Act - var expiresOn = await ((IAzureSqlTokenRefresher)provider).RefreshAsync(CancellationToken.None); + var expiresOn = await ((ITokenRefresher)provider).RefreshAsync(CancellationToken.None); var result = await provider.GetAzureSqlAccessTokenAsync(CancellationToken.None); // Assert expiresOn.ShouldBe(secondExpiry); - result.ShouldBe(SecondToken); - await this.ShouldHaveExchangedAsync(2); - } - - /// - /// Verifies that a forced refresh arriving while a caller's exchange is in flight shares that exchange. - /// - /// A task that represents the asynchronous operation. - [Fact] - public async Task Given_ExchangeInFlight_When_RefreshAsync_Then_SharesThatExchange() - { - // Arrange - var expiresOn = StartTime.AddHours(1); - var exchange = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - this.exchanger.ExchangeClientAssertionForAzureTokenAsync(TenantId, ClientId, Arg.Any()).Returns(exchange.Task); - using var provider = this.CreateProvider(); - - // Act - var call = Task.Run(() => provider.GetAzureSqlAccessTokenAsync(CancellationToken.None)); - await Task.Delay(TimeSpan.FromMilliseconds(200)); - var refresh = Task.Run(() => ((IAzureSqlTokenRefresher)provider).RefreshAsync(CancellationToken.None)); - await Task.Delay(TimeSpan.FromMilliseconds(200)); - exchange.SetResult(new AccessToken(FirstToken, expiresOn)); - - // Assert - (await call).ShouldBe(FirstToken); - (await refresh).ShouldBe(expiresOn); - await this.ShouldHaveExchangedAsync(1); + result.ShouldBe("second"); + await this.exchanger.Received(2).ExchangeAsync(TokenScope.AzureSql, Arg.Any()); } /// @@ -265,34 +90,18 @@ public async Task Given_ExchangeInFlight_When_RefreshAsync_Then_SharesThatExchan /// The tokens to hand out. private void SetupExchange(params AccessToken[] tokens) { - this.exchanger.ExchangeClientAssertionForAzureTokenAsync(TenantId, ClientId, Arg.Any()) + this.exchanger.ExchangeAsync(TokenScope.AzureSql, Arg.Any()) .Returns(tokens[0], tokens.Skip(1).ToArray()); } - /// - /// Asserts how many times the exchanger was asked for a token. - /// - /// The expected number of exchanges. - /// A task that represents the asynchronous operation. - private async Task ShouldHaveExchangedAsync(int times) - { - await this.exchanger.Received(times).ExchangeClientAssertionForAzureTokenAsync(TenantId, ClientId, Arg.Any()); - } - /// /// Creates the provider under test. /// /// The provider. private AzureSqlTokenProvider CreateProvider() { - var options = Options.Create(new AzureSqlFederatedIdentityOptions - { - TenantId = TenantId, - ClientId = ClientId, - RefreshAheadWindow = RefreshAheadWindow, - }); - - return new AzureSqlTokenProvider(this.exchanger, options, this.timeProvider, NullLogger.Instance); + var tokenProvider = new ResourceTokenProvider(TokenScope.AzureSql, this.exchanger, TimeSpan.FromMinutes(5), new FakeTimeProvider(StartTime), NullLogger.Instance); + return new AzureSqlTokenProvider(tokenProvider); } } } diff --git a/Csag.WorkloadIdentity.UnitTests/BlobStorageTokenProviderTests.cs b/Csag.WorkloadIdentity.UnitTests/BlobStorageTokenProviderTests.cs new file mode 100644 index 0000000..a37721a --- /dev/null +++ b/Csag.WorkloadIdentity.UnitTests/BlobStorageTokenProviderTests.cs @@ -0,0 +1,107 @@ +namespace Csag.WorkloadIdentity.UnitTests +{ + using Azure.Core; + using Csag.WorkloadIdentity.Internal; + using Microsoft.Extensions.Logging.Abstractions; + using Microsoft.Extensions.Time.Testing; + using NSubstitute; + using Shouldly; + + /// + /// Unit tests for the class, which wraps a . + /// + public class BlobStorageTokenProviderTests + { + /// + /// The instant at which every test starts. + /// + private static readonly DateTimeOffset StartTime = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero); + + /// + /// The substituted token exchanger. + /// + private readonly IWorkloadIdentityTokenExchanger exchanger = Substitute.For(); + + /// + /// Verifies that the string overload returns the token value obtained for Blob Storage. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Given_Token_When_GetBlobStorageAccessTokenAsync_Then_ReturnsTokenValue() + { + // Arrange + this.SetupExchange(new AccessToken("blob-token", StartTime.AddHours(1))); + using var provider = this.CreateProvider(); + + // Act + var result = await provider.GetBlobStorageAccessTokenAsync(CancellationToken.None); + + // Assert + result.ShouldBe("blob-token"); + await this.exchanger.Received(1).ExchangeAsync(TokenScope.BlobStorage, Arg.Any()); + } + + /// + /// Verifies that the token overload returns the token together with its real expiry. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Given_Token_When_GetAccessTokenAsync_Then_ReturnsTokenWithExpiry() + { + // Arrange + var expiresOn = StartTime.AddHours(1); + this.SetupExchange(new AccessToken("blob-token", expiresOn)); + using var provider = this.CreateProvider(); + + // Act + var result = await provider.GetAccessTokenAsync(CancellationToken.None); + + // Assert + result.Token.ShouldBe("blob-token"); + result.ExpiresOn.ShouldBe(expiresOn); + } + + /// + /// Verifies that a forced refresh replaces the held token and reports the new expiry. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Given_HeldToken_When_RefreshAsync_Then_ReplacesItAndReturnsNewExpiry() + { + // Arrange + var secondExpiry = StartTime.AddHours(2); + this.SetupExchange(new AccessToken("first", StartTime.AddHours(1)), new AccessToken("second", secondExpiry)); + using var provider = this.CreateProvider(); + await provider.GetBlobStorageAccessTokenAsync(CancellationToken.None); + + // Act + var expiresOn = await ((ITokenRefresher)provider).RefreshAsync(CancellationToken.None); + var result = await provider.GetBlobStorageAccessTokenAsync(CancellationToken.None); + + // Assert + expiresOn.ShouldBe(secondExpiry); + result.ShouldBe("second"); + await this.exchanger.Received(2).ExchangeAsync(TokenScope.BlobStorage, Arg.Any()); + } + + /// + /// Configures the exchanger to hand out the given tokens in order, repeating the last one. + /// + /// The tokens to hand out. + private void SetupExchange(params AccessToken[] tokens) + { + this.exchanger.ExchangeAsync(TokenScope.BlobStorage, Arg.Any()) + .Returns(tokens[0], tokens.Skip(1).ToArray()); + } + + /// + /// Creates the provider under test. + /// + /// The provider. + private BlobStorageTokenProvider CreateProvider() + { + var tokenProvider = new ResourceTokenProvider(TokenScope.BlobStorage, this.exchanger, TimeSpan.FromMinutes(5), new FakeTimeProvider(StartTime), NullLogger.Instance); + return new BlobStorageTokenProvider(tokenProvider); + } + } +} diff --git a/Csag.WorkloadIdentity.UnitTests/FederatedIdentityServiceCollectionExtensionsTests.cs b/Csag.WorkloadIdentity.UnitTests/FederatedIdentityServiceCollectionExtensionsTests.cs deleted file mode 100644 index 2058699..0000000 --- a/Csag.WorkloadIdentity.UnitTests/FederatedIdentityServiceCollectionExtensionsTests.cs +++ /dev/null @@ -1,199 +0,0 @@ -namespace Csag.WorkloadIdentity.UnitTests -{ - using Csag.WorkloadIdentity.Abstractions; - using Csag.WorkloadIdentity.Internal.Services; - using Csag.WorkloadIdentity.Options; - using Microsoft.Extensions.Configuration; - using Microsoft.Extensions.DependencyInjection; - using Microsoft.Extensions.Hosting; - using Microsoft.Extensions.Options; - using NSubstitute; - using Shouldly; - - /// - /// Unit tests for the class. - /// - public class FederatedIdentityServiceCollectionExtensionsTests - { - /// - /// The Azure AD tenant ID in the test configuration. - /// - private const string TenantId = "tenant"; - - /// - /// The Azure AD client ID in the test configuration. - /// - private const string ClientId = "client"; - - /// - /// The Google service account email in the test configuration. - /// - private const string ServiceAccountEmail = "sa@example.iam.gserviceaccount.com"; - - /// - /// The refresh-ahead window in the test configuration. - /// - private static readonly TimeSpan RefreshAheadWindow = TimeSpan.FromMinutes(7); - - /// - /// Verifies that the parameterless overload binds the default section of the registered configuration and - /// wires up the token provider and the refresh service. - /// - [Fact] - public void Given_ConfigurationInContainer_When_AddAzureSqlFederatedIdentity_Then_BindsOptionsAndResolvesPipeline() - { - // Arrange - var services = CreateServices(CreateValidConfiguration()); - - // Act - services.AddAzureSqlFederatedIdentity(); - using var serviceProvider = services.BuildServiceProvider(); - - // Assert - var options = serviceProvider.GetRequiredService>().Value; - options.TenantId.ShouldBe(TenantId); - options.ClientId.ShouldBe(ClientId); - options.Google.ShouldNotBeNull().ServiceAccountEmail.ShouldBe(ServiceAccountEmail); - options.RefreshAheadWindow.ShouldBe(RefreshAheadWindow); - options.EnableBackgroundRefresh.ShouldBeFalse(); - serviceProvider.GetRequiredService().ShouldBeOfType(); - serviceProvider.GetServices().ShouldHaveSingleItem().ShouldBeOfType(); - } - - /// - /// Verifies that missing configuration fails host startup with a validation error naming the missing value. - /// - /// A task that represents the asynchronous operation. - [Fact] - public async Task Given_MissingConfiguration_When_HostStarts_Then_ThrowsOptionsValidationException() - { - // Arrange - var builder = Host.CreateEmptyApplicationBuilder(new HostApplicationBuilderSettings()); - builder.Services.AddAzureSqlFederatedIdentity(); - using var host = builder.Build(); - - // Act - var exception = await Should.ThrowAsync(() => host.StartAsync()); - - // Assert - exception.Message.ShouldContain(nameof(AzureSqlFederatedIdentityOptions.TenantId)); - } - - /// - /// Verifies that a token provider registered by the consumer beforehand is kept. - /// - [Fact] - public void Given_ConsumerRegisteredTokenProvider_When_AddAzureSqlFederatedIdentity_Then_ConsumerRegistrationWins() - { - // Arrange - var consumerProvider = Substitute.For(); - var services = CreateServices(CreateValidConfiguration()); - services.AddSingleton(consumerProvider); - - // Act - services.AddAzureSqlFederatedIdentity(); - using var serviceProvider = services.BuildServiceProvider(); - - // Assert - serviceProvider.GetRequiredService().ShouldBeSameAs(consumerProvider); - } - - /// - /// Verifies that registering twice does not duplicate the services or the hosted service. - /// - [Fact] - public void Given_CalledTwice_When_AddAzureSqlFederatedIdentity_Then_RegistersEachServiceOnce() - { - // Arrange - var services = CreateServices(CreateValidConfiguration()); - - // Act - services.AddAzureSqlFederatedIdentity(); - services.AddAzureSqlFederatedIdentity(); - - // Assert - services.Count(descriptor => descriptor.ServiceType == typeof(IAzureSqlTokenProvider)).ShouldBe(1); - services.Count(descriptor => descriptor.ServiceType == typeof(IHostedService)).ShouldBe(1); - services.Count(descriptor => descriptor.ServiceType == typeof(IValidateOptions)).ShouldBe(1); - } - - /// - /// Verifies that the configure-action overload applies the options without any configuration in the container. - /// - [Fact] - public void Given_ConfigureAction_When_AddAzureSqlFederatedIdentity_Then_AppliesOptions() - { - // Arrange - var services = new ServiceCollection(); - services.AddLogging(); - - // Act - services.AddAzureSqlFederatedIdentity(options => - { - options.TenantId = TenantId; - options.ClientId = ClientId; - options.Google = new GoogleOptions { ServiceAccountEmail = ServiceAccountEmail }; - }); - using var serviceProvider = services.BuildServiceProvider(); - - // Assert - var options = serviceProvider.GetRequiredService>().Value; - options.TenantId.ShouldBe(TenantId); - options.EnableBackgroundRefresh.ShouldBeTrue(); - serviceProvider.GetRequiredService().ShouldBeOfType(); - } - - /// - /// Verifies that the configuration overload binds from the given instance without any configuration in the container. - /// - [Fact] - public void Given_ConfigurationInstance_When_AddAzureSqlFederatedIdentity_Then_BindsOptions() - { - // Arrange - var services = new ServiceCollection(); - services.AddLogging(); - - // Act - services.AddAzureSqlFederatedIdentity(CreateValidConfiguration()); - using var serviceProvider = services.BuildServiceProvider(); - - // Assert - var options = serviceProvider.GetRequiredService>().Value; - options.ClientId.ShouldBe(ClientId); - options.RefreshAheadWindow.ShouldBe(RefreshAheadWindow); - serviceProvider.GetRequiredService().ShouldBeOfType(); - } - - /// - /// Creates an in-memory configuration holding a complete default section. - /// - /// The configuration. - private static IConfiguration CreateValidConfiguration() - { - var section = AzureSqlFederatedIdentityOptions.ConfigurationSectionName; - return new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [$"{section}:TenantId"] = TenantId, - [$"{section}:ClientId"] = ClientId, - [$"{section}:Google:ServiceAccountEmail"] = ServiceAccountEmail, - [$"{section}:RefreshAheadWindow"] = RefreshAheadWindow.ToString(), - [$"{section}:EnableBackgroundRefresh"] = "false", - }) - .Build(); - } - - /// - /// Creates a service collection with the given configuration and logging registered, as a host would. - /// - /// The configuration to register. - /// The service collection. - private static ServiceCollection CreateServices(IConfiguration configuration) - { - var services = new ServiceCollection(); - services.AddSingleton(configuration); - services.AddLogging(); - return services; - } - } -} diff --git a/Csag.WorkloadIdentity.UnitTests/GoogleFederatedTokenExchangerTests.cs b/Csag.WorkloadIdentity.UnitTests/GoogleFederatedTokenExchangerTests.cs new file mode 100644 index 0000000..b59b31d --- /dev/null +++ b/Csag.WorkloadIdentity.UnitTests/GoogleFederatedTokenExchangerTests.cs @@ -0,0 +1,228 @@ +namespace Csag.WorkloadIdentity.UnitTests +{ + using Azure.Core; + using Csag.WorkloadIdentity.Abstractions; + using Csag.WorkloadIdentity.Internal; + using Csag.WorkloadIdentity.Options; + using Microsoft.Extensions.Logging.Abstractions; + using Microsoft.Extensions.Options; + using NSubstitute; + using Shouldly; + + /// + /// Unit tests for the class. + /// + public class GoogleFederatedTokenExchangerTests + { + /// + /// The Microsoft Entra tenant ID both resources are configured with. + /// + private const string TenantId = "tenant"; + + /// + /// The client ID both resources are configured with. + /// + private const string ClientId = "client"; + + /// + /// The service account the Azure SQL resource federates through. + /// + private const string AzureSqlServiceAccountEmail = "sql@example.iam.gserviceaccount.com"; + + /// + /// The service account the Blob Storage resource federates through. + /// + private const string BlobStorageServiceAccountEmail = "blob@example.iam.gserviceaccount.com"; + + /// + /// The OAuth 2.0 scope of Azure SQL. + /// + private const string AzureSqlScope = "https://database.windows.net/.default"; + + /// + /// The OAuth 2.0 scope of Blob Storage. + /// + private const string BlobStorageScope = "https://storage.azure.com/.default"; + + /// + /// The substituted Google ID token provider. + /// + private readonly IGoogleIdTokenProvider googleIdTokenProvider = Substitute.For(); + + /// + /// The substituted credential factory. + /// + private readonly IClientAssertionCredentialFactory credentialFactory = Substitute.For(); + + /// + /// The substituted credential the factory hands out. + /// + private readonly TokenCredential credential = Substitute.For(); + + /// + /// The client assertion callbacks the exchanger handed to the factory, in creation order. + /// + private readonly List>> assertionCallbacks = []; + + /// + /// Initializes a new instance of the class. + /// + public GoogleFederatedTokenExchangerTests() + { + this.credentialFactory + .Create(TenantId, ClientId, Arg.Do>>(this.assertionCallbacks.Add)) + .Returns(this.credential); + this.credential.GetTokenAsync(Arg.Any(), Arg.Any()) + .Returns(new AccessToken("access-token", DateTimeOffset.UtcNow.AddHours(1))); + } + + /// + /// Verifies that the exchanger identifies itself as the Google provider. + /// + [Fact] + public void Given_Exchanger_When_Provider_Then_IsGoogle() + { + // Act + var provider = this.CreateExchanger().Provider; + + // Assert + provider.ShouldBe(WorkloadIdentityProvider.Google); + } + + /// + /// Verifies that the exchanger requests the resource's scope and returns the credential's token unchanged. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Given_Credential_When_ExchangeAsync_Then_RequestsResourceScopeAndReturnsToken() + { + // Arrange + var expected = new AccessToken("sql-token", DateTimeOffset.UtcNow.AddHours(2)); + this.credential.GetTokenAsync(Arg.Any(), Arg.Any()).Returns(expected); + var exchanger = this.CreateExchanger(); + + // Act + var result = await exchanger.ExchangeAsync(TokenScope.AzureSql, CancellationToken.None); + + // Assert + result.Token.ShouldBe(expected.Token); + result.ExpiresOn.ShouldBe(expected.ExpiresOn); + await this.credential.Received(1).GetTokenAsync(Arg.Is(context => context.Scopes.Single() == AzureSqlScope), Arg.Any()); + } + + /// + /// Verifies that repeated exchanges for the same resource reuse one credential. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Given_TwoExchangesForSameResource_When_ExchangeAsync_Then_CreatesCredentialOnce() + { + // Arrange + var exchanger = this.CreateExchanger(); + + // Act + await exchanger.ExchangeAsync(TokenScope.AzureSql, CancellationToken.None); + await exchanger.ExchangeAsync(TokenScope.AzureSql, CancellationToken.None); + + // Assert + this.credentialFactory.Received(1).Create(TenantId, ClientId, Arg.Any>>()); + } + + /// + /// Verifies that resources federating through different service accounts get their own credentials, each + /// requesting its own scope. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Given_TwoResourcesWithDifferentServiceAccounts_When_ExchangeAsync_Then_CreatesOneCredentialEach() + { + // Arrange + var exchanger = this.CreateExchanger(); + + // Act + await exchanger.ExchangeAsync(TokenScope.AzureSql, CancellationToken.None); + await exchanger.ExchangeAsync(TokenScope.BlobStorage, CancellationToken.None); + + // Assert + this.credentialFactory.Received(2).Create(TenantId, ClientId, Arg.Any>>()); + await this.credential.Received(1).GetTokenAsync(Arg.Is(context => context.Scopes.Single() == AzureSqlScope), Arg.Any()); + await this.credential.Received(1).GetTokenAsync(Arg.Is(context => context.Scopes.Single() == BlobStorageScope), Arg.Any()); + } + + /// + /// Verifies that the client assertion callback asks for the resource's service account and forwards the + /// cancellation token the credential passes to it, not the token of the call that created the credential. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Given_CreatedCredential_When_CredentialInvokesAssertionCallback_Then_GoogleProviderReceivesServiceAccountAndCredentialsCancellationToken() + { + // Arrange + this.googleIdTokenProvider.GetIdTokenAsync(Arg.Any(), Arg.Any()).Returns("id-token"); + using var callerCancellation = new CancellationTokenSource(); + using var credentialCancellation = new CancellationTokenSource(); + var exchanger = this.CreateExchanger(); + await exchanger.ExchangeAsync(TokenScope.BlobStorage, callerCancellation.Token); + + // Act + var assertion = await this.assertionCallbacks.ShouldHaveSingleItem()(credentialCancellation.Token); + + // Assert + assertion.ShouldBe("id-token"); + await this.googleIdTokenProvider.Received(1).GetIdTokenAsync(BlobStorageServiceAccountEmail, credentialCancellation.Token); + await this.googleIdTokenProvider.DidNotReceive().GetIdTokenAsync(Arg.Any(), callerCancellation.Token); + } + + /// + /// Verifies that exchanging for a resource without a section fails with a message naming the section. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Given_UnconfiguredResource_When_ExchangeAsync_Then_ThrowsInvalidOperationExceptionNamingSection() + { + // Arrange + var exchanger = this.CreateExchanger(new WorkloadIdentityOptions { AzureSql = CreateGoogleResource(AzureSqlServiceAccountEmail) }); + + // Act + var exception = await Should.ThrowAsync(() => exchanger.ExchangeAsync(TokenScope.BlobStorage, CancellationToken.None)); + + // Assert + exception.Message.ShouldContain("Csag.WorkloadIdentity:BlobStorage"); + } + + /// + /// Creates a resource section that federates through the given service account. + /// + /// The service account. + /// The section. + private static WorkloadIdentityResourceOptions CreateGoogleResource(string serviceAccountEmail) + { + return new WorkloadIdentityResourceOptions + { + Provider = WorkloadIdentityProvider.Google, + Google = new GoogleOptions + { + TenantId = TenantId, + ClientId = ClientId, + ServiceAccountEmail = serviceAccountEmail, + }, + }; + } + + /// + /// Creates the exchanger under test, configured with both resources unless other options are given. + /// + /// The options, or for both resources over Google federation. + /// The exchanger. + private GoogleFederatedTokenExchanger CreateExchanger(WorkloadIdentityOptions? options = null) + { + options ??= new WorkloadIdentityOptions + { + AzureSql = CreateGoogleResource(AzureSqlServiceAccountEmail), + BlobStorage = CreateGoogleResource(BlobStorageServiceAccountEmail), + }; + + return new GoogleFederatedTokenExchanger(Options.Create(options), this.googleIdTokenProvider, this.credentialFactory, NullLogger.Instance); + } + } +} diff --git a/Csag.WorkloadIdentity.UnitTests/GoogleIdTokenProviderTests.cs b/Csag.WorkloadIdentity.UnitTests/GoogleIdTokenProviderTests.cs index 7181d9c..582f356 100644 --- a/Csag.WorkloadIdentity.UnitTests/GoogleIdTokenProviderTests.cs +++ b/Csag.WorkloadIdentity.UnitTests/GoogleIdTokenProviderTests.cs @@ -1,10 +1,8 @@ namespace Csag.WorkloadIdentity.UnitTests { using Csag.WorkloadIdentity.Internal; - using Csag.WorkloadIdentity.Options; using Google.Cloud.Iam.Credentials.V1; using Microsoft.Extensions.Logging.Abstractions; - using Microsoft.Extensions.Options; using NSubstitute; using Shouldly; @@ -14,7 +12,7 @@ public class GoogleIdTokenProviderTests { /// - /// The service account the provider under test impersonates. + /// The service account the tests mint tokens for. /// private const string ServiceAccountEmail = "sa@example.iam.gserviceaccount.com"; @@ -44,11 +42,11 @@ public class GoogleIdTokenProviderTests public GoogleIdTokenProviderTests() { this.clientFactory.CreateAsync(Arg.Any()).Returns(this.client); - this.provider = new GoogleIdTokenProvider(CreateOptions(), this.clientFactory, NullLogger.Instance); + this.provider = new GoogleIdTokenProvider(this.clientFactory, NullLogger.Instance); } /// - /// Verifies that the provider requests a token for the configured service account with the Azure AD audience. + /// Verifies that the provider requests a token for the given service account with the Azure AD audience. /// /// A task that represents the asynchronous operation. [Fact] @@ -58,7 +56,7 @@ public async Task Given_Client_When_GetIdTokenAsync_Then_RequestsTokenForService this.SetupGeneratedToken(IdToken); // Act - var result = await this.provider.GetIdTokenAsync(CancellationToken.None); + var result = await this.provider.GetIdTokenAsync(ServiceAccountEmail, CancellationToken.None); // Assert result.ShouldBe(IdToken); @@ -71,18 +69,18 @@ await this.client.Received(1).GenerateIdTokenAsync( } /// - /// Verifies that repeated calls reuse one client. + /// Verifies that repeated calls, even for different service accounts, reuse one client. /// /// A task that represents the asynchronous operation. [Fact] - public async Task Given_TwoCalls_When_GetIdTokenAsync_Then_CreatesClientOnce() + public async Task Given_TwoCallsForDifferentServiceAccounts_When_GetIdTokenAsync_Then_CreatesClientOnce() { // Arrange this.SetupGeneratedToken(IdToken); // Act - await this.provider.GetIdTokenAsync(CancellationToken.None); - await this.provider.GetIdTokenAsync(CancellationToken.None); + await this.provider.GetIdTokenAsync(ServiceAccountEmail, CancellationToken.None); + await this.provider.GetIdTokenAsync("other@example.iam.gserviceaccount.com", CancellationToken.None); // Assert await this.clientFactory.Received(1).CreateAsync(Arg.Any()); @@ -103,8 +101,8 @@ public async Task Given_FailedClientCreation_When_GetIdTokenAsyncAgain_Then_Retr this.SetupGeneratedToken(IdToken); // Act - var failure = await Should.ThrowAsync(() => this.provider.GetIdTokenAsync(CancellationToken.None)); - var result = await this.provider.GetIdTokenAsync(CancellationToken.None); + var failure = await Should.ThrowAsync(() => this.provider.GetIdTokenAsync(ServiceAccountEmail, CancellationToken.None)); + var result = await this.provider.GetIdTokenAsync(ServiceAccountEmail, CancellationToken.None); // Assert failure.Message.ShouldBe("no application default credentials"); @@ -172,38 +170,29 @@ public async Task Given_EmptyTokenInResponse_When_GetIdTokenAsync_Then_ThrowsInv this.SetupGeneratedToken(string.Empty); // Act - var exception = await Should.ThrowAsync(() => this.provider.GetIdTokenAsync(CancellationToken.None)); + var exception = await Should.ThrowAsync(() => this.provider.GetIdTokenAsync(ServiceAccountEmail, CancellationToken.None)); // Assert exception.Message.ShouldContain(ServiceAccountEmail); } /// - /// Verifies that the provider cannot be constructed without the Google options. + /// Verifies that a blank service account email is rejected before any client is created. /// - [Fact] - public void Given_MissingGoogleOptions_When_Constructed_Then_ThrowsArgumentNullException() + /// The blank service account email. + /// A task that represents the asynchronous operation. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Given_BlankServiceAccountEmail_When_GetIdTokenAsync_Then_ThrowsArgumentException(string? serviceAccountEmail) { - // Arrange - var options = Options.Create(new AzureSqlFederatedIdentityOptions()); - // Act - var exception = Should.Throw(() => new GoogleIdTokenProvider(options, this.clientFactory, NullLogger.Instance)); + var exception = await Should.ThrowAsync(() => this.provider.GetIdTokenAsync(serviceAccountEmail!, CancellationToken.None)); // Assert - exception.ParamName.ShouldNotBeNull().ShouldContain(nameof(AzureSqlFederatedIdentityOptions.Google)); - } - - /// - /// Creates options naming the test service account. - /// - /// The options. - private static IOptions CreateOptions() - { - return Options.Create(new AzureSqlFederatedIdentityOptions - { - Google = new GoogleOptions { ServiceAccountEmail = ServiceAccountEmail }, - }); + exception.ParamName.ShouldBe("serviceAccountEmail"); + await this.clientFactory.DidNotReceiveWithAnyArgs().CreateAsync(CancellationToken.None); } /// diff --git a/Csag.WorkloadIdentity.UnitTests/ManagedIdentityTokenExchangerTests.cs b/Csag.WorkloadIdentity.UnitTests/ManagedIdentityTokenExchangerTests.cs new file mode 100644 index 0000000..fe5e120 --- /dev/null +++ b/Csag.WorkloadIdentity.UnitTests/ManagedIdentityTokenExchangerTests.cs @@ -0,0 +1,200 @@ +namespace Csag.WorkloadIdentity.UnitTests +{ + using Azure.Core; + using Csag.WorkloadIdentity.Internal; + using Csag.WorkloadIdentity.Options; + using Microsoft.Extensions.Logging.Abstractions; + using Microsoft.Extensions.Options; + using NSubstitute; + using Shouldly; + + /// + /// Unit tests for the class. + /// + public class ManagedIdentityTokenExchangerTests + { + /// + /// The client ID of the user-assigned managed identity used by the tests. + /// + private const string UserAssignedClientId = "11111111-2222-3333-4444-555555555555"; + + /// + /// The OAuth 2.0 scope of Azure SQL. + /// + private const string AzureSqlScope = "https://database.windows.net/.default"; + + /// + /// The OAuth 2.0 scope of Blob Storage. + /// + private const string BlobStorageScope = "https://storage.azure.com/.default"; + + /// + /// The substituted credential factory. + /// + private readonly IManagedIdentityCredentialFactory credentialFactory = Substitute.For(); + + /// + /// The substituted credential the factory hands out. + /// + private readonly TokenCredential credential = Substitute.For(); + + /// + /// Initializes a new instance of the class. + /// + public ManagedIdentityTokenExchangerTests() + { + this.credentialFactory.Create(Arg.Any()).Returns(this.credential); + this.credential.GetTokenAsync(Arg.Any(), Arg.Any()) + .Returns(new AccessToken("access-token", DateTimeOffset.UtcNow.AddHours(1))); + } + + /// + /// Verifies that the exchanger identifies itself as the managed identity provider. + /// + [Fact] + public void Given_Exchanger_When_Provider_Then_IsManagedIdentity() + { + // Act + var provider = this.CreateExchanger(new WorkloadIdentityOptions { AzureSql = CreateSystemAssignedResource() }).Provider; + + // Assert + provider.ShouldBe(WorkloadIdentityProvider.ManagedIdentity); + } + + /// + /// Verifies that a system-assigned identity gets a credential for it and the resource's scope is requested. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Given_SystemAssignedIdentity_When_ExchangeAsync_Then_CreatesCredentialForItAndRequestsScope() + { + // Arrange + var expected = new AccessToken("sql-token", DateTimeOffset.UtcNow.AddHours(2)); + this.credential.GetTokenAsync(Arg.Any(), Arg.Any()).Returns(expected); + var exchanger = this.CreateExchanger(new WorkloadIdentityOptions { AzureSql = CreateSystemAssignedResource() }); + + // Act + var result = await exchanger.ExchangeAsync(TokenScope.AzureSql, CancellationToken.None); + + // Assert + result.Token.ShouldBe(expected.Token); + result.ExpiresOn.ShouldBe(expected.ExpiresOn); + this.credentialFactory.Received(1).Create(Arg.Is(identity => identity.UseSystemAssignedIdentity)); + await this.credential.Received(1).GetTokenAsync(Arg.Is(context => context.Scopes.Single() == AzureSqlScope), Arg.Any()); + } + + /// + /// Verifies that a user-assigned identity gets a credential for its client ID. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Given_UserAssignedIdentity_When_ExchangeAsync_Then_CreatesCredentialForClientId() + { + // Arrange + var exchanger = this.CreateExchanger(new WorkloadIdentityOptions { BlobStorage = CreateUserAssignedResource() }); + + // Act + await exchanger.ExchangeAsync(TokenScope.BlobStorage, CancellationToken.None); + + // Assert + this.credentialFactory.Received(1).Create(Arg.Is(identity => !identity.UseSystemAssignedIdentity && identity.ClientId == UserAssignedClientId)); + await this.credential.Received(1).GetTokenAsync(Arg.Is(context => context.Scopes.Single() == BlobStorageScope), Arg.Any()); + } + + /// + /// Verifies that repeated exchanges for the same resource reuse one credential. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Given_TwoExchangesForSameResource_When_ExchangeAsync_Then_CreatesCredentialOnce() + { + // Arrange + var exchanger = this.CreateExchanger(new WorkloadIdentityOptions { AzureSql = CreateUserAssignedResource() }); + + // Act + await exchanger.ExchangeAsync(TokenScope.AzureSql, CancellationToken.None); + await exchanger.ExchangeAsync(TokenScope.AzureSql, CancellationToken.None); + + // Assert + this.credentialFactory.Received(1).Create(Arg.Any()); + } + + /// + /// Verifies that two resources using the same identity share one credential while each requests its own scope. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Given_TwoResourcesWithSameIdentity_When_ExchangeAsync_Then_SharesOneCredential() + { + // Arrange + var exchanger = this.CreateExchanger(new WorkloadIdentityOptions + { + AzureSql = CreateSystemAssignedResource(), + BlobStorage = CreateSystemAssignedResource(), + }); + + // Act + await exchanger.ExchangeAsync(TokenScope.AzureSql, CancellationToken.None); + await exchanger.ExchangeAsync(TokenScope.BlobStorage, CancellationToken.None); + + // Assert + this.credentialFactory.Received(1).Create(Arg.Any()); + await this.credential.Received(1).GetTokenAsync(Arg.Is(context => context.Scopes.Single() == AzureSqlScope), Arg.Any()); + await this.credential.Received(1).GetTokenAsync(Arg.Is(context => context.Scopes.Single() == BlobStorageScope), Arg.Any()); + } + + /// + /// Verifies that exchanging for a resource without a section fails with a message naming the section. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Given_UnconfiguredResource_When_ExchangeAsync_Then_ThrowsInvalidOperationExceptionNamingSection() + { + // Arrange + var exchanger = this.CreateExchanger(new WorkloadIdentityOptions { BlobStorage = CreateSystemAssignedResource() }); + + // Act + var exception = await Should.ThrowAsync(() => exchanger.ExchangeAsync(TokenScope.AzureSql, CancellationToken.None)); + + // Assert + exception.Message.ShouldContain("Csag.WorkloadIdentity:AzureSql"); + this.credentialFactory.DidNotReceiveWithAnyArgs().Create(null!); + } + + /// + /// Creates a resource section that uses the system-assigned managed identity. + /// + /// The section. + private static WorkloadIdentityResourceOptions CreateSystemAssignedResource() + { + return new WorkloadIdentityResourceOptions + { + Provider = WorkloadIdentityProvider.ManagedIdentity, + ManagedIdentity = new ManagedIdentityOptions { UseSystemAssignedIdentity = true }, + }; + } + + /// + /// Creates a resource section that uses the user-assigned managed identity. + /// + /// The section. + private static WorkloadIdentityResourceOptions CreateUserAssignedResource() + { + return new WorkloadIdentityResourceOptions + { + Provider = WorkloadIdentityProvider.ManagedIdentity, + ManagedIdentity = new ManagedIdentityOptions { ClientId = UserAssignedClientId }, + }; + } + + /// + /// Creates the exchanger under test. + /// + /// The options. + /// The exchanger. + private ManagedIdentityTokenExchanger CreateExchanger(WorkloadIdentityOptions options) + { + return new ManagedIdentityTokenExchanger(Options.Create(options), this.credentialFactory, NullLogger.Instance); + } + } +} diff --git a/Csag.WorkloadIdentity.UnitTests/ResourceTokenProviderTests.cs b/Csag.WorkloadIdentity.UnitTests/ResourceTokenProviderTests.cs new file mode 100644 index 0000000..c1ec875 --- /dev/null +++ b/Csag.WorkloadIdentity.UnitTests/ResourceTokenProviderTests.cs @@ -0,0 +1,285 @@ +namespace Csag.WorkloadIdentity.UnitTests +{ + using Azure.Core; + using Csag.WorkloadIdentity.Internal; + using Microsoft.Extensions.Logging.Abstractions; + using Microsoft.Extensions.Time.Testing; + using NSubstitute; + using Shouldly; + + /// + /// Unit tests for the class. + /// + public class ResourceTokenProviderTests + { + /// + /// The resource the provider under test holds the token for. + /// + private const TokenScope Scope = TokenScope.AzureSql; + + /// + /// The value of the first token the exchanger hands out. + /// + private const string FirstToken = "first-token"; + + /// + /// The value of the second token the exchanger hands out. + /// + private const string SecondToken = "second-token"; + + /// + /// The refresh-ahead window the provider under test is configured with. + /// + private static readonly TimeSpan RefreshAheadWindow = TimeSpan.FromMinutes(5); + + /// + /// The instant at which every test starts. + /// + private static readonly DateTimeOffset StartTime = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero); + + /// + /// The controllable clock the provider under test reads. + /// + private readonly FakeTimeProvider timeProvider = new(StartTime); + + /// + /// The substituted token exchanger. + /// + private readonly IWorkloadIdentityTokenExchanger exchanger = Substitute.For(); + + /// + /// Verifies that the first call obtains a token and returns it with its real expiry. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Given_NoHeldToken_When_GetAccessTokenAsync_Then_ExchangesAndReturnsTokenWithExpiry() + { + // Arrange + var expiresOn = StartTime.AddHours(1); + this.SetupExchange(new AccessToken(FirstToken, expiresOn)); + using var provider = this.CreateProvider(); + + // Act + var result = await provider.GetAccessTokenAsync(CancellationToken.None); + + // Assert + result.Token.ShouldBe(FirstToken); + result.ExpiresOn.ShouldBe(expiresOn); + await this.ShouldHaveExchangedAsync(1); + } + + /// + /// Verifies that a held token outside the refresh-ahead window is returned without another exchange. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Given_HeldTokenOutsideRefreshWindow_When_GetAccessTokenAsync_Then_ReturnsHeldTokenWithoutExchange() + { + // Arrange + this.SetupExchange(new AccessToken(FirstToken, StartTime.AddHours(1))); + using var provider = this.CreateProvider(); + await provider.GetAccessTokenAsync(CancellationToken.None); + + // Act + var result = await provider.GetAccessTokenAsync(CancellationToken.None); + + // Assert + result.Token.ShouldBe(FirstToken); + await this.ShouldHaveExchangedAsync(1); + } + + /// + /// Verifies that a token with one tick more than the refresh-ahead window left is still returned. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Given_HeldTokenOneTickOutsideRefreshWindow_When_GetAccessTokenAsync_Then_ReturnsHeldToken() + { + // Arrange + var expiresOn = StartTime.AddHours(1); + this.SetupExchange(new AccessToken(FirstToken, expiresOn), new AccessToken(SecondToken, expiresOn.AddHours(1))); + using var provider = this.CreateProvider(); + await provider.GetAccessTokenAsync(CancellationToken.None); + this.timeProvider.SetUtcNow(expiresOn - RefreshAheadWindow - TimeSpan.FromTicks(1)); + + // Act + var result = await provider.GetAccessTokenAsync(CancellationToken.None); + + // Assert + result.Token.ShouldBe(FirstToken); + await this.ShouldHaveExchangedAsync(1); + } + + /// + /// Verifies that a token with exactly the refresh-ahead window left is replaced. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Given_HeldTokenEnteringRefreshWindow_When_GetAccessTokenAsync_Then_ExchangesNewToken() + { + // Arrange + var expiresOn = StartTime.AddHours(1); + this.SetupExchange(new AccessToken(FirstToken, expiresOn), new AccessToken(SecondToken, expiresOn.AddHours(1))); + using var provider = this.CreateProvider(); + await provider.GetAccessTokenAsync(CancellationToken.None); + this.timeProvider.SetUtcNow(expiresOn - RefreshAheadWindow); + + // Act + var result = await provider.GetAccessTokenAsync(CancellationToken.None); + + // Assert + result.Token.ShouldBe(SecondToken); + await this.ShouldHaveExchangedAsync(2); + } + + /// + /// Verifies that a token which arrives with less than the refresh-ahead window left is used for the call + /// that fetched it and replaced by the next call, with exactly one exchange per call. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Given_ExchangedTokenAlreadyInsideRefreshWindow_When_GetAccessTokenAsync_Then_UsesItAndRefreshesOnNextCall() + { + // Arrange + var shortLivedExpiry = StartTime.AddMinutes(2); + this.SetupExchange(new AccessToken(FirstToken, shortLivedExpiry), new AccessToken(SecondToken, shortLivedExpiry)); + using var provider = this.CreateProvider(); + + // Act + var first = await provider.GetAccessTokenAsync(CancellationToken.None); + var second = await provider.GetAccessTokenAsync(CancellationToken.None); + + // Assert + first.Token.ShouldBe(FirstToken); + second.Token.ShouldBe(SecondToken); + await this.ShouldHaveExchangedAsync(2); + } + + /// + /// Verifies that concurrent callers on a cold provider share a single exchange. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Given_FiftyConcurrentCallersAndNoHeldToken_When_GetAccessTokenAsync_Then_ExchangesExactlyOnce() + { + // Arrange + var exchange = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + this.exchanger.ExchangeAsync(Scope, Arg.Any()).Returns(exchange.Task); + using var provider = this.CreateProvider(); + + // Act + var calls = Enumerable.Range(0, 50) + .Select(_ => Task.Run(() => provider.GetAccessTokenAsync(CancellationToken.None))) + .ToArray(); + await Task.Delay(TimeSpan.FromMilliseconds(200)); + exchange.SetResult(new AccessToken(FirstToken, StartTime.AddHours(1))); + var results = await Task.WhenAll(calls); + + // Assert + results.ShouldAllBe(token => token.Token == FirstToken); + await this.ShouldHaveExchangedAsync(1); + } + + /// + /// Verifies that a failed exchange is not retained and the next call exchanges again. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Given_FailedExchange_When_GetAccessTokenAsyncAgain_Then_RetriesExchange() + { + // Arrange + this.exchanger.ExchangeAsync(Scope, Arg.Any()) + .Returns( + _ => throw new InvalidOperationException("exchange failed"), + _ => new AccessToken(FirstToken, StartTime.AddHours(1))); + using var provider = this.CreateProvider(); + + // Act + var failure = await Should.ThrowAsync(() => provider.GetAccessTokenAsync(CancellationToken.None)); + var result = await provider.GetAccessTokenAsync(CancellationToken.None); + + // Assert + failure.Message.ShouldBe("exchange failed"); + result.Token.ShouldBe(FirstToken); + await this.ShouldHaveExchangedAsync(2); + } + + /// + /// Verifies that a forced refresh replaces a token that is not yet due and reports the new expiry. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Given_HeldTokenOutsideRefreshWindow_When_RefreshAsync_Then_ExchangesNewTokenAndReturnsItsExpiry() + { + // Arrange + var secondExpiry = StartTime.AddHours(2); + this.SetupExchange(new AccessToken(FirstToken, StartTime.AddHours(1)), new AccessToken(SecondToken, secondExpiry)); + using var provider = this.CreateProvider(); + await provider.GetAccessTokenAsync(CancellationToken.None); + + // Act + var expiresOn = await provider.RefreshAsync(CancellationToken.None); + var result = await provider.GetAccessTokenAsync(CancellationToken.None); + + // Assert + expiresOn.ShouldBe(secondExpiry); + result.Token.ShouldBe(SecondToken); + await this.ShouldHaveExchangedAsync(2); + } + + /// + /// Verifies that a forced refresh arriving while a caller's exchange is in flight shares that exchange. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Given_ExchangeInFlight_When_RefreshAsync_Then_SharesThatExchange() + { + // Arrange + var expiresOn = StartTime.AddHours(1); + var exchange = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + this.exchanger.ExchangeAsync(Scope, Arg.Any()).Returns(exchange.Task); + using var provider = this.CreateProvider(); + + // Act + var call = Task.Run(() => provider.GetAccessTokenAsync(CancellationToken.None)); + await Task.Delay(TimeSpan.FromMilliseconds(200)); + var refresh = Task.Run(() => provider.RefreshAsync(CancellationToken.None)); + await Task.Delay(TimeSpan.FromMilliseconds(200)); + exchange.SetResult(new AccessToken(FirstToken, expiresOn)); + + // Assert + (await call).Token.ShouldBe(FirstToken); + (await refresh).ShouldBe(expiresOn); + await this.ShouldHaveExchangedAsync(1); + } + + /// + /// Configures the exchanger to hand out the given tokens in order, repeating the last one. + /// + /// The tokens to hand out. + private void SetupExchange(params AccessToken[] tokens) + { + this.exchanger.ExchangeAsync(Scope, Arg.Any()) + .Returns(tokens[0], tokens.Skip(1).ToArray()); + } + + /// + /// Asserts how many times the exchanger was asked for a token. + /// + /// The expected number of exchanges. + /// A task that represents the asynchronous operation. + private async Task ShouldHaveExchangedAsync(int times) + { + await this.exchanger.Received(times).ExchangeAsync(Scope, Arg.Any()); + } + + /// + /// Creates the provider under test. + /// + /// The provider. + private ResourceTokenProvider CreateProvider() + { + return new ResourceTokenProvider(Scope, this.exchanger, RefreshAheadWindow, this.timeProvider, NullLogger.Instance); + } + } +} diff --git a/Csag.WorkloadIdentity.UnitTests/AzureSqlTokenRefreshServiceTests.cs b/Csag.WorkloadIdentity.UnitTests/TokenRefreshServiceTests.cs similarity index 56% rename from Csag.WorkloadIdentity.UnitTests/AzureSqlTokenRefreshServiceTests.cs rename to Csag.WorkloadIdentity.UnitTests/TokenRefreshServiceTests.cs index a083166..fa3317c 100644 --- a/Csag.WorkloadIdentity.UnitTests/AzureSqlTokenRefreshServiceTests.cs +++ b/Csag.WorkloadIdentity.UnitTests/TokenRefreshServiceTests.cs @@ -4,6 +4,7 @@ using Csag.WorkloadIdentity.Internal; using Csag.WorkloadIdentity.Internal.Services; using Csag.WorkloadIdentity.Options; + using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.Extensions.Time.Testing; @@ -12,9 +13,9 @@ using Shouldly; /// - /// Unit tests for the class. + /// Unit tests for the class. /// - public class AzureSqlTokenRefreshServiceTests + public class TokenRefreshServiceTests { /// /// The instant at which every test starts. @@ -27,7 +28,7 @@ public class AzureSqlTokenRefreshServiceTests private static readonly TimeSpan RefreshAheadWindow = TimeSpan.FromMinutes(5); /// - /// The lifetime of the tokens the refresher hands out unless a test says otherwise. + /// The lifetime of the tokens the refreshers hand out unless a test says otherwise. /// private static readonly TimeSpan TokenLifetime = TimeSpan.FromHours(1); @@ -52,19 +53,24 @@ public class AzureSqlTokenRefreshServiceTests private readonly TimerCountingTimeProvider timeProvider = new(StartTime); /// - /// The substituted token provider, which also supports forced refresh. + /// The substituted Azure SQL token provider, which also supports forced refresh. /// - private readonly IAzureSqlTokenProvider tokenProvider = Substitute.For(); + private readonly IAzureSqlTokenProvider azureSqlTokenProvider = Substitute.For(); /// - /// The number of refreshes the service has requested so far. + /// The substituted Blob Storage token provider, which also supports forced refresh. /// - private int refreshCount; + private readonly IBlobStorageTokenProvider blobStorageTokenProvider = Substitute.For(); /// - /// Gets the number of refreshes the service has requested so far. + /// Counts the refreshes the service requested from the Azure SQL provider. /// - private int RefreshCount => Volatile.Read(ref this.refreshCount); + private readonly RefreshCounter azureSqlRefreshes = new(); + + /// + /// Counts the refreshes the service requested from the Blob Storage provider. + /// + private readonly RefreshCounter blobStorageRefreshes = new(); /// /// Verifies that the service refreshes once at start and again exactly when the refresh-ahead point is reached. @@ -74,7 +80,7 @@ public class AzureSqlTokenRefreshServiceTests public async Task Given_RefreshedToken_When_RefreshAheadPointIsReached_Then_RefreshesAgain() { // Arrange - this.SetupRefresh(_ => this.timeProvider.GetUtcNow() + TokenLifetime); + SetupRefresh(this.azureSqlTokenProvider, this.azureSqlRefreshes, _ => this.timeProvider.GetUtcNow() + TokenLifetime); using var service = this.CreateService(); await service.StartAsync(CancellationToken.None); await this.WaitForScheduledRefreshAsync(1); @@ -82,13 +88,13 @@ public async Task Given_RefreshedToken_When_RefreshAheadPointIsReached_Then_Refr // Act this.timeProvider.Advance(TokenLifetime - RefreshAheadWindow - TimeSpan.FromSeconds(1)); await Task.Delay(SettleTime); - var refreshesBeforeRefreshPoint = this.RefreshCount; + var refreshesBeforeRefreshPoint = this.azureSqlRefreshes.Count; this.timeProvider.Advance(TimeSpan.FromSeconds(1)); - await WaitUntilAsync(() => this.RefreshCount >= 2); + await WaitUntilAsync(() => this.azureSqlRefreshes.Count >= 2); // Assert refreshesBeforeRefreshPoint.ShouldBe(1); - this.RefreshCount.ShouldBe(2); + this.azureSqlRefreshes.Count.ShouldBe(2); await service.StopAsync(CancellationToken.None); } @@ -102,7 +108,7 @@ public async Task Given_TokenShorterThanRefreshAheadWindow_When_HalfItsLifetimeE { // Arrange var lifetime = TimeSpan.FromMinutes(2); - this.SetupRefresh(_ => this.timeProvider.GetUtcNow() + lifetime); + SetupRefresh(this.azureSqlTokenProvider, this.azureSqlRefreshes, _ => this.timeProvider.GetUtcNow() + lifetime); using var service = this.CreateService(); await service.StartAsync(CancellationToken.None); await this.WaitForScheduledRefreshAsync(1); @@ -110,13 +116,13 @@ public async Task Given_TokenShorterThanRefreshAheadWindow_When_HalfItsLifetimeE // Act this.timeProvider.Advance(TimeSpan.FromSeconds(59)); await Task.Delay(SettleTime); - var refreshesBeforeHalfLifetime = this.RefreshCount; + var refreshesBeforeHalfLifetime = this.azureSqlRefreshes.Count; this.timeProvider.Advance(TimeSpan.FromSeconds(1)); - await WaitUntilAsync(() => this.RefreshCount >= 2); + await WaitUntilAsync(() => this.azureSqlRefreshes.Count >= 2); // Assert refreshesBeforeHalfLifetime.ShouldBe(1); - this.RefreshCount.ShouldBe(2); + this.azureSqlRefreshes.Count.ShouldBe(2); await service.StopAsync(CancellationToken.None); } @@ -128,7 +134,9 @@ public async Task Given_TokenShorterThanRefreshAheadWindow_When_HalfItsLifetimeE public async Task Given_FailingRefresh_When_RetryDelayElapses_Then_RetriesRefresh() { // Arrange - this.SetupRefresh( + SetupRefresh( + this.azureSqlTokenProvider, + this.azureSqlRefreshes, _ => throw new InvalidOperationException("refresh failed"), _ => this.timeProvider.GetUtcNow() + TokenLifetime); using var service = this.CreateService(); @@ -138,13 +146,13 @@ public async Task Given_FailingRefresh_When_RetryDelayElapses_Then_RetriesRefres // Act this.timeProvider.Advance(InitialRetryDelay - TimeSpan.FromSeconds(1)); await Task.Delay(SettleTime); - var refreshesBeforeRetryPoint = this.RefreshCount; + var refreshesBeforeRetryPoint = this.azureSqlRefreshes.Count; this.timeProvider.Advance(TimeSpan.FromSeconds(1)); - await WaitUntilAsync(() => this.RefreshCount >= 2); + await WaitUntilAsync(() => this.azureSqlRefreshes.Count >= 2); // Assert refreshesBeforeRetryPoint.ShouldBe(1); - this.RefreshCount.ShouldBe(2); + this.azureSqlRefreshes.Count.ShouldBe(2); service.ExecuteTask.ShouldNotBeNull().IsCompleted.ShouldBeFalse(); await service.StopAsync(CancellationToken.None); } @@ -157,7 +165,7 @@ public async Task Given_FailingRefresh_When_RetryDelayElapses_Then_RetriesRefres public async Task Given_RunningService_When_Stopped_Then_CompletesWithoutError() { // Arrange - this.SetupRefresh(_ => this.timeProvider.GetUtcNow() + TokenLifetime); + SetupRefresh(this.azureSqlTokenProvider, this.azureSqlRefreshes, _ => this.timeProvider.GetUtcNow() + TokenLifetime); using var service = this.CreateService(); await service.StartAsync(CancellationToken.None); await this.WaitForScheduledRefreshAsync(1); @@ -167,7 +175,7 @@ public async Task Given_RunningService_When_Stopped_Then_CompletesWithoutError() // Assert service.ExecuteTask.ShouldNotBeNull().Status.ShouldBe(TaskStatus.RanToCompletion); - this.RefreshCount.ShouldBe(1); + this.azureSqlRefreshes.Count.ShouldBe(1); } /// @@ -178,7 +186,7 @@ public async Task Given_RunningService_When_Stopped_Then_CompletesWithoutError() public async Task Given_BackgroundRefreshDisabled_When_Started_Then_DoesNotRefresh() { // Arrange - this.SetupRefresh(_ => this.timeProvider.GetUtcNow() + TokenLifetime); + SetupRefresh(this.azureSqlTokenProvider, this.azureSqlRefreshes, _ => this.timeProvider.GetUtcNow() + TokenLifetime); using var service = this.CreateService(enableBackgroundRefresh: false); // Act @@ -187,11 +195,12 @@ public async Task Given_BackgroundRefreshDisabled_When_Started_Then_DoesNotRefre // Assert service.ExecuteTask.Status.ShouldBe(TaskStatus.RanToCompletion); - this.RefreshCount.ShouldBe(0); + this.azureSqlRefreshes.Count.ShouldBe(0); } /// - /// Verifies that a token provider without forced-refresh support leaves the service idle instead of failing. + /// Verifies that a consumer-substituted token provider without forced-refresh support is skipped instead of + /// failing the service. /// /// A task that represents the asynchronous operation. [Fact] @@ -199,7 +208,7 @@ public async Task Given_TokenProviderWithoutRefreshSupport_When_Started_Then_Com { // Arrange var plainProvider = Substitute.For(); - using var service = this.CreateService(plainProvider); + using var service = this.CreateService(azureSqlProvider: plainProvider); // Act await service.StartAsync(CancellationToken.None); @@ -208,6 +217,59 @@ public async Task Given_TokenProviderWithoutRefreshSupport_When_Started_Then_Com // Assert service.ExecuteTask.Status.ShouldBe(TaskStatus.RanToCompletion); await plainProvider.DidNotReceiveWithAnyArgs().GetAzureSqlAccessTokenAsync(CancellationToken.None); + await plainProvider.DidNotReceiveWithAnyArgs().GetAccessTokenAsync(CancellationToken.None); + } + + /// + /// Verifies that only the providers of configured resources are resolved: the Blob Storage provider is not + /// registered here, and the service must not ask for it. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Given_OnlyAzureSqlConfigured_When_Started_Then_RefreshesAzureSqlWithoutResolvingBlobStorage() + { + // Arrange + SetupRefresh(this.azureSqlTokenProvider, this.azureSqlRefreshes, _ => this.timeProvider.GetUtcNow() + TokenLifetime); + using var service = this.CreateService(); + + // Act + await service.StartAsync(CancellationToken.None); + await this.WaitForScheduledRefreshAsync(1); + + // Assert + service.ExecuteTask.ShouldNotBeNull().IsFaulted.ShouldBeFalse(); + this.azureSqlRefreshes.Count.ShouldBe(1); + await service.StopAsync(CancellationToken.None); + } + + /// + /// Verifies that two configured resources are refreshed by independent loops, each at its own refresh-ahead point. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Given_TwoConfiguredResources_When_TheirRefreshPointsAreReached_Then_RefreshesEachIndependently() + { + // Arrange + var blobStorageLifetime = TimeSpan.FromMinutes(40); + SetupRefresh(this.azureSqlTokenProvider, this.azureSqlRefreshes, _ => this.timeProvider.GetUtcNow() + TokenLifetime); + SetupRefresh(this.blobStorageTokenProvider, this.blobStorageRefreshes, _ => this.timeProvider.GetUtcNow() + blobStorageLifetime); + using var service = this.CreateService(configureBlobStorage: true); + await service.StartAsync(CancellationToken.None); + await this.WaitForScheduledRefreshAsync(2); + + // Act + this.timeProvider.Advance(blobStorageLifetime - RefreshAheadWindow); + await WaitUntilAsync(() => this.blobStorageRefreshes.Count >= 2); + await Task.Delay(SettleTime); + var azureSqlRefreshesAfterBlobStorageRefresh = this.azureSqlRefreshes.Count; + this.timeProvider.Advance(TokenLifetime - blobStorageLifetime); + await WaitUntilAsync(() => this.azureSqlRefreshes.Count >= 2); + + // Assert + azureSqlRefreshesAfterBlobStorageRefresh.ShouldBe(1); + this.azureSqlRefreshes.Count.ShouldBe(2); + this.blobStorageRefreshes.Count.ShouldBe(2); + await service.StopAsync(CancellationToken.None); } /// @@ -225,26 +287,29 @@ private static async Task WaitUntilAsync(Func condition) } /// - /// Configures the refresher to produce the given results in order, repeating the last one, and counts every request. + /// Configures a provider's refresher to produce the given results in order, repeating the last one, and + /// counts every request. /// + /// The substituted provider, which also implements . + /// Receives every request. /// The results to produce; a result may throw to simulate a failed refresh. - private void SetupRefresh(params Func[] results) + private static void SetupRefresh(IAccessTokenProvider tokenProvider, RefreshCounter counter, params Func[] results) { var counted = results .Select(result => new Func(call => { - Interlocked.Increment(ref this.refreshCount); + counter.Increment(); return result(call); })) .ToArray(); - ((IAzureSqlTokenRefresher)this.tokenProvider).RefreshAsync(Arg.Any()) + ((ITokenRefresher)tokenProvider).RefreshAsync(Arg.Any()) .Returns(counted[0], counted.Skip(1).ToArray()); } /// - /// Waits until the service has scheduled its next refresh (or retry) on the fake clock for the given time, - /// so that advancing the clock afterwards is guaranteed to reach that schedule. + /// Waits until the service has scheduled the given number of refreshes (or retries) on the fake clock, so + /// that advancing the clock afterwards is guaranteed to reach those schedules. /// /// The number of schedules to wait for, counted from the start of the service. /// A task that represents the asynchronous operation. @@ -255,20 +320,54 @@ private async Task WaitForScheduledRefreshAsync(int scheduledRefreshes) } /// - /// Creates the service under test. + /// Creates the service under test with the Azure SQL resource configured and its provider registered. /// - /// The token provider to hand the service, or for the refreshable substitute. /// Whether background refresh is enabled in the options. + /// Whether the Blob Storage resource is configured and its provider registered too. + /// The Azure SQL provider to register, or for the refreshable substitute. /// The service. - private AzureSqlTokenRefreshService CreateService(IAzureSqlTokenProvider? provider = null, bool enableBackgroundRefresh = true) + private TokenRefreshService CreateService(bool enableBackgroundRefresh = true, bool configureBlobStorage = false, IAzureSqlTokenProvider? azureSqlProvider = null) { - var options = Options.Create(new AzureSqlFederatedIdentityOptions + var services = new ServiceCollection(); + services.AddSingleton(azureSqlProvider ?? this.azureSqlTokenProvider); + if (configureBlobStorage) + { + services.AddSingleton(this.blobStorageTokenProvider); + } + + var options = Options.Create(new WorkloadIdentityOptions { + AzureSql = new WorkloadIdentityResourceOptions(), + BlobStorage = configureBlobStorage ? new WorkloadIdentityResourceOptions() : null, RefreshAheadWindow = RefreshAheadWindow, EnableBackgroundRefresh = enableBackgroundRefresh, }); - return new AzureSqlTokenRefreshService(provider ?? this.tokenProvider, options, this.timeProvider, NullLogger.Instance); + return new TokenRefreshService(services.BuildServiceProvider(), options, this.timeProvider, NullLogger.Instance); + } + + /// + /// A thread-safe counter of the refreshes requested from one provider. + /// + private sealed class RefreshCounter + { + /// + /// The number of refreshes so far. + /// + private int count; + + /// + /// Gets the number of refreshes so far. + /// + public int Count => Volatile.Read(ref this.count); + + /// + /// Records one refresh. + /// + public void Increment() + { + Interlocked.Increment(ref this.count); + } } /// diff --git a/Csag.WorkloadIdentity.UnitTests/TokenScopeTests.cs b/Csag.WorkloadIdentity.UnitTests/TokenScopeTests.cs new file mode 100644 index 0000000..b708035 --- /dev/null +++ b/Csag.WorkloadIdentity.UnitTests/TokenScopeTests.cs @@ -0,0 +1,62 @@ +namespace Csag.WorkloadIdentity.UnitTests +{ + using System.Reflection; + using Csag.WorkloadIdentity.Internal; + using Shouldly; + + /// + /// Unit tests for the metadata and the that read it. + /// + public class TokenScopeTests + { + /// + /// Verifies that each scope resolves to the OAuth 2.0 scope named by its metadata. + /// + /// The token scope. + /// The OAuth 2.0 scope expected for it. + [Theory] + [InlineData(TokenScope.AzureSql, "https://database.windows.net/.default")] + [InlineData(TokenScope.BlobStorage, "https://storage.azure.com/.default")] + public void Given_Scope_When_GetIdentifier_Then_ReturnsScopeFromMetadata(TokenScope scope, string expected) + { + // Act + var result = scope.GetIdentifier(); + + // Assert + result.ShouldBe(expected); + } + + /// + /// Verifies that every member of the enum carries metadata, so that a new resource cannot be added without its scope. + /// + [Fact] + public void Given_EveryMember_When_ReadingMetadata_Then_EachCarriesAnIdentifier() + { + foreach (var scope in Enum.GetValues()) + { + // Act + var attribute = typeof(TokenScope).GetField(scope.ToString()).ShouldNotBeNull().GetCustomAttribute(); + + // Assert + attribute.ShouldNotBeNull().Identifier.ShouldStartWith("https://"); + scope.GetIdentifier().ShouldBe(attribute.Identifier); + } + } + + /// + /// Verifies that a value outside the enum is rejected instead of silently yielding an empty scope. + /// + [Fact] + public void Given_UndefinedScope_When_GetIdentifier_Then_ThrowsArgumentOutOfRangeException() + { + // Arrange + var scope = (TokenScope)42; + + // Act + var exception = Should.Throw(() => scope.GetIdentifier()); + + // Assert + exception.ParamName.ShouldBe("scope"); + } + } +} diff --git a/Csag.WorkloadIdentity.UnitTests/WorkloadIdentityOptionsValidatorTests.cs b/Csag.WorkloadIdentity.UnitTests/WorkloadIdentityOptionsValidatorTests.cs new file mode 100644 index 0000000..81ebc27 --- /dev/null +++ b/Csag.WorkloadIdentity.UnitTests/WorkloadIdentityOptionsValidatorTests.cs @@ -0,0 +1,329 @@ +namespace Csag.WorkloadIdentity.UnitTests +{ + using Csag.WorkloadIdentity.Options; + using Shouldly; + + /// + /// Unit tests for the class. + /// + public class WorkloadIdentityOptionsValidatorTests + { + /// + /// The Microsoft Entra tenant ID used in complete Google sections. + /// + private const string TenantId = "tenant"; + + /// + /// The client ID used in complete Google and user-assigned managed identity sections. + /// + private const string ClientId = "client"; + + /// + /// The service account email used in complete Google sections. + /// + private const string ServiceAccountEmail = "sa@example.iam.gserviceaccount.com"; + + /// + /// The validator under test. + /// + private readonly WorkloadIdentityOptionsValidator validator = new(); + + /// + /// Verifies that Azure SQL over Google federation with every value set passes validation. + /// + [Fact] + public void Given_AzureSqlWithCompleteGoogleSection_When_Validate_Then_Succeeds() + { + // Arrange + var options = new WorkloadIdentityOptions { AzureSql = CreateGoogleResource() }; + + // Act + var result = this.validator.Validate(null, options); + + // Assert + result.Succeeded.ShouldBeTrue(); + } + + /// + /// Verifies that options without any resource section fail validation. + /// + [Fact] + public void Given_NoResourceConfigured_When_Validate_Then_Fails() + { + // Arrange + var options = new WorkloadIdentityOptions(); + + // Act + var result = this.validator.Validate(null, options); + + // Assert + result.Failed.ShouldBeTrue(); + result.FailureMessage.ShouldNotBeNull().ShouldContain("At least one resource section"); + } + + /// + /// Verifies that Blob Storage over the system-assigned managed identity passes validation without a client ID. + /// + [Fact] + public void Given_BlobStorageWithSystemAssignedManagedIdentity_When_Validate_Then_Succeeds() + { + // Arrange + var options = new WorkloadIdentityOptions { BlobStorage = CreateSystemAssignedResource() }; + + // Act + var result = this.validator.Validate(null, options); + + // Assert + result.Succeeded.ShouldBeTrue(); + } + + /// + /// Verifies that a user-assigned managed identity with a client ID passes validation. + /// + [Fact] + public void Given_AzureSqlWithUserAssignedManagedIdentity_When_Validate_Then_Succeeds() + { + // Arrange + var options = new WorkloadIdentityOptions + { + AzureSql = new WorkloadIdentityResourceOptions + { + Provider = WorkloadIdentityProvider.ManagedIdentity, + ManagedIdentity = new ManagedIdentityOptions { ClientId = ClientId }, + }, + }; + + // Act + var result = this.validator.Validate(null, options); + + // Assert + result.Succeeded.ShouldBeTrue(); + } + + /// + /// Verifies that both resources configured with different providers pass validation. + /// + [Fact] + public void Given_BothResourcesConfigured_When_Validate_Then_Succeeds() + { + // Arrange + var options = new WorkloadIdentityOptions + { + AzureSql = CreateGoogleResource(), + BlobStorage = CreateSystemAssignedResource(), + }; + + // Act + var result = this.validator.Validate(null, options); + + // Assert + result.Succeeded.ShouldBeTrue(); + } + + /// + /// Verifies that a blank value in the Google section fails validation with a message naming its path. + /// + /// The key of the blank value. + /// The blank value. + [Theory] + [InlineData(nameof(GoogleOptions.TenantId), null)] + [InlineData(nameof(GoogleOptions.TenantId), "")] + [InlineData(nameof(GoogleOptions.TenantId), " ")] + [InlineData(nameof(GoogleOptions.ClientId), null)] + [InlineData(nameof(GoogleOptions.ClientId), "")] + [InlineData(nameof(GoogleOptions.ClientId), " ")] + [InlineData(nameof(GoogleOptions.ServiceAccountEmail), null)] + [InlineData(nameof(GoogleOptions.ServiceAccountEmail), "")] + [InlineData(nameof(GoogleOptions.ServiceAccountEmail), " ")] + public void Given_BlankGoogleValue_When_Validate_Then_FailsNamingPath(string key, string? value) + { + // Arrange + var options = new WorkloadIdentityOptions { AzureSql = CreateGoogleResource() }; + var google = options.AzureSql.Google.ShouldNotBeNull(); + switch (key) + { + case nameof(GoogleOptions.TenantId): + google.TenantId = value; + break; + case nameof(GoogleOptions.ClientId): + google.ClientId = value; + break; + default: + google.ServiceAccountEmail = value; + break; + } + + // Act + var result = this.validator.Validate(null, options); + + // Assert + result.Failed.ShouldBeTrue(); + result.FailureMessage.ShouldNotBeNull().ShouldContain($"AzureSql:Google:{key} must be provided."); + } + + /// + /// Verifies that selecting the Google provider without a Google section fails validation. + /// + [Fact] + public void Given_GoogleProviderWithoutGoogleSection_When_Validate_Then_Fails() + { + // Arrange + var options = new WorkloadIdentityOptions + { + AzureSql = new WorkloadIdentityResourceOptions { Provider = WorkloadIdentityProvider.Google }, + }; + + // Act + var result = this.validator.Validate(null, options); + + // Assert + result.Failed.ShouldBeTrue(); + result.FailureMessage.ShouldNotBeNull().ShouldContain("AzureSql:Google section must be provided"); + } + + /// + /// Verifies that selecting the managed identity provider without a managed identity section fails validation. + /// + [Fact] + public void Given_ManagedIdentityProviderWithoutSection_When_Validate_Then_Fails() + { + // Arrange + var options = new WorkloadIdentityOptions { BlobStorage = new WorkloadIdentityResourceOptions() }; + + // Act + var result = this.validator.Validate(null, options); + + // Assert + result.Failed.ShouldBeTrue(); + result.FailureMessage.ShouldNotBeNull().ShouldContain("BlobStorage:ManagedIdentity section must be provided"); + } + + /// + /// Verifies that a user-assigned managed identity without a client ID fails validation. + /// + /// The blank client ID. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Given_UserAssignedManagedIdentityWithoutClientId_When_Validate_Then_Fails(string? clientId) + { + // Arrange + var options = new WorkloadIdentityOptions + { + BlobStorage = new WorkloadIdentityResourceOptions + { + ManagedIdentity = new ManagedIdentityOptions { ClientId = clientId }, + }, + }; + + // Act + var result = this.validator.Validate(null, options); + + // Assert + result.Failed.ShouldBeTrue(); + result.FailureMessage.ShouldNotBeNull().ShouldContain("BlobStorage:ManagedIdentity:ClientId must be provided"); + } + + /// + /// Verifies that a provider value outside the enum fails validation. + /// + [Fact] + public void Given_UndefinedProvider_When_Validate_Then_Fails() + { + // Arrange + var options = new WorkloadIdentityOptions + { + AzureSql = new WorkloadIdentityResourceOptions { Provider = (WorkloadIdentityProvider)42 }, + }; + + // Act + var result = this.validator.Validate(null, options); + + // Assert + result.Failed.ShouldBeTrue(); + result.FailureMessage.ShouldNotBeNull().ShouldContain("AzureSql:Provider"); + } + + /// + /// Verifies that a refresh-ahead window that is not positive fails validation. + /// + /// The window length in minutes. + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Given_NonPositiveRefreshAheadWindow_When_Validate_Then_Fails(int minutes) + { + // Arrange + var options = new WorkloadIdentityOptions + { + AzureSql = CreateGoogleResource(), + RefreshAheadWindow = TimeSpan.FromMinutes(minutes), + }; + + // Act + var result = this.validator.Validate(null, options); + + // Assert + result.Failed.ShouldBeTrue(); + result.FailureMessage.ShouldNotBeNull().ShouldContain(nameof(WorkloadIdentityOptions.RefreshAheadWindow)); + } + + /// + /// Verifies that every invalid value is reported at once, so that one restart fixes them all. + /// + [Fact] + public void Given_SeveralInvalidValues_When_Validate_Then_ReportsAll() + { + // Arrange + var options = new WorkloadIdentityOptions + { + AzureSql = CreateGoogleResource(), + BlobStorage = new WorkloadIdentityResourceOptions { ManagedIdentity = new ManagedIdentityOptions() }, + RefreshAheadWindow = TimeSpan.Zero, + }; + options.AzureSql.Google.ShouldNotBeNull().TenantId = null; + + // Act + var result = this.validator.Validate(null, options); + + // Assert + result.Failed.ShouldBeTrue(); + var message = result.FailureMessage.ShouldNotBeNull(); + message.ShouldContain("AzureSql:Google:TenantId"); + message.ShouldContain("BlobStorage:ManagedIdentity:ClientId"); + message.ShouldContain(nameof(WorkloadIdentityOptions.RefreshAheadWindow)); + } + + /// + /// Creates a resource section that federates through Google with every value set. + /// + /// The section. + private static WorkloadIdentityResourceOptions CreateGoogleResource() + { + return new WorkloadIdentityResourceOptions + { + Provider = WorkloadIdentityProvider.Google, + Google = new GoogleOptions + { + TenantId = TenantId, + ClientId = ClientId, + ServiceAccountEmail = ServiceAccountEmail, + }, + }; + } + + /// + /// Creates a resource section that uses the system-assigned managed identity. + /// + /// The section. + private static WorkloadIdentityResourceOptions CreateSystemAssignedResource() + { + return new WorkloadIdentityResourceOptions + { + Provider = WorkloadIdentityProvider.ManagedIdentity, + ManagedIdentity = new ManagedIdentityOptions { UseSystemAssignedIdentity = true }, + }; + } + } +} diff --git a/Csag.WorkloadIdentity.UnitTests/WorkloadIdentityServiceCollectionExtensionsTests.cs b/Csag.WorkloadIdentity.UnitTests/WorkloadIdentityServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..2a1aca3 --- /dev/null +++ b/Csag.WorkloadIdentity.UnitTests/WorkloadIdentityServiceCollectionExtensionsTests.cs @@ -0,0 +1,356 @@ +namespace Csag.WorkloadIdentity.UnitTests +{ + using Azure.Core; + using Csag.WorkloadIdentity.Abstractions; + using Csag.WorkloadIdentity.Internal; + using Csag.WorkloadIdentity.Internal.Services; + using Csag.WorkloadIdentity.Options; + using Microsoft.Extensions.Configuration; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Hosting; + using Microsoft.Extensions.Options; + using NSubstitute; + using Shouldly; + + /// + /// Unit tests for the class. + /// + public class WorkloadIdentityServiceCollectionExtensionsTests + { + /// + /// The Microsoft Entra tenant ID in the test configuration. + /// + private const string TenantId = "tenant"; + + /// + /// The client ID of the app registration in the test configuration. + /// + private const string ClientId = "client"; + + /// + /// The Google service account email in the test configuration. + /// + private const string ServiceAccountEmail = "sa@example.iam.gserviceaccount.com"; + + /// + /// The client ID of the user-assigned managed identity in the test configuration. + /// + private const string UserAssignedClientId = "11111111-2222-3333-4444-555555555555"; + + /// + /// The refresh-ahead window in the test configuration. + /// + private static readonly TimeSpan RefreshAheadWindow = TimeSpan.FromMinutes(7); + + /// + /// Verifies that the parameterless overload binds the default section of the registered configuration and + /// wires up both token providers, both exchangers and the refresh service. + /// + [Fact] + public void Given_ConfigurationInContainer_When_AddWorkloadIdentity_Then_BindsOptionsAndResolvesPipeline() + { + // Arrange + var services = CreateServices(CreateValidConfiguration()); + + // Act + services.AddWorkloadIdentity(); + using var serviceProvider = services.BuildServiceProvider(); + + // Assert + var options = serviceProvider.GetRequiredService>().Value; + var azureSql = options.AzureSql.ShouldNotBeNull(); + azureSql.Provider.ShouldBe(WorkloadIdentityProvider.Google); + var google = azureSql.Google.ShouldNotBeNull(); + google.TenantId.ShouldBe(TenantId); + google.ClientId.ShouldBe(ClientId); + google.ServiceAccountEmail.ShouldBe(ServiceAccountEmail); + var blobStorage = options.BlobStorage.ShouldNotBeNull(); + blobStorage.Provider.ShouldBe(WorkloadIdentityProvider.ManagedIdentity); + blobStorage.ManagedIdentity.ShouldNotBeNull().ClientId.ShouldBe(UserAssignedClientId); + options.RefreshAheadWindow.ShouldBe(RefreshAheadWindow); + options.EnableBackgroundRefresh.ShouldBeFalse(); + serviceProvider.GetRequiredService().ShouldBeOfType(); + serviceProvider.GetRequiredService().ShouldBeOfType(); + serviceProvider.GetServices().Select(exchanger => exchanger.GetType()) + .ShouldBe([typeof(GoogleFederatedTokenExchanger), typeof(ManagedIdentityTokenExchanger)], ignoreOrder: true); + serviceProvider.GetServices().ShouldHaveSingleItem().ShouldBeOfType(); + } + + /// + /// Verifies that missing configuration fails host startup with a validation error naming the requirement. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Given_MissingConfiguration_When_HostStarts_Then_ThrowsOptionsValidationException() + { + // Arrange + var builder = Host.CreateEmptyApplicationBuilder(new HostApplicationBuilderSettings()); + builder.Services.AddWorkloadIdentity(); + using var host = builder.Build(); + + // Act + var exception = await Should.ThrowAsync(() => host.StartAsync()); + + // Assert + exception.Message.ShouldContain("At least one resource section"); + } + + /// + /// Verifies that an incomplete resource section fails host startup with a validation error naming the path. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Given_IncompleteResourceSection_When_HostStarts_Then_ThrowsOptionsValidationExceptionNamingPath() + { + // Arrange + var section = WorkloadIdentityOptions.ConfigurationSectionName; + var builder = Host.CreateEmptyApplicationBuilder(new HostApplicationBuilderSettings()); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + [$"{section}:AzureSql:Provider"] = nameof(WorkloadIdentityProvider.Google), + [$"{section}:AzureSql:Google:TenantId"] = TenantId, + }); + builder.Services.AddWorkloadIdentity(); + using var host = builder.Build(); + + // Act + var exception = await Should.ThrowAsync(() => host.StartAsync()); + + // Assert + exception.Message.ShouldContain("AzureSql:Google:ClientId must be provided."); + exception.Message.ShouldContain("AzureSql:Google:ServiceAccountEmail must be provided."); + } + + /// + /// Verifies that a token provider registered by the consumer beforehand is kept. + /// + [Fact] + public void Given_ConsumerRegisteredTokenProvider_When_AddWorkloadIdentity_Then_ConsumerRegistrationWins() + { + // Arrange + var consumerProvider = Substitute.For(); + var services = CreateServices(CreateValidConfiguration()); + services.AddSingleton(consumerProvider); + + // Act + services.AddWorkloadIdentity(); + using var serviceProvider = services.BuildServiceProvider(); + + // Assert + serviceProvider.GetRequiredService().ShouldBeSameAs(consumerProvider); + serviceProvider.GetRequiredService().ShouldBeOfType(); + } + + /// + /// Verifies that registering twice does not duplicate the services or the hosted service. + /// + [Fact] + public void Given_CalledTwice_When_AddWorkloadIdentity_Then_RegistersEachServiceOnce() + { + // Arrange + var services = CreateServices(CreateValidConfiguration()); + + // Act + services.AddWorkloadIdentity(); + services.AddWorkloadIdentity(); + + // Assert + services.Count(descriptor => descriptor.ServiceType == typeof(IAzureSqlTokenProvider)).ShouldBe(1); + services.Count(descriptor => descriptor.ServiceType == typeof(IBlobStorageTokenProvider)).ShouldBe(1); + services.Count(descriptor => descriptor.ServiceType == typeof(IWorkloadIdentityTokenExchanger)).ShouldBe(2); + services.Count(descriptor => descriptor.ServiceType == typeof(IHostedService)).ShouldBe(1); + services.Count(descriptor => descriptor.ServiceType == typeof(IValidateOptions)).ShouldBe(1); + } + + /// + /// Verifies that the configure-action overload applies the options without any configuration in the container. + /// + [Fact] + public void Given_ConfigureAction_When_AddWorkloadIdentity_Then_AppliesOptions() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + + // Act + services.AddWorkloadIdentity(options => options.AzureSql = CreateGoogleResource()); + using var serviceProvider = services.BuildServiceProvider(); + + // Assert + var options = serviceProvider.GetRequiredService>().Value; + options.AzureSql.ShouldNotBeNull().Google.ShouldNotBeNull().TenantId.ShouldBe(TenantId); + options.BlobStorage.ShouldBeNull(); + options.EnableBackgroundRefresh.ShouldBeTrue(); + serviceProvider.GetRequiredService().ShouldBeOfType(); + } + + /// + /// Verifies that the configuration overload binds from the given instance without any configuration in the container. + /// + [Fact] + public void Given_ConfigurationInstance_When_AddWorkloadIdentity_Then_BindsOptions() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + + // Act + services.AddWorkloadIdentity(CreateValidConfiguration()); + using var serviceProvider = services.BuildServiceProvider(); + + // Assert + var options = serviceProvider.GetRequiredService>().Value; + options.AzureSql.ShouldNotBeNull().Google.ShouldNotBeNull().ClientId.ShouldBe(ClientId); + options.RefreshAheadWindow.ShouldBe(RefreshAheadWindow); + serviceProvider.GetRequiredService().ShouldBeOfType(); + } + + /// + /// Verifies that resolving the provider of a resource without a section fails with a message naming the + /// section, while the configured resource's provider still resolves. + /// + [Fact] + public void Given_UnconfiguredResource_When_ResolvingItsTokenProvider_Then_ThrowsInvalidOperationExceptionNamingSection() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + services.AddWorkloadIdentity(options => options.AzureSql = CreateGoogleResource()); + using var serviceProvider = services.BuildServiceProvider(); + + // Act + var exception = Should.Throw(() => serviceProvider.GetRequiredService()); + + // Assert + exception.Message.ShouldContain("Csag.WorkloadIdentity:BlobStorage"); + serviceProvider.GetRequiredService().ShouldBeOfType(); + } + + /// + /// Verifies that the overloads reject a missing configuration or configure action. + /// + [Fact] + public void Given_NullArguments_When_AddWorkloadIdentity_Then_ThrowsArgumentNullException() + { + // Arrange + var services = new ServiceCollection(); + + // Act + var configurationException = Should.Throw(() => services.AddWorkloadIdentity((IConfiguration)null!)); + var actionException = Should.Throw(() => services.AddWorkloadIdentity((Action)null!)); + + // Assert + configurationException.ParamName.ShouldBe("configuration"); + actionException.ParamName.ShouldBe("configureOptions"); + } + + /// + /// Verifies that a resource selecting the managed identity provider is served through the managed identity + /// credential seam with its configured identity and scope. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Given_ManagedIdentityResourceAndSubstitutedCredentialFactory_When_GetBlobStorageAccessTokenAsync_Then_ReturnsCredentialsToken() + { + // Arrange + var credential = Substitute.For(); + credential.GetTokenAsync(Arg.Any(), Arg.Any()) + .Returns(new AccessToken("managed-identity-token", DateTimeOffset.UtcNow.AddHours(1))); + var credentialFactory = Substitute.For(); + credentialFactory.Create(Arg.Any()).Returns(credential); + var services = CreateServices(CreateValidConfiguration()); + services.AddSingleton(credentialFactory); + services.AddWorkloadIdentity(); + using var serviceProvider = services.BuildServiceProvider(); + + // Act + var token = await serviceProvider.GetRequiredService().GetBlobStorageAccessTokenAsync(CancellationToken.None); + + // Assert + token.ShouldBe("managed-identity-token"); + credentialFactory.Received(1).Create(Arg.Is(identity => identity.ClientId == UserAssignedClientId)); + await credential.Received(1).GetTokenAsync(Arg.Is(context => context.Scopes.Single() == "https://storage.azure.com/.default"), Arg.Any()); + } + + /// + /// Verifies that a resource selecting the Google provider is served through the client assertion credential + /// seam with its configured identity and scope. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Given_GoogleResourceAndSubstitutedCredentialFactory_When_GetAzureSqlAccessTokenAsync_Then_ReturnsCredentialsToken() + { + // Arrange + var credential = Substitute.For(); + credential.GetTokenAsync(Arg.Any(), Arg.Any()) + .Returns(new AccessToken("federated-token", DateTimeOffset.UtcNow.AddHours(1))); + var credentialFactory = Substitute.For(); + credentialFactory.Create(TenantId, ClientId, Arg.Any>>()).Returns(credential); + var services = CreateServices(CreateValidConfiguration()); + services.AddSingleton(credentialFactory); + services.AddWorkloadIdentity(); + using var serviceProvider = services.BuildServiceProvider(); + + // Act + var token = await serviceProvider.GetRequiredService().GetAzureSqlAccessTokenAsync(CancellationToken.None); + + // Assert + token.ShouldBe("federated-token"); + await credential.Received(1).GetTokenAsync(Arg.Is(context => context.Scopes.Single() == "https://database.windows.net/.default"), Arg.Any()); + } + + /// + /// Creates an in-memory configuration holding a complete default section: Azure SQL over Google federation + /// and Blob Storage over a user-assigned managed identity. + /// + /// The configuration. + private static IConfiguration CreateValidConfiguration() + { + var section = WorkloadIdentityOptions.ConfigurationSectionName; + return new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [$"{section}:AzureSql:Provider"] = nameof(WorkloadIdentityProvider.Google), + [$"{section}:AzureSql:Google:TenantId"] = TenantId, + [$"{section}:AzureSql:Google:ClientId"] = ClientId, + [$"{section}:AzureSql:Google:ServiceAccountEmail"] = ServiceAccountEmail, + [$"{section}:BlobStorage:Provider"] = nameof(WorkloadIdentityProvider.ManagedIdentity), + [$"{section}:BlobStorage:ManagedIdentity:ClientId"] = UserAssignedClientId, + [$"{section}:RefreshAheadWindow"] = RefreshAheadWindow.ToString(), + [$"{section}:EnableBackgroundRefresh"] = "false", + }) + .Build(); + } + + /// + /// Creates a resource section that federates through Google with every value set. + /// + /// The section. + private static WorkloadIdentityResourceOptions CreateGoogleResource() + { + return new WorkloadIdentityResourceOptions + { + Provider = WorkloadIdentityProvider.Google, + Google = new GoogleOptions + { + TenantId = TenantId, + ClientId = ClientId, + ServiceAccountEmail = ServiceAccountEmail, + }, + }; + } + + /// + /// Creates a service collection with the given configuration and logging registered, as a host would. + /// + /// The configuration to register. + /// The service collection. + private static ServiceCollection CreateServices(IConfiguration configuration) + { + var services = new ServiceCollection(); + services.AddSingleton(configuration); + services.AddLogging(); + return services; + } + } +} diff --git a/Csag.WorkloadIdentity.UnitTests/WorkloadIdentityTokenCredentialTests.cs b/Csag.WorkloadIdentity.UnitTests/WorkloadIdentityTokenCredentialTests.cs new file mode 100644 index 0000000..872e0ba --- /dev/null +++ b/Csag.WorkloadIdentity.UnitTests/WorkloadIdentityTokenCredentialTests.cs @@ -0,0 +1,88 @@ +namespace Csag.WorkloadIdentity.UnitTests +{ + using Azure.Core; + using Csag.WorkloadIdentity.Abstractions; + using NSubstitute; + using Shouldly; + + /// + /// Unit tests for the class. + /// + public class WorkloadIdentityTokenCredentialTests + { + /// + /// The instant the provider's token expires. + /// + private static readonly DateTimeOffset ExpiresOn = new(2026, 1, 1, 13, 0, 0, TimeSpan.Zero); + + /// + /// The token request every test makes; its scopes play no role. + /// + private static readonly TokenRequestContext RequestContext = new(["https://storage.azure.com/.default"]); + + /// + /// The substituted provider the credential wraps. + /// + private readonly IAccessTokenProvider tokenProvider = Substitute.For(); + + /// + /// The credential under test. + /// + private readonly WorkloadIdentityTokenCredential credential; + + /// + /// Initializes a new instance of the class. + /// + public WorkloadIdentityTokenCredentialTests() + { + this.tokenProvider.GetAccessTokenAsync(Arg.Any()).Returns(new AccessToken("access-token", ExpiresOn)); + this.credential = new WorkloadIdentityTokenCredential(this.tokenProvider); + } + + /// + /// Verifies that the asynchronous overload returns the provider's token with its real expiry. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Given_Provider_When_GetTokenAsync_Then_ReturnsProvidersTokenWithItsExpiry() + { + // Arrange + using var cancellation = new CancellationTokenSource(); + + // Act + var result = await this.credential.GetTokenAsync(RequestContext, cancellation.Token); + + // Assert + result.Token.ShouldBe("access-token"); + result.ExpiresOn.ShouldBe(ExpiresOn); + await this.tokenProvider.Received(1).GetAccessTokenAsync(cancellation.Token); + } + + /// + /// Verifies that the synchronous overload returns the provider's token with its real expiry. + /// + [Fact] + public void Given_Provider_When_GetToken_Then_ReturnsProvidersTokenWithItsExpiry() + { + // Act + var result = this.credential.GetToken(RequestContext, CancellationToken.None); + + // Assert + result.Token.ShouldBe("access-token"); + result.ExpiresOn.ShouldBe(ExpiresOn); + } + + /// + /// Verifies that the credential cannot be constructed without a provider. + /// + [Fact] + public void Given_NullProvider_When_Constructed_Then_ThrowsArgumentNullException() + { + // Act + var exception = Should.Throw(() => new WorkloadIdentityTokenCredential(null!)); + + // Assert + exception.ParamName.ShouldBe("tokenProvider"); + } + } +} diff --git a/Csag.WorkloadIdentity/Abstractions/IAccessTokenProvider.cs b/Csag.WorkloadIdentity/Abstractions/IAccessTokenProvider.cs new file mode 100644 index 0000000..d86005a --- /dev/null +++ b/Csag.WorkloadIdentity/Abstractions/IAccessTokenProvider.cs @@ -0,0 +1,18 @@ +namespace Csag.WorkloadIdentity.Abstractions +{ + using Azure.Core; + + /// + /// Provides Microsoft Entra ID access tokens for one Azure resource. Every resource-specific token provider + /// implements it, so that can wrap any of them. + /// + public interface IAccessTokenProvider + { + /// + /// Gets a valid access token for the resource together with the instant it expires. + /// + /// A cancellation token. + /// The access token. + Task GetAccessTokenAsync(CancellationToken cancellationToken); + } +} diff --git a/Csag.WorkloadIdentity/Abstractions/IAzureSqlTokenExchanger.cs b/Csag.WorkloadIdentity/Abstractions/IAzureSqlTokenExchanger.cs deleted file mode 100644 index 2d1a512..0000000 --- a/Csag.WorkloadIdentity/Abstractions/IAzureSqlTokenExchanger.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Csag.WorkloadIdentity.Abstractions -{ - using Azure.Core; - - /// - /// Provides a method to exchange a client assertion for an Azure AD access token for Azure SQL. - /// - public interface IAzureSqlTokenExchanger - { - /// - /// Exchanges a client assertion for an Azure AD access token for Azure SQL. - /// - /// The Azure AD tenant ID. - /// The Azure AD client ID. - /// A cancellation token. - /// An Azure AD access token for Azure SQL. - Task ExchangeClientAssertionForAzureTokenAsync(string tenantId, string clientId, CancellationToken cancellationToken); - } -} diff --git a/Csag.WorkloadIdentity/Abstractions/IAzureSqlTokenProvider.cs b/Csag.WorkloadIdentity/Abstractions/IAzureSqlTokenProvider.cs index a765360..c98227e 100644 --- a/Csag.WorkloadIdentity/Abstractions/IAzureSqlTokenProvider.cs +++ b/Csag.WorkloadIdentity/Abstractions/IAzureSqlTokenProvider.cs @@ -1,18 +1,15 @@ namespace Csag.WorkloadIdentity.Abstractions { - using System.Threading; - using System.Threading.Tasks; - /// - /// Provides a method to obtain an Azure SQL access token. + /// Provides access tokens for Azure SQL. /// - public interface IAzureSqlTokenProvider + public interface IAzureSqlTokenProvider : IAccessTokenProvider { /// - /// Gets an Azure SQL access token asynchronously. + /// Gets a valid access token for Azure SQL, to be assigned to SqlConnection.AccessToken. /// /// A cancellation token. - /// The Azure SQL access token as a string. + /// The access token as a string. Task GetAzureSqlAccessTokenAsync(CancellationToken cancellationToken); } } diff --git a/Csag.WorkloadIdentity/Abstractions/IBlobStorageTokenProvider.cs b/Csag.WorkloadIdentity/Abstractions/IBlobStorageTokenProvider.cs new file mode 100644 index 0000000..82e025b --- /dev/null +++ b/Csag.WorkloadIdentity/Abstractions/IBlobStorageTokenProvider.cs @@ -0,0 +1,15 @@ +namespace Csag.WorkloadIdentity.Abstractions +{ + /// + /// Provides access tokens for Azure Blob Storage. + /// + public interface IBlobStorageTokenProvider : IAccessTokenProvider + { + /// + /// Gets a valid access token for Azure Blob Storage. + /// + /// A cancellation token. + /// The access token as a string. + Task GetBlobStorageAccessTokenAsync(CancellationToken cancellationToken); + } +} diff --git a/Csag.WorkloadIdentity/Abstractions/IGoogleIdTokenProvider.cs b/Csag.WorkloadIdentity/Abstractions/IGoogleIdTokenProvider.cs index 244a429..3495721 100644 --- a/Csag.WorkloadIdentity/Abstractions/IGoogleIdTokenProvider.cs +++ b/Csag.WorkloadIdentity/Abstractions/IGoogleIdTokenProvider.cs @@ -1,16 +1,17 @@ namespace Csag.WorkloadIdentity.Abstractions { /// - /// Provides a method to obtain a Google-signed ID token for the target service account. + /// Provides Google-signed ID tokens for a service account, for use as client assertions. /// public interface IGoogleIdTokenProvider { /// - /// Returns an ID token for the target service account with audience api://AzureADTokenExchange, - /// the audience Azure AD expects from a client assertion in workload identity federation. + /// Returns an ID token for the service account with audience api://AzureADTokenExchange, the audience + /// Microsoft Entra ID expects from a client assertion in workload identity federation. /// + /// The email of the service account the token is minted for. /// A cancellation token. /// The Google-signed ID token as a string. - Task GetIdTokenAsync(CancellationToken cancellationToken); + Task GetIdTokenAsync(string serviceAccountEmail, CancellationToken cancellationToken); } } diff --git a/Csag.WorkloadIdentity/AzureSqlTokenProvider.cs b/Csag.WorkloadIdentity/AzureSqlTokenProvider.cs index 99c1400..ff30dda 100644 --- a/Csag.WorkloadIdentity/AzureSqlTokenProvider.cs +++ b/Csag.WorkloadIdentity/AzureSqlTokenProvider.cs @@ -1,174 +1,56 @@ namespace Csag.WorkloadIdentity { + using Azure.Core; using Csag.WorkloadIdentity.Abstractions; using Csag.WorkloadIdentity.Internal; - using Csag.WorkloadIdentity.Options; - using Microsoft.Extensions.Logging; - using Microsoft.Extensions.Options; /// - /// Provides Azure SQL access tokens. The current token is held in memory and reused until it enters the - /// configured refresh-ahead window; callers that find no usable token share a single token exchange. + /// Provides access tokens for Azure SQL. The current token is held in memory and reused until it enters the + /// configured refresh-ahead window; callers that find no usable token share a single token request. Registered + /// by and resolvable only while the + /// section is configured. /// - public sealed class AzureSqlTokenProvider : IAzureSqlTokenProvider, IAzureSqlTokenRefresher, IDisposable + public sealed class AzureSqlTokenProvider : IAzureSqlTokenProvider, ITokenRefresher, IDisposable { /// - /// The token exchanger for Azure SQL. + /// Holds the token and performs the requests. /// - private readonly IAzureSqlTokenExchanger azureSqlTokenExchanger; - - /// - /// The options for federated identity configuration. - /// - private readonly AzureSqlFederatedIdentityOptions options; - - /// - /// The clock used to judge how much lifetime the held token has left. - /// - private readonly TimeProvider timeProvider; - - /// - /// The logger instance for this class. - /// - private readonly ILogger logger; - - /// - /// Serialises token exchanges: the first caller to find no usable token performs the exchange while the - /// others wait for it and then reuse its result. - /// - private readonly SemaphoreSlim exchangeLock = new(1, 1); - - /// - /// The token currently held, or before the first successful exchange. A reference - /// type so that the lock-free read in is atomic. - /// - private volatile HeldToken? currentToken; + private readonly ResourceTokenProvider tokenProvider; /// /// Initializes a new instance of the class. /// - /// The token exchanger for Azure SQL. - /// The federated identity options. - /// The clock used to judge token lifetime. - /// The logger instance. - public AzureSqlTokenProvider( - IAzureSqlTokenExchanger azureSqlTokenExchanger, - IOptions options, - TimeProvider timeProvider, - ILogger logger) - { - ArgumentNullException.ThrowIfNull(options); - - this.azureSqlTokenExchanger = azureSqlTokenExchanger; - this.options = options.Value; - this.timeProvider = timeProvider; - this.logger = logger; - } - - /// - /// Gets a valid Azure AD access token for Azure SQL, reusing the held token while it is outside the - /// refresh-ahead window. - /// - /// A cancellation token. - /// The Azure AD access token for Azure SQL. - public async Task GetAzureSqlAccessTokenAsync(CancellationToken cancellationToken) + /// The holder of the Azure SQL token. + internal AzureSqlTokenProvider(ResourceTokenProvider tokenProvider) { - var held = this.currentToken; - if (held is not null && !this.IsDueForRefresh(held)) - { - this.logger.LogTrace("Returning the held Azure AD access token."); - return held.Token; - } + ArgumentNullException.ThrowIfNull(tokenProvider); - var token = await this.ExchangeAsync(held, force: false, cancellationToken).ConfigureAwait(false); - return token.Token; + this.tokenProvider = tokenProvider; } /// - async Task IAzureSqlTokenRefresher.RefreshAsync(CancellationToken cancellationToken) + public Task GetAccessTokenAsync(CancellationToken cancellationToken) { - var token = await this.ExchangeAsync(this.currentToken, force: true, cancellationToken).ConfigureAwait(false); - return token.ExpiresOn; + return this.tokenProvider.GetAccessTokenAsync(cancellationToken); } /// - public void Dispose() - { - this.exchangeLock.Dispose(); - } - - /// - /// Exchanges a new token, unless another caller replaced with one this caller - /// can use while it waited for the lock. - /// - /// The token the caller saw before deciding to exchange, if any. - /// Whether to replace a token that is not yet due for refresh. - /// A cancellation token. - /// The token now held. - private async Task ExchangeAsync(HeldToken? observed, bool force, CancellationToken cancellationToken) + public async Task GetAzureSqlAccessTokenAsync(CancellationToken cancellationToken) { - await this.exchangeLock.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - var held = this.currentToken; - if (held is not null && this.CanReuse(held, observed, force)) - { - this.logger.LogTrace("Another caller refreshed the Azure AD access token in the meantime; reusing it."); - return held; - } - - this.logger.LogDebug("Exchanging a new Azure AD access token for Azure SQL."); - var accessToken = await this.azureSqlTokenExchanger - .ExchangeClientAssertionForAzureTokenAsync(this.options.TenantId, this.options.ClientId, cancellationToken) - .ConfigureAwait(false); - - held = new HeldToken(accessToken.Token, accessToken.ExpiresOn); - this.currentToken = held; - this.logger.LogDebug("Holding a new Azure AD access token that expires at {ExpiresOn}.", held.ExpiresOn); - return held; - } - finally - { - this.exchangeLock.Release(); - } + var token = await this.tokenProvider.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false); + return token.Token; } - /// - /// Decides whether the token found under the lock can be returned instead of exchanging a new one. - /// - /// The token currently held. - /// The token the caller saw before deciding to exchange, if any. - /// Whether the caller asked for a forced refresh. - /// to return ; to exchange. - private bool CanReuse(HeldToken held, HeldToken? observed, bool force) + /// + Task ITokenRefresher.RefreshAsync(CancellationToken cancellationToken) { - if (ReferenceEquals(held, observed)) - { - // Nothing changed while waiting for the lock, so the decision to exchange stands. - return false; - } - - // Another caller replaced the token in the meantime. A forced refresh is satisfied by any token that is - // not yet due. An ordinary call also accepts a short-lived token that arrived already inside the - // refresh-ahead window, as long as it has not expired, rather than exchanging again straight away. - return force ? !this.IsDueForRefresh(held) : held.ExpiresOn > this.timeProvider.GetUtcNow(); + return this.tokenProvider.RefreshAsync(cancellationToken); } - /// - /// Checks whether the token has entered the refresh-ahead window. - /// - /// The token to check. - /// if the token is due for refresh. - private bool IsDueForRefresh(HeldToken held) + /// + public void Dispose() { - return held.ExpiresOn - this.timeProvider.GetUtcNow() <= this.options.RefreshAheadWindow; + this.tokenProvider.Dispose(); } - - /// - /// An access token together with the instant it expires. - /// - /// The access token. - /// The instant the token expires. - private sealed record HeldToken(string Token, DateTimeOffset ExpiresOn); } } diff --git a/Csag.WorkloadIdentity/BlobStorageTokenProvider.cs b/Csag.WorkloadIdentity/BlobStorageTokenProvider.cs new file mode 100644 index 0000000..b225496 --- /dev/null +++ b/Csag.WorkloadIdentity/BlobStorageTokenProvider.cs @@ -0,0 +1,56 @@ +namespace Csag.WorkloadIdentity +{ + using Azure.Core; + using Csag.WorkloadIdentity.Abstractions; + using Csag.WorkloadIdentity.Internal; + + /// + /// Provides access tokens for Azure Blob Storage. The current token is held in memory and reused until it enters + /// the configured refresh-ahead window; callers that find no usable token share a single token request. + /// Registered by and resolvable only while the + /// section is configured. + /// + public sealed class BlobStorageTokenProvider : IBlobStorageTokenProvider, ITokenRefresher, IDisposable + { + /// + /// Holds the token and performs the requests. + /// + private readonly ResourceTokenProvider tokenProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The holder of the Blob Storage token. + internal BlobStorageTokenProvider(ResourceTokenProvider tokenProvider) + { + ArgumentNullException.ThrowIfNull(tokenProvider); + + this.tokenProvider = tokenProvider; + } + + /// + public Task GetAccessTokenAsync(CancellationToken cancellationToken) + { + return this.tokenProvider.GetAccessTokenAsync(cancellationToken); + } + + /// + public async Task GetBlobStorageAccessTokenAsync(CancellationToken cancellationToken) + { + var token = await this.tokenProvider.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false); + return token.Token; + } + + /// + Task ITokenRefresher.RefreshAsync(CancellationToken cancellationToken) + { + return this.tokenProvider.RefreshAsync(cancellationToken); + } + + /// + public void Dispose() + { + this.tokenProvider.Dispose(); + } + } +} diff --git a/Csag.WorkloadIdentity/Csag.WorkloadIdentity.csproj b/Csag.WorkloadIdentity/Csag.WorkloadIdentity.csproj index ac5301d..8b072e0 100644 --- a/Csag.WorkloadIdentity/Csag.WorkloadIdentity.csproj +++ b/Csag.WorkloadIdentity/Csag.WorkloadIdentity.csproj @@ -6,8 +6,8 @@ true README.md icon.png - azure sql federated identity google cloud iam credentials entra authentication - Federated identity integration for Azure SQL using Google Cloud IAM Credentials and Microsoft Entra ID. + azure sql blob storage workload identity federated identity managed identity google cloud entra authentication + Passwordless access tokens for Azure SQL and Azure Blob Storage from the application's workload identity: an Azure managed identity, or a Google identity through workload identity federation with Microsoft Entra ID. diff --git a/Csag.WorkloadIdentity/FederatedIdentityServiceCollectionExtensions.cs b/Csag.WorkloadIdentity/FederatedIdentityServiceCollectionExtensions.cs deleted file mode 100644 index 4c3f943..0000000 --- a/Csag.WorkloadIdentity/FederatedIdentityServiceCollectionExtensions.cs +++ /dev/null @@ -1,94 +0,0 @@ -namespace Csag.WorkloadIdentity -{ - using Csag.WorkloadIdentity.Abstractions; - using Csag.WorkloadIdentity.Internal; - using Csag.WorkloadIdentity.Internal.Services; - using Csag.WorkloadIdentity.Options; - using Microsoft.Extensions.Configuration; - using Microsoft.Extensions.DependencyInjection; - using Microsoft.Extensions.DependencyInjection.Extensions; - using Microsoft.Extensions.Options; - - /// - /// Provides extension methods for registering Azure SQL federated identity services. - /// - public static class FederatedIdentityServiceCollectionExtensions - { - /// - /// Adds Azure SQL federated identity services, binding the options from the - /// section of the - /// registered in the container. The options are validated when the host starts. - /// - /// The service collection. - /// The service collection for chaining. - public static IServiceCollection AddAzureSqlFederatedIdentity(this IServiceCollection services) - { - ArgumentNullException.ThrowIfNull(services); - - services.AddOptions() - .BindConfiguration(AzureSqlFederatedIdentityOptions.ConfigurationSectionName) - .ValidateOnStart(); - RegisterFederatedIdentityServices(services); - return services; - } - - /// - /// Adds Azure SQL federated identity services with the options configured in code. The options are validated - /// when the host starts. - /// - /// The service collection. - /// The action to configure options. - /// The service collection for chaining. - public static IServiceCollection AddAzureSqlFederatedIdentity(this IServiceCollection services, Action configureOptions) - { - ArgumentNullException.ThrowIfNull(services); - ArgumentNullException.ThrowIfNull(configureOptions); - - services.AddOptions() - .Configure(configureOptions) - .ValidateOnStart(); - RegisterFederatedIdentityServices(services); - return services; - } - - /// - /// Adds Azure SQL federated identity services, binding the options from the - /// section of the given configuration. - /// Use this when the configuration is not registered as in the container. The - /// options are validated when the host starts. - /// - /// The service collection. - /// The application configuration. - /// The service collection for chaining. - public static IServiceCollection AddAzureSqlFederatedIdentity(this IServiceCollection services, IConfiguration configuration) - { - ArgumentNullException.ThrowIfNull(services); - ArgumentNullException.ThrowIfNull(configuration); - - services.AddOptions() - .Bind(configuration.GetSection(AzureSqlFederatedIdentityOptions.ConfigurationSectionName)) - .ValidateOnStart(); - RegisterFederatedIdentityServices(services); - return services; - } - - /// - /// Registers the token pipeline. Each service is a Try* registration, so a consumer can substitute it by - /// registering its own implementation first. The hosted service is the exception: it is added with - /// TryAddEnumerable, which prevents duplicates but not substitution, and is turned off through - /// . Repeated calls do not duplicate anything. - /// - /// The service collection. - private static void RegisterFederatedIdentityServices(IServiceCollection services) - { - services.TryAddSingleton(TimeProvider.System); - services.TryAddEnumerable(ServiceDescriptor.Singleton, AzureSqlFederatedIdentityOptionsValidator>()); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.AddHostedService(); - } - } -} diff --git a/Csag.WorkloadIdentity/Internal/AzureSqlTokenExchanger.cs b/Csag.WorkloadIdentity/Internal/AzureSqlTokenExchanger.cs deleted file mode 100644 index 681780c..0000000 --- a/Csag.WorkloadIdentity/Internal/AzureSqlTokenExchanger.cs +++ /dev/null @@ -1,106 +0,0 @@ -namespace Csag.WorkloadIdentity.Internal -{ - using Azure.Core; - using Csag.WorkloadIdentity.Abstractions; - using Microsoft.Extensions.Logging; - - /// - /// Exchanges a Google-signed ID token for an Azure AD access token for Azure SQL. - /// - internal class AzureSqlTokenExchanger : IAzureSqlTokenExchanger - { - /// - /// The scope that grants access to Azure SQL. - /// - private const string AzureSqlScope = "https://database.windows.net/.default"; - - /// - /// The logger instance for this class. - /// - private readonly ILogger logger; - - /// - /// Provides Google-signed ID tokens for use as client assertions. - /// - private readonly IGoogleIdTokenProvider googleIdTokenProvider; - - /// - /// Creates the credential that performs the exchange. - /// - private readonly IClientAssertionCredentialFactory credentialFactory; - - /// - /// Guards . - /// - private readonly object credentialLock = new(); - - /// - /// The credential for the most recently requested tenant and client. The credential caches the tokens it - /// obtains, so it is kept for as long as the same tenant and client are requested. - /// - private CachedCredential? credential; - - /// - /// Initializes a new instance of the class. - /// - /// The logger instance. - /// The Google ID token provider. - /// Creates the credential that performs the exchange. - public AzureSqlTokenExchanger(ILogger logger, IGoogleIdTokenProvider googleIdTokenProvider, IClientAssertionCredentialFactory credentialFactory) - { - this.logger = logger; - this.googleIdTokenProvider = googleIdTokenProvider; - this.credentialFactory = credentialFactory; - } - - /// - /// Exchanges a Google-signed ID token for an Azure AD access token for Azure SQL. - /// - /// The Azure AD tenant ID. - /// The Azure AD client ID. - /// A cancellation token. - /// An Azure AD access token for Azure SQL. - public async Task ExchangeClientAssertionForAzureTokenAsync(string tenantId, string clientId, CancellationToken cancellationToken) - { - var tokenCredential = this.GetOrCreateCredential(tenantId, clientId); - - this.logger.LogTrace("Exchanging Google-signed ID token for Azure AD access token for Azure SQL using client assertion"); - var tokenRequestContext = new TokenRequestContext([AzureSqlScope]); - var token = await tokenCredential.GetTokenAsync(tokenRequestContext, cancellationToken).ConfigureAwait(false); - - this.logger.LogDebug("Successfully obtained Azure AD access token expiring at {ExpiresOn}", token.ExpiresOn); - return token; - } - - /// - /// Returns the credential for the given tenant and client, creating it on first use or when the pair changes. - /// - /// The Azure AD tenant ID. - /// The Azure AD client ID. - /// The credential. - private TokenCredential GetOrCreateCredential(string tenantId, string clientId) - { - lock (this.credentialLock) - { - var current = this.credential; - if (current is null || current.TenantId != tenantId || current.ClientId != clientId) - { - // The credential passes the callback the cancellation token of the token request it is serving. - var tokenCredential = this.credentialFactory.Create(tenantId, clientId, this.googleIdTokenProvider.GetIdTokenAsync); - current = new CachedCredential(tenantId, clientId, tokenCredential); - this.credential = current; - } - - return current.Credential; - } - } - - /// - /// A credential together with the tenant and client it was created for. - /// - /// The Azure AD tenant ID. - /// The Azure AD client ID. - /// The credential. - private sealed record CachedCredential(string TenantId, string ClientId, TokenCredential Credential); - } -} diff --git a/Csag.WorkloadIdentity/Internal/GoogleFederatedTokenExchanger.cs b/Csag.WorkloadIdentity/Internal/GoogleFederatedTokenExchanger.cs new file mode 100644 index 0000000..d2ad4ff --- /dev/null +++ b/Csag.WorkloadIdentity/Internal/GoogleFederatedTokenExchanger.cs @@ -0,0 +1,149 @@ +namespace Csag.WorkloadIdentity.Internal +{ + using Azure.Core; + using Csag.WorkloadIdentity.Abstractions; + using Csag.WorkloadIdentity.Options; + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Options; + + /// + /// Obtains access tokens through Google workload identity federation: a Google-signed ID token for the resource's + /// service account is presented to Microsoft Entra ID as the client assertion of the identity that holds the + /// federated credential. + /// + internal sealed class GoogleFederatedTokenExchanger : IWorkloadIdentityTokenExchanger + { + /// + /// The options naming the federated identity of each resource. + /// + private readonly WorkloadIdentityOptions options; + + /// + /// Provides Google-signed ID tokens for use as client assertions. + /// + private readonly IGoogleIdTokenProvider googleIdTokenProvider; + + /// + /// Creates the credential that performs the exchange. + /// + private readonly IClientAssertionCredentialFactory credentialFactory; + + /// + /// The logger instance for this class. + /// + private readonly ILogger logger; + + /// + /// Guards . + /// + private readonly object credentialsLock = new(); + + /// + /// One credential per federated identity. A credential caches the tokens it obtains, so resources that share + /// an identity share its credential. The service account is part of the identity because the credential's + /// assertion callback is bound to it. + /// + private readonly Dictionary credentials = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The workload identity options. + /// The Google ID token provider. + /// Creates the credential that performs the exchange. + /// The logger instance. + public GoogleFederatedTokenExchanger( + IOptions options, + IGoogleIdTokenProvider googleIdTokenProvider, + IClientAssertionCredentialFactory credentialFactory, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(options); + + this.options = options.Value; + this.googleIdTokenProvider = googleIdTokenProvider; + this.credentialFactory = credentialFactory; + this.logger = logger; + } + + /// + public WorkloadIdentityProvider Provider => WorkloadIdentityProvider.Google; + + /// + public async Task ExchangeAsync(TokenScope scope, CancellationToken cancellationToken) + { + var identity = GetIdentity(scope, this.options.GetRequiredResource(scope)); + var credential = this.GetOrCreateCredential(identity); + + this.logger.LogTrace("Exchanging a Google-signed ID token for a Microsoft Entra ID access token for {Scope}.", scope); + var tokenRequestContext = new TokenRequestContext([scope.GetIdentifier()]); + var token = await credential.GetTokenAsync(tokenRequestContext, cancellationToken).ConfigureAwait(false); + + this.logger.LogDebug("Obtained a Microsoft Entra ID access token for {Scope} that expires at {ExpiresOn}.", scope, token.ExpiresOn); + return token; + } + + /// + /// Reads the federated identity a resource is configured with. + /// + /// The resource. + /// The resource's options. + /// The federated identity. + private static FederatedIdentity GetIdentity(TokenScope scope, WorkloadIdentityResourceOptions resource) + { + var google = resource.Google ?? throw new InvalidOperationException($"The {scope} resource has no {nameof(resource.Google)} section."); + return new FederatedIdentity( + Require(scope, nameof(google.TenantId), google.TenantId), + Require(scope, nameof(google.ClientId), google.ClientId), + Require(scope, nameof(google.ServiceAccountEmail), google.ServiceAccountEmail)); + } + + /// + /// Returns a required value of the Google section, or throws when it is blank. + /// + /// The resource. + /// The key of the value within the Google section. + /// The configured value, if any. + /// The value, which is not blank. + private static string Require(TokenScope scope, string key, string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException($"{scope}:{nameof(WorkloadIdentityResourceOptions.Google)}:{key} must be provided."); + } + + return value; + } + + /// + /// Returns the credential for the federated identity, creating it on first use. + /// + /// The federated identity. + /// The credential. + private TokenCredential GetOrCreateCredential(FederatedIdentity identity) + { + lock (this.credentialsLock) + { + if (!this.credentials.TryGetValue(identity, out var credential)) + { + // The credential passes the callback the cancellation token of the token request it is serving. + credential = this.credentialFactory.Create( + identity.TenantId, + identity.ClientId, + cancellationToken => this.googleIdTokenProvider.GetIdTokenAsync(identity.ServiceAccountEmail, cancellationToken)); + this.credentials.Add(identity, credential); + } + + return credential; + } + } + + /// + /// The identity a Google-federated token is obtained for. + /// + /// The Microsoft Entra tenant ID. + /// The client ID of the identity holding the federated credential. + /// The Google service account the ID token is minted for. + private sealed record FederatedIdentity(string TenantId, string ClientId, string ServiceAccountEmail); + } +} diff --git a/Csag.WorkloadIdentity/Internal/GoogleIdTokenProvider.cs b/Csag.WorkloadIdentity/Internal/GoogleIdTokenProvider.cs index b268a75..2cec895 100644 --- a/Csag.WorkloadIdentity/Internal/GoogleIdTokenProvider.cs +++ b/Csag.WorkloadIdentity/Internal/GoogleIdTokenProvider.cs @@ -1,19 +1,17 @@ namespace Csag.WorkloadIdentity.Internal { using Csag.WorkloadIdentity.Abstractions; - using Csag.WorkloadIdentity.Options; using Google.Cloud.Iam.Credentials.V1; using Grpc.Core; using Microsoft.Extensions.Logging; - using Microsoft.Extensions.Options; /// - /// Provides Google-signed ID tokens for use as client assertions in Azure SQL token exchange. + /// Provides Google-signed ID tokens for use as client assertions in workload identity federation. /// internal class GoogleIdTokenProvider : IGoogleIdTokenProvider { /// - /// The audience Azure AD expects in an ID token presented as a client assertion for workload identity federation. + /// The audience Microsoft Entra ID expects in an ID token presented as a client assertion for workload identity federation. /// private const string AzureAdTokenExchangeAudience = "api://AzureADTokenExchange"; @@ -22,11 +20,6 @@ internal class GoogleIdTokenProvider : IGoogleIdTokenProvider /// private readonly ILogger logger; - /// - /// The Google-specific options. - /// - private readonly GoogleOptions options; - /// /// Creates the IAM Credentials client. /// @@ -46,29 +39,33 @@ internal class GoogleIdTokenProvider : IGoogleIdTokenProvider /// /// Initializes a new instance of the class. /// - /// The federated identity options. /// Creates the IAM Credentials client. /// The logger instance. - public GoogleIdTokenProvider(IOptions options, IIamCredentialsClientFactory clientFactory, ILogger logger) + public GoogleIdTokenProvider(IIamCredentialsClientFactory clientFactory, ILogger logger) { - ArgumentNullException.ThrowIfNull(options); - ArgumentNullException.ThrowIfNull(options.Value.Google); - - this.options = options.Value.Google; this.clientFactory = clientFactory; this.logger = logger; } + /// + public Task GetIdTokenAsync(string serviceAccountEmail, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(serviceAccountEmail); + + return this.GenerateIdTokenAsync(serviceAccountEmail, cancellationToken); + } + /// - /// Gets a Google-signed ID token for the configured service account and the Azure AD token exchange audience. + /// Asks the IAM Credentials API for an ID token for the service account with the Microsoft Entra ID token + /// exchange audience. /// + /// The email of the service account the token is minted for. /// A cancellation token. /// The Google-signed ID token. - public async Task GetIdTokenAsync(CancellationToken cancellationToken) + private async Task GenerateIdTokenAsync(string serviceAccountEmail, CancellationToken cancellationToken) { var client = await this.GetClientAsync(cancellationToken).ConfigureAwait(false); - var serviceAccountEmail = this.options.ServiceAccountEmail; this.logger.LogTrace("Requesting ID token for service account {ServiceAccountEmail}", serviceAccountEmail); var request = new GenerateIdTokenRequest diff --git a/Csag.WorkloadIdentity/Internal/IAzureSqlTokenRefresher.cs b/Csag.WorkloadIdentity/Internal/IAzureSqlTokenRefresher.cs deleted file mode 100644 index 5ba9204..0000000 --- a/Csag.WorkloadIdentity/Internal/IAzureSqlTokenRefresher.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Csag.WorkloadIdentity.Internal -{ - /// - /// Replaces the currently held Azure SQL access token with a freshly exchanged one, regardless of how much - /// lifetime the current token has left. Implemented by the default token provider so that the background - /// refresh service can renew the token ahead of its expiry. - /// - internal interface IAzureSqlTokenRefresher - { - /// - /// Exchanges a fresh Azure SQL access token and makes it the current token. - /// - /// A cancellation token. - /// The instant at which the token now held expires. - Task RefreshAsync(CancellationToken cancellationToken); - } -} diff --git a/Csag.WorkloadIdentity/Internal/IManagedIdentityCredentialFactory.cs b/Csag.WorkloadIdentity/Internal/IManagedIdentityCredentialFactory.cs new file mode 100644 index 0000000..906f069 --- /dev/null +++ b/Csag.WorkloadIdentity/Internal/IManagedIdentityCredentialFactory.cs @@ -0,0 +1,20 @@ +namespace Csag.WorkloadIdentity.Internal +{ + using Azure.Core; + using Csag.WorkloadIdentity.Options; + + /// + /// Creates the Azure credential that requests tokens from the managed identity endpoint. Exists so that the + /// exchanger can be tested without running on an Azure resource. + /// + internal interface IManagedIdentityCredentialFactory + { + /// + /// Creates a credential for the managed identity the options describe: the system-assigned identity, or the + /// user-assigned identity with the given client ID. + /// + /// The managed identity. + /// The credential. + TokenCredential Create(ManagedIdentityOptions managedIdentity); + } +} diff --git a/Csag.WorkloadIdentity/Internal/ITokenRefresher.cs b/Csag.WorkloadIdentity/Internal/ITokenRefresher.cs new file mode 100644 index 0000000..0f64225 --- /dev/null +++ b/Csag.WorkloadIdentity/Internal/ITokenRefresher.cs @@ -0,0 +1,17 @@ +namespace Csag.WorkloadIdentity.Internal +{ + /// + /// Replaces the currently held access token with a freshly obtained one, regardless of how much lifetime the + /// current token has left. Implemented by the default token providers so that the background refresh service can + /// renew their tokens ahead of expiry. + /// + internal interface ITokenRefresher + { + /// + /// Obtains a fresh access token and makes it the current token. + /// + /// A cancellation token. + /// The instant at which the token now held expires. + Task RefreshAsync(CancellationToken cancellationToken); + } +} diff --git a/Csag.WorkloadIdentity/Internal/IWorkloadIdentityTokenExchanger.cs b/Csag.WorkloadIdentity/Internal/IWorkloadIdentityTokenExchanger.cs new file mode 100644 index 0000000..6eaff37 --- /dev/null +++ b/Csag.WorkloadIdentity/Internal/IWorkloadIdentityTokenExchanger.cs @@ -0,0 +1,25 @@ +namespace Csag.WorkloadIdentity.Internal +{ + using Azure.Core; + using Csag.WorkloadIdentity.Options; + + /// + /// Obtains an access token for a resource from Microsoft Entra ID with one kind of identity. The token provider + /// of a resource uses the exchanger whose the resource's options select. + /// + internal interface IWorkloadIdentityTokenExchanger + { + /// + /// Gets the identity provider this exchanger obtains tokens with. + /// + WorkloadIdentityProvider Provider { get; } + + /// + /// Obtains an access token for the resource, using the identity its options configure. + /// + /// The resource. + /// A cancellation token. + /// The access token. + Task ExchangeAsync(TokenScope scope, CancellationToken cancellationToken); + } +} diff --git a/Csag.WorkloadIdentity/Internal/ManagedIdentityCredentialFactory.cs b/Csag.WorkloadIdentity/Internal/ManagedIdentityCredentialFactory.cs new file mode 100644 index 0000000..9ce803d --- /dev/null +++ b/Csag.WorkloadIdentity/Internal/ManagedIdentityCredentialFactory.cs @@ -0,0 +1,26 @@ +namespace Csag.WorkloadIdentity.Internal +{ + using Azure.Core; + using Azure.Identity; + using Csag.WorkloadIdentity.Options; + + /// + /// Creates instances. + /// + internal sealed class ManagedIdentityCredentialFactory : IManagedIdentityCredentialFactory + { + /// + public TokenCredential Create(ManagedIdentityOptions managedIdentity) + { + ArgumentNullException.ThrowIfNull(managedIdentity); + + if (managedIdentity.UseSystemAssignedIdentity) + { + return new ManagedIdentityCredential(ManagedIdentityId.SystemAssigned); + } + + ArgumentException.ThrowIfNullOrWhiteSpace(managedIdentity.ClientId); + return new ManagedIdentityCredential(ManagedIdentityId.FromUserAssignedClientId(managedIdentity.ClientId)); + } + } +} diff --git a/Csag.WorkloadIdentity/Internal/ManagedIdentityTokenExchanger.cs b/Csag.WorkloadIdentity/Internal/ManagedIdentityTokenExchanger.cs new file mode 100644 index 0000000..239a73a --- /dev/null +++ b/Csag.WorkloadIdentity/Internal/ManagedIdentityTokenExchanger.cs @@ -0,0 +1,103 @@ +namespace Csag.WorkloadIdentity.Internal +{ + using Azure.Core; + using Csag.WorkloadIdentity.Options; + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Options; + + /// + /// Obtains access tokens from the managed identity endpoint of the Azure resource the application runs on. + /// + internal sealed class ManagedIdentityTokenExchanger : IWorkloadIdentityTokenExchanger + { + /// + /// The options naming the managed identity of each resource. + /// + private readonly WorkloadIdentityOptions options; + + /// + /// Creates the credential that requests the tokens. + /// + private readonly IManagedIdentityCredentialFactory credentialFactory; + + /// + /// The logger instance for this class. + /// + private readonly ILogger logger; + + /// + /// Guards . + /// + private readonly object credentialsLock = new(); + + /// + /// One credential per managed identity. A credential caches the tokens it obtains, so resources that share an + /// identity share its credential. + /// + private readonly Dictionary credentials = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The workload identity options. + /// Creates the credential that requests the tokens. + /// The logger instance. + public ManagedIdentityTokenExchanger( + IOptions options, + IManagedIdentityCredentialFactory credentialFactory, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(options); + + this.options = options.Value; + this.credentialFactory = credentialFactory; + this.logger = logger; + } + + /// + public WorkloadIdentityProvider Provider => WorkloadIdentityProvider.ManagedIdentity; + + /// + public async Task ExchangeAsync(TokenScope scope, CancellationToken cancellationToken) + { + var resource = this.options.GetRequiredResource(scope); + var managedIdentity = resource.ManagedIdentity ?? throw new InvalidOperationException($"The {scope} resource has no {nameof(resource.ManagedIdentity)} section."); + var credential = this.GetOrCreateCredential(managedIdentity); + + this.logger.LogTrace("Requesting a Microsoft Entra ID access token for {Scope} from the managed identity endpoint.", scope); + var tokenRequestContext = new TokenRequestContext([scope.GetIdentifier()]); + var token = await credential.GetTokenAsync(tokenRequestContext, cancellationToken).ConfigureAwait(false); + + this.logger.LogDebug("Obtained a Microsoft Entra ID access token for {Scope} that expires at {ExpiresOn}.", scope, token.ExpiresOn); + return token; + } + + /// + /// Returns the credential for the managed identity, creating it on first use. + /// + /// The managed identity. + /// The credential. + private TokenCredential GetOrCreateCredential(ManagedIdentityOptions managedIdentity) + { + // A client ID set alongside UseSystemAssignedIdentity is ignored, so it must not tell the identities apart. + var key = new ManagedIdentityKey(managedIdentity.UseSystemAssignedIdentity, managedIdentity.UseSystemAssignedIdentity ? null : managedIdentity.ClientId); + lock (this.credentialsLock) + { + if (!this.credentials.TryGetValue(key, out var credential)) + { + credential = this.credentialFactory.Create(managedIdentity); + this.credentials.Add(key, credential); + } + + return credential; + } + } + + /// + /// Identifies a managed identity by value. + /// + /// Whether the identity is the system-assigned one. + /// The client ID of the user-assigned identity, or for the system-assigned one. + private sealed record ManagedIdentityKey(bool SystemAssigned, string? ClientId); + } +} diff --git a/Csag.WorkloadIdentity/Internal/ResourceTokenProvider.cs b/Csag.WorkloadIdentity/Internal/ResourceTokenProvider.cs new file mode 100644 index 0000000..a096211 --- /dev/null +++ b/Csag.WorkloadIdentity/Internal/ResourceTokenProvider.cs @@ -0,0 +1,181 @@ +namespace Csag.WorkloadIdentity.Internal +{ + using Azure.Core; + using Microsoft.Extensions.Logging; + + /// + /// Holds the access token of one resource. The current token is reused until it enters the refresh-ahead window; + /// callers that find no usable token share a single token request. Each public resource-specific token provider + /// wraps one instance. + /// + internal sealed class ResourceTokenProvider : ITokenRefresher, IDisposable + { + /// + /// The resource whose token is held. + /// + private readonly TokenScope scope; + + /// + /// Obtains the tokens. + /// + private readonly IWorkloadIdentityTokenExchanger exchanger; + + /// + /// How long before it expires the held token is treated as due for refresh. + /// + private readonly TimeSpan refreshAheadWindow; + + /// + /// The clock used to judge how much lifetime the held token has left. + /// + private readonly TimeProvider timeProvider; + + /// + /// The logger instance for this class. + /// + private readonly ILogger logger; + + /// + /// Serialises token requests: the first caller to find no usable token performs the request while the others + /// wait for it and then reuse its result. + /// + private readonly SemaphoreSlim exchangeLock = new(1, 1); + + /// + /// The token currently held, or before the first successful request. A reference type + /// so that the lock-free read in is atomic. + /// + private volatile HeldToken? currentToken; + + /// + /// Initializes a new instance of the class. + /// + /// The resource whose token is held. + /// Obtains the tokens. + /// How long before it expires the held token is treated as due for refresh. + /// The clock used to judge token lifetime. + /// The logger instance. + public ResourceTokenProvider( + TokenScope scope, + IWorkloadIdentityTokenExchanger exchanger, + TimeSpan refreshAheadWindow, + TimeProvider timeProvider, + ILogger logger) + { + this.scope = scope; + this.exchanger = exchanger; + this.refreshAheadWindow = refreshAheadWindow; + this.timeProvider = timeProvider; + this.logger = logger; + } + + /// + /// Gets a valid access token for the resource, reusing the held token while it is outside the refresh-ahead + /// window. + /// + /// A cancellation token. + /// The access token. + public async Task GetAccessTokenAsync(CancellationToken cancellationToken) + { + var held = this.currentToken; + if (held is not null && !this.IsDueForRefresh(held)) + { + this.logger.LogTrace("Returning the held {Scope} access token.", this.scope); + return held.Value; + } + + var token = await this.ExchangeAsync(held, force: false, cancellationToken).ConfigureAwait(false); + return token.Value; + } + + /// + public async Task RefreshAsync(CancellationToken cancellationToken) + { + var token = await this.ExchangeAsync(this.currentToken, force: true, cancellationToken).ConfigureAwait(false); + return token.ExpiresOn; + } + + /// + public void Dispose() + { + this.exchangeLock.Dispose(); + } + + /// + /// Obtains a new token, unless another caller replaced with one this caller can + /// use while it waited for the lock. + /// + /// The token the caller saw before deciding to request a new one, if any. + /// Whether to replace a token that is not yet due for refresh. + /// A cancellation token. + /// The token now held. + private async Task ExchangeAsync(HeldToken? observed, bool force, CancellationToken cancellationToken) + { + await this.exchangeLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var held = this.currentToken; + if (held is not null && this.CanReuse(held, observed, force)) + { + this.logger.LogTrace("Another caller refreshed the {Scope} access token in the meantime; reusing it.", this.scope); + return held; + } + + this.logger.LogDebug("Obtaining a new {Scope} access token.", this.scope); + var accessToken = await this.exchanger.ExchangeAsync(this.scope, cancellationToken).ConfigureAwait(false); + + held = new HeldToken(accessToken); + this.currentToken = held; + this.logger.LogDebug("Holding a new {Scope} access token that expires at {ExpiresOn}.", this.scope, held.ExpiresOn); + return held; + } + finally + { + this.exchangeLock.Release(); + } + } + + /// + /// Decides whether the token found under the lock can be returned instead of requesting a new one. + /// + /// The token currently held. + /// The token the caller saw before deciding to request a new one, if any. + /// Whether the caller asked for a forced refresh. + /// to return ; to request a new token. + private bool CanReuse(HeldToken held, HeldToken? observed, bool force) + { + if (ReferenceEquals(held, observed)) + { + // Nothing changed while waiting for the lock, so the decision to request a new token stands. + return false; + } + + // Another caller replaced the token in the meantime. A forced refresh is satisfied by any token that is + // not yet due. An ordinary call also accepts a short-lived token that arrived already inside the + // refresh-ahead window, as long as it has not expired, rather than requesting again straight away. + return force ? !this.IsDueForRefresh(held) : held.ExpiresOn > this.timeProvider.GetUtcNow(); + } + + /// + /// Checks whether the token has entered the refresh-ahead window. + /// + /// The token to check. + /// if the token is due for refresh. + private bool IsDueForRefresh(HeldToken held) + { + return held.ExpiresOn - this.timeProvider.GetUtcNow() <= this.refreshAheadWindow; + } + + /// + /// An access token held by reference, so that replacing it is atomic. + /// + /// The access token. + private sealed record HeldToken(AccessToken Value) + { + /// + /// Gets the instant the token expires. + /// + public DateTimeOffset ExpiresOn => this.Value.ExpiresOn; + } + } +} diff --git a/Csag.WorkloadIdentity/Internal/ResourceTokenProviderFactory.cs b/Csag.WorkloadIdentity/Internal/ResourceTokenProviderFactory.cs new file mode 100644 index 0000000..c21c01e --- /dev/null +++ b/Csag.WorkloadIdentity/Internal/ResourceTokenProviderFactory.cs @@ -0,0 +1,68 @@ +namespace Csag.WorkloadIdentity.Internal +{ + using Csag.WorkloadIdentity.Options; + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Options; + + /// + /// Creates the token holder of a resource, wired to the exchanger of the provider the resource's options select. + /// + internal sealed class ResourceTokenProviderFactory + { + /// + /// The options naming the provider of each resource. + /// + private readonly WorkloadIdentityOptions options; + + /// + /// The registered exchangers, one per identity provider. + /// + private readonly IEnumerable exchangers; + + /// + /// The clock the holders judge token lifetime with. + /// + private readonly TimeProvider timeProvider; + + /// + /// Creates the holders' loggers. + /// + private readonly ILoggerFactory loggerFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The workload identity options. + /// The registered exchangers, one per identity provider. + /// The clock the holders judge token lifetime with. + /// Creates the holders' loggers. + public ResourceTokenProviderFactory( + IOptions options, + IEnumerable exchangers, + TimeProvider timeProvider, + ILoggerFactory loggerFactory) + { + ArgumentNullException.ThrowIfNull(options); + + this.options = options.Value; + this.exchangers = exchangers; + this.timeProvider = timeProvider; + this.loggerFactory = loggerFactory; + } + + /// + /// Creates the token holder of the resource. + /// + /// The resource. + /// The token holder, owned by the caller. + /// The resource is not configured, or no exchanger is registered for its provider. + public ResourceTokenProvider Create(TokenScope scope) + { + var resource = this.options.GetRequiredResource(scope); + var exchanger = this.exchangers.FirstOrDefault(candidate => candidate.Provider == resource.Provider) + ?? throw new InvalidOperationException($"No token exchanger is registered for the {resource.Provider} provider selected by the {scope} resource."); + + return new ResourceTokenProvider(scope, exchanger, this.options.RefreshAheadWindow, this.timeProvider, this.loggerFactory.CreateLogger()); + } + } +} diff --git a/Csag.WorkloadIdentity/Internal/Services/AzureSqlTokenRefreshService.cs b/Csag.WorkloadIdentity/Internal/Services/TokenRefreshService.cs similarity index 58% rename from Csag.WorkloadIdentity/Internal/Services/AzureSqlTokenRefreshService.cs rename to Csag.WorkloadIdentity/Internal/Services/TokenRefreshService.cs index 6d8fbaa..7e0d2a7 100644 --- a/Csag.WorkloadIdentity/Internal/Services/AzureSqlTokenRefreshService.cs +++ b/Csag.WorkloadIdentity/Internal/Services/TokenRefreshService.cs @@ -2,15 +2,17 @@ { using Csag.WorkloadIdentity.Abstractions; using Csag.WorkloadIdentity.Options; + using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; /// - /// Background service that keeps the Azure SQL access token refreshed ahead of its expiry, so that callers are - /// served from the held token instead of waiting for a token exchange. + /// Background service that keeps the access token of every configured resource refreshed ahead of its expiry, so + /// that callers are served from the held token instead of waiting for a token request. Each resource is + /// refreshed by its own loop, so a slow or failing resource does not delay the others. /// - internal class AzureSqlTokenRefreshService : BackgroundService + internal sealed class TokenRefreshService : BackgroundService { /// /// The shortest wait between two refreshes, so that a token which arrives already inside its refresh-ahead @@ -35,14 +37,14 @@ internal class AzureSqlTokenRefreshService : BackgroundService private static readonly TimeSpan MaximumRetryDelay = TimeSpan.FromMinutes(5); /// - /// Provides Azure SQL access tokens. + /// Resolves the token provider of each configured resource once the host starts. /// - private readonly IAzureSqlTokenProvider tokenProvider; + private readonly IServiceProvider serviceProvider; /// - /// The options for federated identity configuration. + /// The options naming the configured resources. /// - private readonly AzureSqlFederatedIdentityOptions options; + private readonly WorkloadIdentityOptions options; /// /// The clock that schedules the refreshes. @@ -52,24 +54,24 @@ internal class AzureSqlTokenRefreshService : BackgroundService /// /// The logger instance for this service. /// - private readonly ILogger logger; + private readonly ILogger logger; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - /// The Azure SQL token provider. - /// The federated identity options. + /// Resolves the token provider of each configured resource. + /// The workload identity options. /// The clock that schedules the refreshes. /// The logger instance. - public AzureSqlTokenRefreshService( - IAzureSqlTokenProvider tokenProvider, - IOptions options, + public TokenRefreshService( + IServiceProvider serviceProvider, + IOptions options, TimeProvider timeProvider, - ILogger logger) + ILogger logger) { ArgumentNullException.ThrowIfNull(options); - this.tokenProvider = tokenProvider; + this.serviceProvider = serviceProvider; this.options = options.Value; this.timeProvider = timeProvider; this.logger = logger; @@ -80,17 +82,73 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { if (!this.options.EnableBackgroundRefresh) { - this.logger.LogDebug("Background refresh of the Azure SQL access token is disabled."); + this.logger.LogDebug("Background refresh of access tokens is disabled."); return; } - if (this.tokenProvider is not IAzureSqlTokenRefresher refresher) + var loops = new List(); + foreach (var scope in Enum.GetValues()) { - this.logger.LogInformation("The registered token provider {TokenProviderType} cannot be refreshed on demand; the Azure SQL access token will not be refreshed in the background.", this.tokenProvider.GetType()); - return; + if (this.options.GetResource(scope) is null) + { + continue; + } + + var tokenProvider = this.ResolveTokenProvider(scope); + if (tokenProvider is not ITokenRefresher refresher) + { + this.logger.LogInformation("The registered token provider {TokenProviderType} for {Scope} cannot be refreshed on demand; its access token will not be refreshed in the background.", tokenProvider.GetType(), scope); + continue; + } + + loops.Add(this.RefreshLoopAsync(scope, refresher, stoppingToken)); + } + + await Task.WhenAll(loops).ConfigureAwait(false); + } + + /// + /// Computes the wait before retrying after the given number of consecutive failed refreshes. + /// + /// The number of consecutive failures so far, at least one. + /// The wait, doubling per failure up to . + private static TimeSpan GetRetryDelay(int consecutiveFailures) + { + var delay = InitialRetryDelay; + for (var failure = 1; failure < consecutiveFailures && delay < MaximumRetryDelay; failure++) + { + delay *= 2; } - this.logger.LogDebug("Background refresh of the Azure SQL access token started; refreshing {RefreshAheadWindow} ahead of expiry.", this.options.RefreshAheadWindow); + return delay < MaximumRetryDelay ? delay : MaximumRetryDelay; + } + + /// + /// Resolves the registered token provider of the resource. + /// + /// The resource. + /// The token provider. + private IAccessTokenProvider ResolveTokenProvider(TokenScope scope) + { + return scope switch + { + TokenScope.AzureSql => this.serviceProvider.GetRequiredService(), + TokenScope.BlobStorage => this.serviceProvider.GetRequiredService(), + _ => throw new ArgumentOutOfRangeException(nameof(scope), scope, "Unknown token scope."), + }; + } + + /// + /// Refreshes the token of one resource ahead of its expiry until the service stops, retrying failed refreshes + /// with exponential backoff. + /// + /// The resource. + /// Refreshes the resource's token. + /// The token that signals the service is stopping. + /// A task that completes once the service is stopping. + private async Task RefreshLoopAsync(TokenScope scope, ITokenRefresher refresher, CancellationToken stoppingToken) + { + this.logger.LogDebug("Background refresh of the {Scope} access token started; refreshing {RefreshAheadWindow} ahead of expiry.", scope, this.options.RefreshAheadWindow); var consecutiveFailures = 0; while (!stoppingToken.IsCancellationRequested) @@ -101,7 +159,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) var expiresOn = await refresher.RefreshAsync(stoppingToken).ConfigureAwait(false); consecutiveFailures = 0; delay = this.GetDelayUntilRefresh(expiresOn); - this.logger.LogDebug("Azure SQL access token refreshed; it expires at {ExpiresOn} and is refreshed again in {Delay}.", expiresOn, delay); + this.logger.LogDebug("{Scope} access token refreshed; it expires at {ExpiresOn} and is refreshed again in {Delay}.", scope, expiresOn, delay); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { @@ -111,29 +169,13 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { consecutiveFailures++; delay = GetRetryDelay(consecutiveFailures); - this.logger.LogError(ex, "Refreshing the Azure SQL access token failed ({ConsecutiveFailures} consecutive failures); retrying in {Delay}.", consecutiveFailures, delay); + this.logger.LogError(ex, "Refreshing the {Scope} access token failed ({ConsecutiveFailures} consecutive failures); retrying in {Delay}.", scope, consecutiveFailures, delay); } await this.WaitAsync(delay, stoppingToken).ConfigureAwait(false); } - this.logger.LogDebug("Background refresh of the Azure SQL access token is stopping."); - } - - /// - /// Computes the wait before retrying after the given number of consecutive failed refreshes. - /// - /// The number of consecutive failures so far, at least one. - /// The wait, doubling per failure up to . - private static TimeSpan GetRetryDelay(int consecutiveFailures) - { - var delay = InitialRetryDelay; - for (var failure = 1; failure < consecutiveFailures && delay < MaximumRetryDelay; failure++) - { - delay *= 2; - } - - return delay < MaximumRetryDelay ? delay : MaximumRetryDelay; + this.logger.LogDebug("Background refresh of the {Scope} access token is stopping.", scope); } /// diff --git a/Csag.WorkloadIdentity/Internal/TokenScopeExtensions.cs b/Csag.WorkloadIdentity/Internal/TokenScopeExtensions.cs new file mode 100644 index 0000000..a376ecd --- /dev/null +++ b/Csag.WorkloadIdentity/Internal/TokenScopeExtensions.cs @@ -0,0 +1,39 @@ +namespace Csag.WorkloadIdentity.Internal +{ + using System.Collections.Concurrent; + using System.Reflection; + + /// + /// Reads the metadata attached to members. + /// + internal static class TokenScopeExtensions + { + /// + /// The scope identifier of every member read so far, so that reflection runs once per member. + /// + private static readonly ConcurrentDictionary Identifiers = new(); + + /// + /// Gets the OAuth 2.0 scope requested from Microsoft Entra ID for the resource. + /// + /// The resource. + /// The OAuth 2.0 scope. + /// is not a member carrying . + public static string GetIdentifier(this TokenScope scope) + { + return Identifiers.GetOrAdd(scope, ReadIdentifier); + } + + /// + /// Reads the scope identifier from the member's attribute. + /// + /// The resource. + /// The OAuth 2.0 scope. + private static string ReadIdentifier(TokenScope scope) + { + var attribute = typeof(TokenScope).GetField(scope.ToString())?.GetCustomAttribute(); + return attribute?.Identifier + ?? throw new ArgumentOutOfRangeException(nameof(scope), scope, $"The value is not a {nameof(TokenScope)} member carrying {nameof(TokenScopeMetadataAttribute)}."); + } + } +} diff --git a/Csag.WorkloadIdentity/Internal/WorkloadIdentityOptionsExtensions.cs b/Csag.WorkloadIdentity/Internal/WorkloadIdentityOptionsExtensions.cs new file mode 100644 index 0000000..aecea41 --- /dev/null +++ b/Csag.WorkloadIdentity/Internal/WorkloadIdentityOptionsExtensions.cs @@ -0,0 +1,41 @@ +namespace Csag.WorkloadIdentity.Internal +{ + using Csag.WorkloadIdentity.Options; + + /// + /// Looks up the section of that configures a resource. + /// + internal static class WorkloadIdentityOptionsExtensions + { + /// + /// Gets the section that configures the resource, or when the resource is not configured. + /// + /// The options. + /// The resource. + /// The section, or . + public static WorkloadIdentityResourceOptions? GetResource(this WorkloadIdentityOptions options, TokenScope scope) + { + ArgumentNullException.ThrowIfNull(options); + + return scope switch + { + TokenScope.AzureSql => options.AzureSql, + TokenScope.BlobStorage => options.BlobStorage, + _ => throw new ArgumentOutOfRangeException(nameof(scope), scope, "Unknown token scope."), + }; + } + + /// + /// Gets the section that configures the resource. + /// + /// The options. + /// The resource. + /// The section. + /// The resource is not configured. + public static WorkloadIdentityResourceOptions GetRequiredResource(this WorkloadIdentityOptions options, TokenScope scope) + { + return options.GetResource(scope) + ?? throw new InvalidOperationException($"The {scope} resource is not configured. Add the '{WorkloadIdentityOptions.ConfigurationSectionName}:{scope}' configuration section, or set {nameof(WorkloadIdentityOptions)}.{scope} when configuring the options in code."); + } + } +} diff --git a/Csag.WorkloadIdentity/Options/AzureSqlFederatedIdentityOptions.cs b/Csag.WorkloadIdentity/Options/AzureSqlFederatedIdentityOptions.cs deleted file mode 100644 index a5d479e..0000000 --- a/Csag.WorkloadIdentity/Options/AzureSqlFederatedIdentityOptions.cs +++ /dev/null @@ -1,42 +0,0 @@ -namespace Csag.WorkloadIdentity.Options -{ - /// - /// Options for configuring Azure SQL federated identity integration. - /// - public class AzureSqlFederatedIdentityOptions - { - /// - /// Gets the configuration section the options are bound from by default. - /// - public static string ConfigurationSectionName { get; } = "Csag.WorkloadIdentity"; - - /// - /// Gets or sets the Azure AD tenant ID. - /// - public string TenantId { get; set; } = string.Empty; - - /// - /// Gets or sets the Azure AD client ID. - /// - public string ClientId { get; set; } = string.Empty; - - /// - /// Gets or sets the Google-specific options. - /// - public GoogleOptions? Google { get; set; } - - /// - /// Gets or sets how long before an access token expires it is treated as due for refresh. A call that finds - /// the current token inside this window exchanges a fresh one, and the background refresh service refreshes - /// the token this long before it expires. Must be positive; defaults to five minutes. - /// - public TimeSpan RefreshAheadWindow { get; set; } = TimeSpan.FromMinutes(5); - - /// - /// Gets or sets a value indicating whether a hosted service keeps the access token refreshed ahead of its - /// expiry, so that callers are served from the held token instead of waiting for a token exchange. - /// Defaults to . - /// - public bool EnableBackgroundRefresh { get; set; } = true; - } -} diff --git a/Csag.WorkloadIdentity/Options/AzureSqlFederatedIdentityOptionsValidator.cs b/Csag.WorkloadIdentity/Options/AzureSqlFederatedIdentityOptionsValidator.cs deleted file mode 100644 index cb91420..0000000 --- a/Csag.WorkloadIdentity/Options/AzureSqlFederatedIdentityOptionsValidator.cs +++ /dev/null @@ -1,48 +0,0 @@ -namespace Csag.WorkloadIdentity.Options -{ - using Microsoft.Extensions.Options; - - /// - /// Validates to ensure all required configuration values are set. - /// - public class AzureSqlFederatedIdentityOptionsValidator : IValidateOptions - { - /// - /// Validates the specified instance. - /// - /// The name of the options instance being validated. - /// The options instance to validate. - /// A indicating success or failure. - public ValidateOptionsResult Validate(string? name, AzureSqlFederatedIdentityOptions options) - { - ArgumentNullException.ThrowIfNull(options); - - if (string.IsNullOrWhiteSpace(options.TenantId)) - { - return ValidateOptionsResult.Fail("TenantId must be provided."); - } - - if (string.IsNullOrWhiteSpace(options.ClientId)) - { - return ValidateOptionsResult.Fail("ClientId must be provided."); - } - - if (options.Google is null) - { - return ValidateOptionsResult.Fail("Google section must be provided."); - } - - if (string.IsNullOrWhiteSpace(options.Google.ServiceAccountEmail)) - { - return ValidateOptionsResult.Fail("Google:ServiceAccountEmail must be provided."); - } - - if (options.RefreshAheadWindow <= TimeSpan.Zero) - { - return ValidateOptionsResult.Fail("RefreshAheadWindow must be a positive duration."); - } - - return ValidateOptionsResult.Success; - } - } -} diff --git a/Csag.WorkloadIdentity/Options/GoogleOptions.cs b/Csag.WorkloadIdentity/Options/GoogleOptions.cs index dffcf11..be3f620 100644 --- a/Csag.WorkloadIdentity/Options/GoogleOptions.cs +++ b/Csag.WorkloadIdentity/Options/GoogleOptions.cs @@ -1,12 +1,27 @@ namespace Csag.WorkloadIdentity.Options { /// - /// Google-specific options for federated identity. + /// Settings for obtaining an access token through Google workload identity federation: the application's Google + /// identity mints an ID token for a service account, and Microsoft Entra ID accepts that ID token as the client + /// assertion of the identity that holds the federated credential. /// public class GoogleOptions { /// - /// Gets or sets the Google service account email to impersonate for ID token generation. + /// Gets or sets the directory (tenant) ID of the Microsoft Entra tenant that issues the access token. + /// + public string? TenantId { get; set; } + + /// + /// Gets or sets the client ID of the Microsoft Entra identity that holds the federated credential trusting + /// the service account: either the application (client) ID of an app registration or the client ID of a + /// user-assigned managed identity. + /// + public string? ClientId { get; set; } + + /// + /// Gets or sets the email of the Google service account the ID token is minted for. The federated credential + /// names this account's unique ID as its subject. /// public string? ServiceAccountEmail { get; set; } } diff --git a/Csag.WorkloadIdentity/Options/ManagedIdentityOptions.cs b/Csag.WorkloadIdentity/Options/ManagedIdentityOptions.cs new file mode 100644 index 0000000..ce279bf --- /dev/null +++ b/Csag.WorkloadIdentity/Options/ManagedIdentityOptions.cs @@ -0,0 +1,21 @@ +namespace Csag.WorkloadIdentity.Options +{ + /// + /// Names the Azure managed identity an access token is obtained with. + /// + public class ManagedIdentityOptions + { + /// + /// Gets or sets a value indicating whether the system-assigned managed identity of the hosting resource is + /// used. When , is ignored; when , + /// must name a user-assigned managed identity. Defaults to . + /// + public bool UseSystemAssignedIdentity { get; set; } + + /// + /// Gets or sets the client ID of the user-assigned managed identity. Required unless + /// is . + /// + public string? ClientId { get; set; } + } +} diff --git a/Csag.WorkloadIdentity/Options/WorkloadIdentityOptions.cs b/Csag.WorkloadIdentity/Options/WorkloadIdentityOptions.cs new file mode 100644 index 0000000..521d05f --- /dev/null +++ b/Csag.WorkloadIdentity/Options/WorkloadIdentityOptions.cs @@ -0,0 +1,42 @@ +namespace Csag.WorkloadIdentity.Options +{ + /// + /// Options for obtaining Azure access tokens with the application's workload identity. Each supported resource + /// has its own section; a resource whose section is absent is not configured, and its token provider cannot be + /// resolved. + /// + public class WorkloadIdentityOptions + { + /// + /// Gets the configuration section the options are bound from by default. + /// + public static string ConfigurationSectionName { get; } = "Csag.WorkloadIdentity"; + + /// + /// Gets or sets how access tokens for Azure SQL are obtained, or when the application + /// does not use Azure SQL. + /// + public WorkloadIdentityResourceOptions? AzureSql { get; set; } + + /// + /// Gets or sets how access tokens for Azure Blob Storage are obtained, or when the + /// application does not use Blob Storage. + /// + public WorkloadIdentityResourceOptions? BlobStorage { get; set; } + + /// + /// Gets or sets how long before an access token expires it is treated as due for refresh. A call that finds + /// the current token inside this window obtains a fresh one, and the background refresh service refreshes + /// each token this long before it expires. Applies to every resource. Must be positive; defaults to five + /// minutes. + /// + public TimeSpan RefreshAheadWindow { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Gets or sets a value indicating whether a hosted service keeps the access token of every configured + /// resource refreshed ahead of its expiry, so that callers are served from the held token instead of waiting + /// for a token request. Defaults to . + /// + public bool EnableBackgroundRefresh { get; set; } = true; + } +} diff --git a/Csag.WorkloadIdentity/Options/WorkloadIdentityOptionsValidator.cs b/Csag.WorkloadIdentity/Options/WorkloadIdentityOptionsValidator.cs new file mode 100644 index 0000000..d2a49f9 --- /dev/null +++ b/Csag.WorkloadIdentity/Options/WorkloadIdentityOptionsValidator.cs @@ -0,0 +1,117 @@ +namespace Csag.WorkloadIdentity.Options +{ + using Microsoft.Extensions.Options; + + /// + /// Validates : at least one resource must be configured, every configured + /// resource must carry the complete settings of the provider it selects, and the refresh-ahead window must be + /// positive. Every failure names the configuration path of the offending value, relative to the options section. + /// + internal sealed class WorkloadIdentityOptionsValidator : IValidateOptions + { + /// + public ValidateOptionsResult Validate(string? name, WorkloadIdentityOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var failures = new List(); + if (options.AzureSql is null && options.BlobStorage is null) + { + failures.Add($"At least one resource section ({nameof(options.AzureSql)} or {nameof(options.BlobStorage)}) must be configured."); + } + + ValidateResource(nameof(options.AzureSql), options.AzureSql, failures); + ValidateResource(nameof(options.BlobStorage), options.BlobStorage, failures); + + if (options.RefreshAheadWindow <= TimeSpan.Zero) + { + failures.Add($"{nameof(options.RefreshAheadWindow)} must be a positive duration."); + } + + return failures.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures); + } + + /// + /// Validates a resource section, if present, against the provider it selects. + /// + /// The configuration path of the section. + /// The section, or when the resource is not configured. + /// Receives a message for every invalid value. + private static void ValidateResource(string path, WorkloadIdentityResourceOptions? resource, List failures) + { + if (resource is null) + { + return; + } + + switch (resource.Provider) + { + case WorkloadIdentityProvider.ManagedIdentity: + ValidateManagedIdentity(path, resource.ManagedIdentity, failures); + break; + case WorkloadIdentityProvider.Google: + ValidateGoogle(path, resource.Google, failures); + break; + default: + failures.Add($"{path}:{nameof(resource.Provider)} must be {nameof(WorkloadIdentityProvider.ManagedIdentity)} or {nameof(WorkloadIdentityProvider.Google)}."); + break; + } + } + + /// + /// Validates the managed identity section of a resource. + /// + /// The configuration path of the resource section. + /// The managed identity section, or when it is absent. + /// Receives a message for every invalid value. + private static void ValidateManagedIdentity(string path, ManagedIdentityOptions? managedIdentity, List failures) + { + var sectionPath = $"{path}:{nameof(WorkloadIdentityResourceOptions.ManagedIdentity)}"; + if (managedIdentity is null) + { + failures.Add($"{sectionPath} section must be provided when {path}:{nameof(WorkloadIdentityResourceOptions.Provider)} is {nameof(WorkloadIdentityProvider.ManagedIdentity)}."); + return; + } + + if (!managedIdentity.UseSystemAssignedIdentity && string.IsNullOrWhiteSpace(managedIdentity.ClientId)) + { + failures.Add($"{sectionPath}:{nameof(managedIdentity.ClientId)} must be provided unless {sectionPath}:{nameof(managedIdentity.UseSystemAssignedIdentity)} is true."); + } + } + + /// + /// Validates the Google section of a resource. + /// + /// The configuration path of the resource section. + /// The Google section, or when it is absent. + /// Receives a message for every invalid value. + private static void ValidateGoogle(string path, GoogleOptions? google, List failures) + { + var sectionPath = $"{path}:{nameof(WorkloadIdentityResourceOptions.Google)}"; + if (google is null) + { + failures.Add($"{sectionPath} section must be provided when {path}:{nameof(WorkloadIdentityResourceOptions.Provider)} is {nameof(WorkloadIdentityProvider.Google)}."); + return; + } + + RequireValue(sectionPath, nameof(google.TenantId), google.TenantId, failures); + RequireValue(sectionPath, nameof(google.ClientId), google.ClientId, failures); + RequireValue(sectionPath, nameof(google.ServiceAccountEmail), google.ServiceAccountEmail, failures); + } + + /// + /// Records a failure when a required value is blank. + /// + /// The configuration path of the section holding the value. + /// The key of the value within the section. + /// The value. + /// Receives the message when the value is blank. + private static void RequireValue(string sectionPath, string key, string? value, List failures) + { + if (string.IsNullOrWhiteSpace(value)) + { + failures.Add($"{sectionPath}:{key} must be provided."); + } + } + } +} diff --git a/Csag.WorkloadIdentity/Options/WorkloadIdentityProvider.cs b/Csag.WorkloadIdentity/Options/WorkloadIdentityProvider.cs new file mode 100644 index 0000000..c3bd480 --- /dev/null +++ b/Csag.WorkloadIdentity/Options/WorkloadIdentityProvider.cs @@ -0,0 +1,19 @@ +namespace Csag.WorkloadIdentity.Options +{ + /// + /// The identity an access token is obtained with. + /// + public enum WorkloadIdentityProvider + { + /// + /// The Azure managed identity, system-assigned or user-assigned, of the resource the application runs on. + /// + ManagedIdentity = 0, + + /// + /// The application's Google identity, exchanged for a Microsoft Entra ID token through workload identity + /// federation. + /// + Google = 1, + } +} diff --git a/Csag.WorkloadIdentity/Options/WorkloadIdentityResourceOptions.cs b/Csag.WorkloadIdentity/Options/WorkloadIdentityResourceOptions.cs new file mode 100644 index 0000000..7742625 --- /dev/null +++ b/Csag.WorkloadIdentity/Options/WorkloadIdentityResourceOptions.cs @@ -0,0 +1,27 @@ +namespace Csag.WorkloadIdentity.Options +{ + /// + /// Configures how access tokens for one Azure resource are obtained: which identity provider is used and the + /// settings of that provider. + /// + public class WorkloadIdentityResourceOptions + { + /// + /// Gets or sets the identity provider the access token is obtained with. Defaults to + /// . + /// + public WorkloadIdentityProvider Provider { get; set; } + + /// + /// Gets or sets the managed identity settings. Required when is + /// ; ignored otherwise. + /// + public ManagedIdentityOptions? ManagedIdentity { get; set; } + + /// + /// Gets or sets the Google federation settings. Required when is + /// ; ignored otherwise. + /// + public GoogleOptions? Google { get; set; } + } +} diff --git a/Csag.WorkloadIdentity/README.md b/Csag.WorkloadIdentity/README.md index 1827bdf..509a463 100644 --- a/Csag.WorkloadIdentity/README.md +++ b/Csag.WorkloadIdentity/README.md @@ -17,6 +17,7 @@ How it works: ## Features - Exchanges a Google-signed ID token for a Microsoft Entra ID access token for Azure SQL; there are no secrets to store or rotate. +- Also serves Azure Blob Storage (`IBlobStorageTokenProvider`), and an application that runs on Azure can obtain the tokens from its managed identity instead (`"Provider": "ManagedIdentity"`). `WorkloadIdentityTokenCredential` presents a provider to Azure SDK clients as a `TokenCredential`. - Holds the current access token in memory and hands it out until it enters the configured refresh-ahead window. Callers that find no usable token share a single exchange instead of each running their own. - Background refresh (on by default): a hosted service exchanges a fresh token whenever the held one enters the refresh-ahead window, so requests are served from a valid token without waiting for an exchange. Failed exchanges are retried with exponential backoff. - Options are validated when the host starts, so a missing or invalid value fails fast with a message that names it. @@ -45,15 +46,18 @@ The library returns a token string and does not depend on `Microsoft.Data.SqlCli ### 2. Configure -The options bind from the `Csag.WorkloadIdentity` section of the configuration: +The options bind from the `Csag.WorkloadIdentity` section of the configuration. Each resource has its own section that selects the identity provider and carries that provider's settings; this is Azure SQL over Google federation: ```json { "Csag.WorkloadIdentity": { - "TenantId": "", - "ClientId": "", - "Google": { - "ServiceAccountEmail": "@.iam.gserviceaccount.com" + "AzureSql": { + "Provider": "Google", + "Google": { + "TenantId": "", + "ClientId": "", + "ServiceAccountEmail": "@.iam.gserviceaccount.com" + } }, "RefreshAheadWindow": "00:05:00", "EnableBackgroundRefresh": true @@ -66,13 +70,16 @@ The options bind from the `Csag.WorkloadIdentity` section of the configuration: | Key | Required | Default | Description | |---|---|---|---| -| `TenantId` | yes | – | Directory (tenant) ID of the Microsoft Entra tenant. | -| `ClientId` | yes | – | Application (client) ID of the app registration that holds the federated credential. | -| `Google:ServiceAccountEmail` | yes | – | The Google service account the ID token is minted for. The federated credential names this account's unique ID as its subject. | -| `RefreshAheadWindow` | no | `00:05:00` | How long before the access token expires it is treated as due for refresh. Must be positive. | -| `EnableBackgroundRefresh` | no | `true` | Whether a hosted service keeps the token refreshed ahead of its expiry. | +| `AzureSql:Provider` | no | `ManagedIdentity` | `Google` for workload identity federation as shown here. The default, `ManagedIdentity`, expects an `AzureSql:ManagedIdentity` section instead of `AzureSql:Google`. | +| `AzureSql:Google:TenantId` | yes | – | Directory (tenant) ID of the Microsoft Entra tenant. | +| `AzureSql:Google:ClientId` | yes | – | Application (client) ID of the app registration that holds the federated credential (or the client ID of a user-assigned managed identity holding it). | +| `AzureSql:Google:ServiceAccountEmail` | yes | – | The Google service account the ID token is minted for. The federated credential names this account's unique ID as its subject. | +| `RefreshAheadWindow` | no | `00:05:00` | How long before an access token expires it is treated as due for refresh. Applies to every resource. Must be positive. | +| `EnableBackgroundRefresh` | no | `true` | Whether a hosted service keeps the tokens refreshed ahead of their expiry. | -Environment variables follow the usual .NET mapping, with `__` as the section separator: `Csag.WorkloadIdentity__TenantId`, `Csag.WorkloadIdentity__ClientId`, `Csag.WorkloadIdentity__Google__ServiceAccountEmail`, and so on. +A `BlobStorage` section of the same shape configures `IBlobStorageTokenProvider`. With `"Provider": "ManagedIdentity"` a section carries `"ManagedIdentity": { "UseSystemAssignedIdentity": true }` or `"ManagedIdentity": { "ClientId": "" }` instead of `Google`. At least one resource section is required. + +Environment variables follow the usual .NET mapping, with `__` as the section separator: `Csag.WorkloadIdentity__AzureSql__Provider`, `Csag.WorkloadIdentity__AzureSql__Google__TenantId`, `Csag.WorkloadIdentity__AzureSql__Google__ClientId`, `Csag.WorkloadIdentity__AzureSql__Google__ServiceAccountEmail`, and so on. The connection string deliberately carries no credentials; see the notes below. @@ -84,7 +91,7 @@ using Csag.WorkloadIdentity; var builder = WebApplication.CreateBuilder(args); // Binds the "Csag.WorkloadIdentity" section of the host configuration. -builder.Services.AddAzureSqlFederatedIdentity(); +builder.Services.AddWorkloadIdentity(); var app = builder.Build(); app.Run(); @@ -97,20 +104,27 @@ using Csag.WorkloadIdentity; using Csag.WorkloadIdentity.Options; // Binds the "Csag.WorkloadIdentity" section of the given configuration. -services.AddAzureSqlFederatedIdentity(configuration); +services.AddWorkloadIdentity(configuration); -// Sets the options in code; the section name is available as AzureSqlFederatedIdentityOptions.ConfigurationSectionName. -services.AddAzureSqlFederatedIdentity(options => +// Sets the options in code; the section name is available as WorkloadIdentityOptions.ConfigurationSectionName. +services.AddWorkloadIdentity(options => { - options.TenantId = ""; - options.ClientId = ""; - options.Google = new GoogleOptions { ServiceAccountEmail = "@.iam.gserviceaccount.com" }; + options.AzureSql = new WorkloadIdentityResourceOptions + { + Provider = WorkloadIdentityProvider.Google, + Google = new GoogleOptions + { + TenantId = "", + ClientId = "", + ServiceAccountEmail = "@.iam.gserviceaccount.com", + }, + }; options.RefreshAheadWindow = TimeSpan.FromMinutes(10); options.EnableBackgroundRefresh = true; }); ``` -`GoogleOptions` and `AzureSqlFederatedIdentityOptions` live in `Csag.WorkloadIdentity.Options`. The options are validated when the host starts: a missing `TenantId`, `ClientId` or `Google:ServiceAccountEmail`, or a non-positive `RefreshAheadWindow`, throws an `OptionsValidationException` that names the offending value. Calling `AddAzureSqlFederatedIdentity` more than once is harmless. +`WorkloadIdentityOptions`, `WorkloadIdentityResourceOptions`, `GoogleOptions` and `ManagedIdentityOptions` live in `Csag.WorkloadIdentity.Options`. The options are validated when the host starts: no resource section at all, a missing value in a configured resource's provider section, or a non-positive `RefreshAheadWindow` throws an `OptionsValidationException` that names every offending value, for example `AzureSql:Google:ServiceAccountEmail must be provided.` Calling `AddWorkloadIdentity` more than once is harmless. Both token providers are always registered; resolving the provider of a resource whose section is absent throws an `InvalidOperationException` that names the missing section. ### 4. Use the token diff --git a/Csag.WorkloadIdentity/TokenScope.cs b/Csag.WorkloadIdentity/TokenScope.cs new file mode 100644 index 0000000..2303e13 --- /dev/null +++ b/Csag.WorkloadIdentity/TokenScope.cs @@ -0,0 +1,22 @@ +namespace Csag.WorkloadIdentity +{ + /// + /// The Azure resources the library obtains access tokens for. Each member carries the OAuth 2.0 scope that is + /// requested from Microsoft Entra ID for the resource and is named after the + /// property that configures the resource. + /// + public enum TokenScope + { + /// + /// Azure SQL Database. + /// + [TokenScopeMetadata("https://database.windows.net/.default")] + AzureSql = 0, + + /// + /// Azure Blob Storage. + /// + [TokenScopeMetadata("https://storage.azure.com/.default")] + BlobStorage = 1, + } +} diff --git a/Csag.WorkloadIdentity/TokenScopeMetadataAttribute.cs b/Csag.WorkloadIdentity/TokenScopeMetadataAttribute.cs new file mode 100644 index 0000000..6374bdd --- /dev/null +++ b/Csag.WorkloadIdentity/TokenScopeMetadataAttribute.cs @@ -0,0 +1,25 @@ +namespace Csag.WorkloadIdentity +{ + /// + /// Names the OAuth 2.0 scope that is requested from Microsoft Entra ID for a member. + /// + [AttributeUsage(AttributeTargets.Field, AllowMultiple = false)] + public sealed class TokenScopeMetadataAttribute : Attribute + { + /// + /// Initializes a new instance of the class. + /// + /// The OAuth 2.0 scope, for example https://database.windows.net/.default. + public TokenScopeMetadataAttribute(string identifier) + { + ArgumentNullException.ThrowIfNull(identifier); + + this.Identifier = identifier; + } + + /// + /// Gets the OAuth 2.0 scope requested for the resource. + /// + public string Identifier { get; } + } +} diff --git a/Csag.WorkloadIdentity/WorkloadIdentityServiceCollectionExtensions.cs b/Csag.WorkloadIdentity/WorkloadIdentityServiceCollectionExtensions.cs new file mode 100644 index 0000000..cfe78ee --- /dev/null +++ b/Csag.WorkloadIdentity/WorkloadIdentityServiceCollectionExtensions.cs @@ -0,0 +1,98 @@ +namespace Csag.WorkloadIdentity +{ + using Csag.WorkloadIdentity.Abstractions; + using Csag.WorkloadIdentity.Internal; + using Csag.WorkloadIdentity.Internal.Services; + using Csag.WorkloadIdentity.Options; + using Microsoft.Extensions.Configuration; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.DependencyInjection.Extensions; + using Microsoft.Extensions.Options; + + /// + /// Provides extension methods for registering the workload identity token providers. + /// + public static class WorkloadIdentityServiceCollectionExtensions + { + /// + /// Adds the workload identity token providers, binding the options from the + /// section of the + /// registered in the container. The options are validated when the host starts. + /// + /// The service collection. + /// The service collection for chaining. + public static IServiceCollection AddWorkloadIdentity(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddOptions() + .BindConfiguration(WorkloadIdentityOptions.ConfigurationSectionName) + .ValidateOnStart(); + RegisterWorkloadIdentityServices(services); + return services; + } + + /// + /// Adds the workload identity token providers with the options configured in code. The options are validated + /// when the host starts. + /// + /// The service collection. + /// The action to configure options. + /// The service collection for chaining. + public static IServiceCollection AddWorkloadIdentity(this IServiceCollection services, Action configureOptions) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configureOptions); + + services.AddOptions() + .Configure(configureOptions) + .ValidateOnStart(); + RegisterWorkloadIdentityServices(services); + return services; + } + + /// + /// Adds the workload identity token providers, binding the options from the + /// section of the given configuration. Use this + /// when the configuration is not registered as in the container. The options are + /// validated when the host starts. + /// + /// The service collection. + /// The application configuration. + /// The service collection for chaining. + public static IServiceCollection AddWorkloadIdentity(this IServiceCollection services, IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + services.AddOptions() + .Bind(configuration.GetSection(WorkloadIdentityOptions.ConfigurationSectionName)) + .ValidateOnStart(); + RegisterWorkloadIdentityServices(services); + return services; + } + + /// + /// Registers the token pipeline. Every registration is a Try* registration, so a consumer can substitute any + /// service by registering its own implementation first, and repeated calls do not duplicate anything. Both + /// resource providers are always registered; resolving the provider of a resource whose section is not + /// configured throws an that names the missing section. + /// + /// The service collection. + private static void RegisterWorkloadIdentityServices(IServiceCollection services) + { + services.TryAddSingleton(TimeProvider.System); + services.TryAddEnumerable(ServiceDescriptor.Singleton, WorkloadIdentityOptionsValidator>()); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddSingleton(); + services.TryAddSingleton(serviceProvider => new AzureSqlTokenProvider(serviceProvider.GetRequiredService().Create(TokenScope.AzureSql))); + services.TryAddSingleton(serviceProvider => new BlobStorageTokenProvider(serviceProvider.GetRequiredService().Create(TokenScope.BlobStorage))); + services.AddHostedService(); + } + } +} diff --git a/Csag.WorkloadIdentity/WorkloadIdentityTokenCredential.cs b/Csag.WorkloadIdentity/WorkloadIdentityTokenCredential.cs new file mode 100644 index 0000000..6963866 --- /dev/null +++ b/Csag.WorkloadIdentity/WorkloadIdentityTokenCredential.cs @@ -0,0 +1,57 @@ +namespace Csag.WorkloadIdentity +{ + using Azure.Core; + using Csag.WorkloadIdentity.Abstractions; + + /// + /// Presents a token provider to Azure SDK clients as a , so that for example a + /// BlobServiceClient can be constructed from an . Every token + /// request is served by the provider, which reuses its held token and reports that token's real expiry. The + /// scopes in the request are ignored, because the provider is bound to one resource. + /// + public sealed class WorkloadIdentityTokenCredential : TokenCredential + { + /// + /// The provider that serves the token requests. + /// + private readonly IAccessTokenProvider tokenProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The provider that serves the token requests. + public WorkloadIdentityTokenCredential(IAccessTokenProvider tokenProvider) + { + ArgumentNullException.ThrowIfNull(tokenProvider); + + this.tokenProvider = tokenProvider; + } + + /// + /// Gets the provider's current token, blocking the calling thread while the provider obtains one. Azure SDK + /// clients call this overload from their synchronous APIs; prefer their asynchronous APIs. + /// + /// The token request, whose scopes are ignored. + /// A cancellation token. + /// The access token. + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + // TokenCredential.GetToken is synchronous by contract, so the provider's asynchronous call is waited on + // here. The library's providers never capture a synchronization context, so the wait cannot deadlock them. +#pragma warning disable S4462 // Calls to "async" methods should not be blocking + return this.tokenProvider.GetAccessTokenAsync(cancellationToken).GetAwaiter().GetResult(); +#pragma warning restore S4462 + } + + /// + /// Gets the provider's current token. + /// + /// The token request, whose scopes are ignored. + /// A cancellation token. + /// The access token. + public override async ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return await this.tokenProvider.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false); + } + } +} From 474c41de5b86b6cec1937a348839572739dd9f18 Mon Sep 17 00:00:00 2001 From: Timothy Trowbridge Date: Fri, 18 Sep 2026 12:58:56 -0300 Subject: [PATCH 2/4] Tests: cover cancellation during the shared IAM client creation Co-Authored-By: Claude Fable 5.1 --- Csag.WorkloadIdentity.UnitTests/GoogleIdTokenProviderTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Csag.WorkloadIdentity.UnitTests/GoogleIdTokenProviderTests.cs b/Csag.WorkloadIdentity.UnitTests/GoogleIdTokenProviderTests.cs index 582f356..df4a5b4 100644 --- a/Csag.WorkloadIdentity.UnitTests/GoogleIdTokenProviderTests.cs +++ b/Csag.WorkloadIdentity.UnitTests/GoogleIdTokenProviderTests.cs @@ -147,8 +147,8 @@ public async Task Given_FirstCallerCancels_When_ClientCreationCompletes_Then_Sec using var firstCaller = new CancellationTokenSource(); // Act - var first = this.provider.GetIdTokenAsync(firstCaller.Token); - var second = this.provider.GetIdTokenAsync(CancellationToken.None); + var first = this.provider.GetIdTokenAsync(ServiceAccountEmail, firstCaller.Token); + var second = this.provider.GetIdTokenAsync(ServiceAccountEmail, CancellationToken.None); await firstCaller.CancelAsync(); await Should.ThrowAsync(() => first); creation.SetResult(this.client); From df9ad8e081e1c9a438019b4e089888b01b120b8e Mon Sep 17 00:00:00 2001 From: Timothy Trowbridge Date: Fri, 18 Sep 2026 13:33:01 -0300 Subject: [PATCH 3/4] Tests: cover a cancelled IAM client creation on the generalised provider Co-Authored-By: Claude Fable 5.1 --- Csag.WorkloadIdentity.UnitTests/GoogleIdTokenProviderTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Csag.WorkloadIdentity.UnitTests/GoogleIdTokenProviderTests.cs b/Csag.WorkloadIdentity.UnitTests/GoogleIdTokenProviderTests.cs index df4a5b4..77c4e96 100644 --- a/Csag.WorkloadIdentity.UnitTests/GoogleIdTokenProviderTests.cs +++ b/Csag.WorkloadIdentity.UnitTests/GoogleIdTokenProviderTests.cs @@ -125,8 +125,8 @@ public async Task Given_CancelledClientCreation_When_GetIdTokenAsyncAgain_Then_R this.SetupGeneratedToken(IdToken); // Act - await Should.ThrowAsync(() => this.provider.GetIdTokenAsync(CancellationToken.None)); - var result = await this.provider.GetIdTokenAsync(CancellationToken.None); + await Should.ThrowAsync(() => this.provider.GetIdTokenAsync(ServiceAccountEmail, CancellationToken.None)); + var result = await this.provider.GetIdTokenAsync(ServiceAccountEmail, CancellationToken.None); // Assert result.ShouldBe(IdToken); From 62b838272b2f81c402865aa5e4567f834b1a0c3c Mon Sep 17 00:00:00 2001 From: Timothy Trowbridge Date: Fri, 18 Sep 2026 14:02:34 -0300 Subject: [PATCH 4/4] Docs: the hosted service is registered with TryAddEnumerable, not a replaceable Try* registration Co-Authored-By: Claude Fable 5.1 --- .../WorkloadIdentityServiceCollectionExtensions.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Csag.WorkloadIdentity/WorkloadIdentityServiceCollectionExtensions.cs b/Csag.WorkloadIdentity/WorkloadIdentityServiceCollectionExtensions.cs index cfe78ee..d87ad5c 100644 --- a/Csag.WorkloadIdentity/WorkloadIdentityServiceCollectionExtensions.cs +++ b/Csag.WorkloadIdentity/WorkloadIdentityServiceCollectionExtensions.cs @@ -73,8 +73,10 @@ public static IServiceCollection AddWorkloadIdentity(this IServiceCollection ser } /// - /// Registers the token pipeline. Every registration is a Try* registration, so a consumer can substitute any - /// service by registering its own implementation first, and repeated calls do not duplicate anything. Both + /// Registers the token pipeline. Each service is a Try* registration, so a consumer can substitute it by + /// registering its own implementation first. The hosted service is the exception: it is added with + /// TryAddEnumerable, which prevents duplicates but not substitution, and is turned off through + /// . Repeated calls do not duplicate anything. Both /// resource providers are always registered; resolving the provider of a resource whose section is not /// configured throws an that names the missing section. ///