From ae81fc4dccdedec9bb97d692cf815c93b0db438b Mon Sep 17 00:00:00 2001 From: Artur Karbone Date: Wed, 11 Mar 2026 07:52:07 +0200 Subject: [PATCH 1/8] First draft --- AppForeach.TokenHandler.sln | 8 +- docs/BACKGROUND-SERVICES-AUTHENTICATION.md | 480 +++++++++++++++++++++ 2 files changed, 485 insertions(+), 3 deletions(-) create mode 100644 docs/BACKGROUND-SERVICES-AUTHENTICATION.md diff --git a/AppForeach.TokenHandler.sln b/AppForeach.TokenHandler.sln index 667b665..a0750f9 100644 --- a/AppForeach.TokenHandler.sln +++ b/AppForeach.TokenHandler.sln @@ -31,14 +31,16 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "realms", "realms", "{1B8C58 EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "backup", "backup", "{12B7FE65-067B-4303-9CAA-C3DB32BCFA07}" - ProjectSection(SolutionItems) = preProject - .keycloak\backup\poc-realm-experimental-old.json = .keycloak\backup\poc-realm-experimental-old.json - EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Poc.InternalApi", "samples\Poc.InternalApi\Poc.InternalApi.csproj", "{B5A52272-0A46-6A90-A185-0154F729982A}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppForeach.TokenHandler.Tests", "tests\AppForeach.TokenHandler.Tests\AppForeach.TokenHandler.Tests.csproj", "{370A4995-4FB0-46CB-97D7-17DBD4F5CBBA}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{9D5DA24B-9D4D-4CBC-A4CD-F0D1790CD240}" + ProjectSection(SolutionItems) = preProject + docs\BACKGROUND-SERVICES-AUTHENTICATION.md = docs\BACKGROUND-SERVICES-AUTHENTICATION.md + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU diff --git a/docs/BACKGROUND-SERVICES-AUTHENTICATION.md b/docs/BACKGROUND-SERVICES-AUTHENTICATION.md new file mode 100644 index 0000000..3f8b8f1 --- /dev/null +++ b/docs/BACKGROUND-SERVICES-AUTHENTICATION.md @@ -0,0 +1,480 @@ +# Background Services Authentication Guide + +This guide explains how to authenticate background services such as +- hosted services +- worker services +- scheduled jobs + +using the **Client Credentials Grant** flow with OAuth 2.0/OpenID Connect. + +## Overview + +Background services don't have user interaction, so they cannot use interactive authentication flows like Authorization Code. Instead, they authenticate as **themselves** (machine-to-machine) using the **Client Credentials Grant**. + +### When to Use Client Credentials Grant + +| Scenario | Grant Type | +|----------|------------| +| User-facing web apps | Authorization Code + PKCE | +| Background services/workers | **Client Credentials** | +| Scheduled jobs/timers | **Client Credentials** | +| Service-to-service communication | **Client Credentials** or Token Exchange | +| Microservice internal calls | **Client Credentials** or Token Exchange | + +## Architecture + +```mermaid +sequenceDiagram + participant BS as Background Service
(Worker/Job) + participant KC as Keycloak
Token Endpoint + participant API as Protected API + + BS->>KC: 1. Request Token
(client_credentials grant) + KC-->>BS: 2. Return Access Token + BS->>API: 3. Call API with Bearer Token + API-->>BS: 4. API Response +``` + +## Implementation + +### Option 1: Using Existing TokenHandlerOptions (Recommended) + +If your project already uses `AddTokenHandler`, you can leverage the same `OpenIdConnectOptions` for background services. + +#### Step 1: Create a Client Credentials Token Service + +```csharp +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.Extensions.Options; +using System.Text.Json; + +namespace YourApp.Services; + +/// +/// Service for acquiring tokens using Client Credentials Grant. +/// Reuses OpenIdConnectOptions from the existing token handler configuration. +/// +public interface IClientCredentialsTokenService +{ + /// + /// Gets an access token for the specified audience using client credentials. + /// + Task GetAccessTokenAsync( + string? audience = null, + IEnumerable? scopes = null, + CancellationToken cancellationToken = default); +} + +public class ClientCredentialsTokenService : IClientCredentialsTokenService +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly OpenIdConnectOptions _oidcOptions; + private readonly ILogger _logger; + + public ClientCredentialsTokenService( + IHttpClientFactory httpClientFactory, + IOptionsMonitor oidcOptionsMonitor, + ILogger logger) + { + _httpClientFactory = httpClientFactory; + // Get the "oidc" named options configured by AddTokenHandler + _oidcOptions = oidcOptionsMonitor.Get("oidc"); + _logger = logger; + } + + public async Task GetAccessTokenAsync( + string? audience = null, + IEnumerable? scopes = null, + CancellationToken cancellationToken = default) + { + var tokenEndpoint = await GetTokenEndpointAsync(cancellationToken); + + if (string.IsNullOrEmpty(tokenEndpoint)) + { + return ClientCredentialsResult.Failure("config_error", "Token endpoint not configured"); + } + + var clientId = _oidcOptions.ClientId; + var clientSecret = _oidcOptions.ClientSecret; + + if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(clientSecret)) + { + return ClientCredentialsResult.Failure("config_error", "Client credentials not configured"); + } + + var body = new Dictionary + { + ["grant_type"] = "client_credentials", + ["client_id"] = clientId, + ["client_secret"] = clientSecret + }; + + // Add audience if specified (for Keycloak, this targets a specific client) + if (!string.IsNullOrEmpty(audience)) + { + body["audience"] = audience; + } + + // Add scopes if specified + if (scopes?.Any() == true) + { + body["scope"] = string.Join(" ", scopes); + } + + try + { + var httpClient = _httpClientFactory.CreateClient("ClientCredentials"); + var content = new FormUrlEncodedContent(body); + + var response = await httpClient.PostAsync(tokenEndpoint, content, cancellationToken); + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Client credentials request failed: {StatusCode} - {Content}", + response.StatusCode, responseContent); + + var errorResponse = JsonSerializer.Deserialize(responseContent); + return ClientCredentialsResult.Failure( + errorResponse?.Error ?? "request_failed", + errorResponse?.ErrorDescription ?? $"HTTP {response.StatusCode}"); + } + + var tokenResponse = JsonSerializer.Deserialize(responseContent); + + if (tokenResponse is null || string.IsNullOrEmpty(tokenResponse.AccessToken)) + { + return ClientCredentialsResult.Failure("invalid_response", "No access token in response"); + } + + _logger.LogDebug("Successfully acquired client credentials token"); + return ClientCredentialsResult.Success( + tokenResponse.AccessToken, + tokenResponse.ExpiresIn, + tokenResponse.TokenType); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error acquiring client credentials token"); + return ClientCredentialsResult.Failure("exception", ex.Message); + } + } + + private async Task GetTokenEndpointAsync(CancellationToken cancellationToken) + { + // Try to get from configuration manager (cached discovery document) + if (_oidcOptions.ConfigurationManager is not null) + { + try + { + var config = await _oidcOptions.ConfigurationManager.GetConfigurationAsync(cancellationToken); + return config?.TokenEndpoint; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get token endpoint from discovery"); + } + } + + // Fallback: construct from authority (Keycloak pattern) + if (!string.IsNullOrEmpty(_oidcOptions.Authority)) + { + var authority = _oidcOptions.Authority.TrimEnd('/'); + return $"{authority}/protocol/openid-connect/token"; + } + + return null; + } +} + +public record ClientCredentialsResult( + bool IsSuccess, + string? AccessToken, + int? ExpiresIn, + string? TokenType, + string? Error, + string? ErrorDescription) +{ + public static ClientCredentialsResult Success(string accessToken, int? expiresIn, string? tokenType) => + new(true, accessToken, expiresIn, tokenType, null, null); + + public static ClientCredentialsResult Failure(string error, string? description) => + new(false, null, null, null, error, description); +} + +public record TokenResponse +{ + [System.Text.Json.Serialization.JsonPropertyName("access_token")] + public string? AccessToken { get; init; } + + [System.Text.Json.Serialization.JsonPropertyName("expires_in")] + public int? ExpiresIn { get; init; } + + [System.Text.Json.Serialization.JsonPropertyName("token_type")] + public string? TokenType { get; init; } +} + +public record TokenErrorResponse +{ + [System.Text.Json.Serialization.JsonPropertyName("error")] + public string? Error { get; init; } + + [System.Text.Json.Serialization.JsonPropertyName("error_description")] + public string? ErrorDescription { get; init; } +} +``` + +#### Step 2: Register the Service + +In your `Program.cs`: + +```csharp +// Register HTTP client for client credentials +builder.Services.AddHttpClient("ClientCredentials"); + +// Register the client credentials service +builder.Services.AddSingleton(); +``` + +#### Step 3: Use in a Background Service + +```csharp +public class MyBackgroundService : BackgroundService +{ + private readonly IClientCredentialsTokenService _tokenService; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + public MyBackgroundService( + IClientCredentialsTokenService tokenService, + IHttpClientFactory httpClientFactory, + ILogger logger) + { + _tokenService = tokenService; + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + // Get a token for the "api" audience + var tokenResult = await _tokenService.GetAccessTokenAsync( + audience: "api", + cancellationToken: stoppingToken); + + if (!tokenResult.IsSuccess) + { + _logger.LogError("Failed to get token: {Error} - {Description}", + tokenResult.Error, tokenResult.ErrorDescription); + await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); + continue; + } + + // Use the token to call a protected API + var httpClient = _httpClientFactory.CreateClient(); + httpClient.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tokenResult.AccessToken); + + var response = await httpClient.GetAsync("http://localhost:5149/weatherforecast", stoppingToken); + var content = await response.Content.ReadAsStringAsync(stoppingToken); + + _logger.LogInformation("API Response: {Content}", content); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in background service"); + } + + await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + } + } +} +``` + +### Option 2: Standalone Configuration (No Token Handler Dependency) + +If your background service runs independently without `AddTokenHandler`: + +```csharp +public class StandaloneClientCredentialsService +{ + private readonly HttpClient _httpClient; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public StandaloneClientCredentialsService( + HttpClient httpClient, + IConfiguration configuration, + ILogger logger) + { + _httpClient = httpClient; + _configuration = configuration; + _logger = logger; + } + + public async Task GetAccessTokenAsync(CancellationToken cancellationToken = default) + { + var authority = _configuration["Keycloak:Authority"]; + var clientId = _configuration["Keycloak:ClientId"]; + var clientSecret = _configuration["Keycloak:ClientSecret"]; + + var tokenEndpoint = $"{authority?.TrimEnd('/')}/protocol/openid-connect/token"; + + var body = new Dictionary + { + ["grant_type"] = "client_credentials", + ["client_id"] = clientId!, + ["client_secret"] = clientSecret! + }; + + var content = new FormUrlEncodedContent(body); + var response = await _httpClient.PostAsync(tokenEndpoint, content, cancellationToken); + + if (response.IsSuccessStatusCode) + { + var json = await response.Content.ReadAsStringAsync(cancellationToken); + var tokenResponse = JsonSerializer.Deserialize(json); + return tokenResponse?.AccessToken; + } + + _logger.LogError("Failed to get token: {StatusCode}", response.StatusCode); + return null; + } +} +``` + +**Configuration** (`appsettings.json`): + +```json +{ + "Keycloak": { + "Authority": "http://localhost:8080/realms/poc", + "ClientId": "bff", + "ClientSecret": "your-client-secret-here" + } +} +``` + +## Token Caching (Important!) + +For production, you should cache tokens to avoid requesting new ones on every call: + +```csharp +public class CachedClientCredentialsTokenService : IClientCredentialsTokenService +{ + private readonly IClientCredentialsTokenService _inner; + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + + public CachedClientCredentialsTokenService( + IClientCredentialsTokenService inner, + IMemoryCache cache, + ILogger logger) + { + _inner = inner; + _cache = cache; + _logger = logger; + } + + public async Task GetAccessTokenAsync( + string? audience = null, + IEnumerable? scopes = null, + CancellationToken cancellationToken = default) + { + var cacheKey = $"client_credentials:{audience ?? "default"}:{string.Join(",", scopes ?? [])}"; + + if (_cache.TryGetValue(cacheKey, out var cachedResult)) + { + _logger.LogDebug("Using cached client credentials token"); + return cachedResult!; + } + + var result = await _inner.GetAccessTokenAsync(audience, scopes, cancellationToken); + + if (result.IsSuccess && result.ExpiresIn.HasValue) + { + // Cache for 80% of token lifetime to allow for clock skew + var cacheTime = TimeSpan.FromSeconds(result.ExpiresIn.Value * 0.8); + _cache.Set(cacheKey, result, cacheTime); + _logger.LogDebug("Cached client credentials token for {Duration}", cacheTime); + } + + return result; + } +} +``` + +## Keycloak Configuration + +### Creating a Client for Background Services + +1. In Keycloak Admin Console, go to **Clients** ? **Create Client** +2. Configure the client: + - **Client ID**: `background-worker` (or your service name) + - **Client authentication**: `ON` (enables client credentials) + - **Authentication flow**: Check only `Service accounts roles` +3. Go to **Credentials** tab and copy the **Client Secret** +4. Under **Service account roles**, assign appropriate roles + +### Using Existing BFF Client + +If you want to reuse the existing `bff` client (which already has Client Credentials enabled): + +``` +POST http://localhost:8080/realms/poc/protocol/openid-connect/token +Content-Type: application/x-www-form-urlencoded + +grant_type=client_credentials&client_id=bff&client_secret=your-client-secret-here +``` + +Test this in the `Test.Yarp.http` file (see "Get Client Credentials Grant Type from KeyCloak" section). + +## Security Best Practices + +1. **Use separate clients** for background services vs. user-facing apps +2. **Limit scopes** - Request only the scopes your service needs +3. **Rotate secrets** - Regularly rotate client secrets +4. **Use short token lifetimes** - Configure appropriate expiration +5. **Cache tokens** - Don't request new tokens for every API call +6. **Monitor usage** - Log and alert on authentication failures + +## Comparison: Client Credentials vs Token Exchange + +| Aspect | Client Credentials | Token Exchange | +|--------|-------------------|----------------| +| Use Case | Machine-to-machine, no user context | Delegated access on behalf of user | +| User Identity | No user, service identity only | Preserves original user identity | +| Audit Trail | Logged as service account | Logged as original user | +| When to Use | Background jobs, scheduled tasks | BFF ? API calls, microservice chains | + +### When to Use Each + +- **Client Credentials**: Your background service needs to access resources as itself, not on behalf of any user +- **Token Exchange**: Your service received a user token and needs to call downstream services while preserving the user's identity + +## Example Test Requests + +See `samples/Poc.Yarp/Test.Yarp.http` for working examples: + +```http +### Get Client Credentials Grant Type from KeyCloak +POST http://localhost:8080/realms/poc/protocol/openid-connect/token +Content-Type: application/x-www-form-urlencoded + +grant_type=client_credentials&client_id=bff&client_secret=your-client-secret-here +``` + +## Troubleshooting + +### Common Errors + +| Error | Cause | Solution | +|-------|-------|----------| +| `invalid_client` | Wrong client ID or secret | Verify credentials in Keycloak | +| `unauthorized_client` | Client not configured for client_credentials | Enable "Service accounts roles" in Keycloak | +| `invalid_scope` | Requested scope not allowed | Configure scopes in client settings | +| Connection refused | Keycloak not running | Start Keycloak with `docker-compose up -d` | \ No newline at end of file From 66ec84eb6cb3b32cb5ce5ffbf89c35fffaa47e89 Mon Sep 17 00:00:00 2001 From: Artur Karbone Date: Wed, 11 Mar 2026 07:54:22 +0200 Subject: [PATCH 2/8] adjust stoppingToken --- docs/BACKGROUND-SERVICES-AUTHENTICATION.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/BACKGROUND-SERVICES-AUTHENTICATION.md b/docs/BACKGROUND-SERVICES-AUTHENTICATION.md index 3f8b8f1..1b2f2dc 100644 --- a/docs/BACKGROUND-SERVICES-AUTHENTICATION.md +++ b/docs/BACKGROUND-SERVICES-AUTHENTICATION.md @@ -255,22 +255,22 @@ public class MyBackgroundService : BackgroundService _logger = logger; } - protected override async Task ExecuteAsync(CancellationToken stoppingToken) + protected override async Task ExecuteAsync(CancellationToken cancellationToken) { - while (!stoppingToken.IsCancellationRequested) + while (!cancellationToken.IsCancellationRequested) { try { // Get a token for the "api" audience var tokenResult = await _tokenService.GetAccessTokenAsync( audience: "api", - cancellationToken: stoppingToken); + cancellationToken: cancellationToken); if (!tokenResult.IsSuccess) { _logger.LogError("Failed to get token: {Error} - {Description}", tokenResult.Error, tokenResult.ErrorDescription); - await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); + await Task.Delay(TimeSpan.FromSeconds(30), cancellationToken); continue; } @@ -279,8 +279,8 @@ public class MyBackgroundService : BackgroundService httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tokenResult.AccessToken); - var response = await httpClient.GetAsync("http://localhost:5149/weatherforecast", stoppingToken); - var content = await response.Content.ReadAsStringAsync(stoppingToken); + var response = await httpClient.GetAsync("http://localhost:5149/weatherforecast", cancellationToken); + var content = await response.Content.ReadAsStringAsync(cancellationToken); _logger.LogInformation("API Response: {Content}", content); } @@ -289,7 +289,7 @@ public class MyBackgroundService : BackgroundService _logger.LogError(ex, "Error in background service"); } - await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + await Task.Delay(TimeSpan.FromMinutes(1), cancellationToken); } } } From 7d28dbed00fd4baa3890db55bf81b1b31c7975a6 Mon Sep 17 00:00:00 2001 From: Artur Karbone Date: Tue, 24 Mar 2026 15:55:44 +0200 Subject: [PATCH 3/8] Background worker first draft --- AppForeach.TokenHandler.sln | 7 + AppForeach.TokenHandler.slnLaunch | 6 +- samples/Poc.Api/Program.cs | 1 - .../Poc.BackgroundWorker.csproj | 16 + samples/Poc.BackgroundWorker/Program.cs | 28 ++ samples/Poc.BackgroundWorker/README.md | 288 ++++++++++++++++++ .../Services/ClientCredentialsTokenService.cs | 182 +++++++++++ .../Workers/WeatherBackgroundService.cs | 147 +++++++++ .../appsettings.Development.json | 9 + samples/Poc.BackgroundWorker/appsettings.json | 21 ++ 10 files changed, 703 insertions(+), 2 deletions(-) create mode 100644 samples/Poc.BackgroundWorker/Poc.BackgroundWorker.csproj create mode 100644 samples/Poc.BackgroundWorker/Program.cs create mode 100644 samples/Poc.BackgroundWorker/README.md create mode 100644 samples/Poc.BackgroundWorker/Services/ClientCredentialsTokenService.cs create mode 100644 samples/Poc.BackgroundWorker/Workers/WeatherBackgroundService.cs create mode 100644 samples/Poc.BackgroundWorker/appsettings.Development.json create mode 100644 samples/Poc.BackgroundWorker/appsettings.json diff --git a/AppForeach.TokenHandler.sln b/AppForeach.TokenHandler.sln index a0750f9..4282b7d 100644 --- a/AppForeach.TokenHandler.sln +++ b/AppForeach.TokenHandler.sln @@ -41,6 +41,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{9D5DA24B-9 docs\BACKGROUND-SERVICES-AUTHENTICATION.md = docs\BACKGROUND-SERVICES-AUTHENTICATION.md EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Poc.BackgroundWorker", "samples\Poc.BackgroundWorker\Poc.BackgroundWorker.csproj", "{92EA1D0A-90AA-F606-DCD0-C96D43387907}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -67,6 +69,10 @@ Global {370A4995-4FB0-46CB-97D7-17DBD4F5CBBA}.Debug|Any CPU.Build.0 = Debug|Any CPU {370A4995-4FB0-46CB-97D7-17DBD4F5CBBA}.Release|Any CPU.ActiveCfg = Release|Any CPU {370A4995-4FB0-46CB-97D7-17DBD4F5CBBA}.Release|Any CPU.Build.0 = Release|Any CPU + {92EA1D0A-90AA-F606-DCD0-C96D43387907}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {92EA1D0A-90AA-F606-DCD0-C96D43387907}.Debug|Any CPU.Build.0 = Debug|Any CPU + {92EA1D0A-90AA-F606-DCD0-C96D43387907}.Release|Any CPU.ActiveCfg = Release|Any CPU + {92EA1D0A-90AA-F606-DCD0-C96D43387907}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -80,6 +86,7 @@ Global {12B7FE65-067B-4303-9CAA-C3DB32BCFA07} = {04279663-B7B0-4DDA-890E-00941C396195} {B5A52272-0A46-6A90-A185-0154F729982A} = {18EAE7C8-2D17-47F8-84F8-A4DCD4635292} {370A4995-4FB0-46CB-97D7-17DBD4F5CBBA} = {6A5A6288-BF68-4F8B-A9AB-5781D358D2F3} + {92EA1D0A-90AA-F606-DCD0-C96D43387907} = {18EAE7C8-2D17-47F8-84F8-A4DCD4635292} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {20CBA6E6-E25F-454F-ACD1-D7EBD635B2B3} diff --git a/AppForeach.TokenHandler.slnLaunch b/AppForeach.TokenHandler.slnLaunch index a111e89..98b76b3 100644 --- a/AppForeach.TokenHandler.slnLaunch +++ b/AppForeach.TokenHandler.slnLaunch @@ -1,6 +1,6 @@ [ { - "Name": "api + yarp", + "Name": "api + internal api + yarp", "Projects": [ { "Path": "samples\\Poc.Yarp\\Poc.Yarp.csproj", @@ -9,6 +9,10 @@ { "Path": "samples\\Poc.Api\\Poc.Api.csproj", "Action": "Start" + }, + { + "Path": "samples\\Poc.InternalApi\\Poc.InternalApi.csproj", + "Action": "Start" } ] } diff --git a/samples/Poc.Api/Program.cs b/samples/Poc.Api/Program.cs index 21a26b8..f3ca9da 100644 --- a/samples/Poc.Api/Program.cs +++ b/samples/Poc.Api/Program.cs @@ -30,7 +30,6 @@ builder.Services.Configure("oidc", options => { options.Authority = builder.Configuration.GetValue("Keycloak:Authority"); - options.ClientId = builder.Configuration.GetValue("Keycloak:ClientId"); options.ClientSecret = builder.Configuration.GetValue("Keycloak:ClientSecret"); }); diff --git a/samples/Poc.BackgroundWorker/Poc.BackgroundWorker.csproj b/samples/Poc.BackgroundWorker/Poc.BackgroundWorker.csproj new file mode 100644 index 0000000..c0d8707 --- /dev/null +++ b/samples/Poc.BackgroundWorker/Poc.BackgroundWorker.csproj @@ -0,0 +1,16 @@ + + + + net9.0 + enable + enable + dotnet-Poc.BackgroundWorker-9d3a8b5c-7e4f-4a5d-9c1a-2b3e4f5a6b7c + + + + + + + + + diff --git a/samples/Poc.BackgroundWorker/Program.cs b/samples/Poc.BackgroundWorker/Program.cs new file mode 100644 index 0000000..0bfd90c --- /dev/null +++ b/samples/Poc.BackgroundWorker/Program.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Poc.BackgroundWorker.Services; +using Poc.BackgroundWorker.Workers; + +var builder = Host.CreateApplicationBuilder(args); + +// Configure OpenIdConnect options (reusing same configuration as TokenHandler) +builder.Services.Configure("oidc", options => +{ + options.Authority = builder.Configuration.GetValue("Keycloak:Authority"); + options.ClientId = builder.Configuration.GetValue("Keycloak:ClientId"); + options.ClientSecret = builder.Configuration.GetValue("Keycloak:ClientSecret"); + + // Note: These settings are not used for client credentials but kept for consistency + options.RequireHttpsMetadata = false; // Development only +}); + +// Register HTTP client for client credentials +builder.Services.AddHttpClient("ClientCredentials"); + +// Register the client credentials token service +builder.Services.AddSingleton(); + +// Register the weather background service +builder.Services.AddHostedService(); + +var host = builder.Build(); +host.Run(); diff --git a/samples/Poc.BackgroundWorker/README.md b/samples/Poc.BackgroundWorker/README.md new file mode 100644 index 0000000..312be7c --- /dev/null +++ b/samples/Poc.BackgroundWorker/README.md @@ -0,0 +1,288 @@ +# Poc.BackgroundWorker + +A demonstration of **Client Credentials Grant** authentication for background services in .NET 9. + +## Overview + +This project shows how to authenticate a background worker service using machine-to-machine (M2M) authentication with OAuth 2.0 Client Credentials Grant. The worker periodically calls the Weather API through YARP, demonstrating secure service-to-service communication without user interaction. + +## Architecture + +```mermaid +sequenceDiagram + participant Worker as Background Worker + participant KC as Keycloak + participant YARP as YARP Proxy + participant API as Weather API + + loop Every 60 seconds + Worker->>KC: 1. Request Token (client_credentials) + KC-->>Worker: 2. Return Access Token + Worker->>YARP: 3. GET /weatherforecast + Bearer Token + YARP->>API: 4. Forward Request + API-->>YARP: 5. Weather Data + YARP-->>Worker: 6. Weather Data + end +``` + +## Features + +- ? **Client Credentials Authentication** - Uses OAuth 2.0 Client Credentials Grant +- ? **OpenIdConnectOptions Reuse** - Leverages existing OIDC configuration +- ? **Periodic API Calls** - Configurable polling interval +- ? **Comprehensive Logging** - Detailed logs for debugging and monitoring +- ? **Error Handling** - Graceful error recovery and retry logic + +## Configuration + +### appsettings.json + +```json +{ + "Keycloak": { + "Authority": "http://localhost:8080/realms/poc", + "ClientId": "bff", + "ClientSecret": "your-client-secret-here" + }, + "YarpApi": { + "BaseUrl": "http://localhost:5198" + }, + "BackgroundWorker": { + "PollingIntervalSeconds": 60, + "ApiAudience": "api" + } +} +``` + +### Configuration Options + +| Setting | Description | Default | +|---------|-------------|---------| +| `Keycloak:Authority` | Keycloak realm URL | `http://localhost:8080/realms/poc` | +| `Keycloak:ClientId` | OAuth client ID | `bff` | +| `Keycloak:ClientSecret` | OAuth client secret | - | +| `YarpApi:BaseUrl` | YARP proxy base URL | `http://localhost:5198` | +| `BackgroundWorker:PollingIntervalSeconds` | Seconds between API calls | `60` | +| `BackgroundWorker:ApiAudience` | Target audience for token exchange | `api` | + +## Prerequisites + +1. **Keycloak** running at `http://localhost:8080` + - Realm: `poc` + - Client: `bff` with client credentials enabled + +2. **YARP Proxy** running at `http://localhost:5198` + ```bash + cd samples/Poc.Yarp + dotnet run + ``` + +3. **Weather API** running at `http://localhost:5149` (proxied through YARP) + ```bash + cd samples/Poc.Api + dotnet run + ``` + +## Running the Service + +### Development + +```bash +cd samples/Poc.BackgroundWorker +dotnet run +``` + +### With Docker Compose + +```bash +docker-compose up -d +``` + +## How It Works + +### 1. Service Registration (Program.cs) + +The service uses `OpenIdConnectOptions` to configure authentication: + +```csharp +builder.Services.Configure("oidc", options => +{ + options.Authority = builder.Configuration["Keycloak:Authority"]; + options.ClientId = builder.Configuration["Keycloak:ClientId"]; + options.ClientSecret = builder.Configuration["Keycloak:ClientSecret"]; +}); + +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); +``` + +### 2. Token Acquisition + +`ClientCredentialsTokenService` obtains tokens using the Client Credentials flow: + +```csharp +var tokenResult = await _tokenService.GetAccessTokenAsync( + audience: "api", + cancellationToken: cancellationToken); +``` + +### 3. API Call + +The worker uses the token to call the Weather API: + +```csharp +httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", tokenResult.AccessToken); + +var response = await httpClient.GetAsync("http://localhost:5198/weatherforecast"); +``` + +## Project Structure + +``` +Poc.BackgroundWorker/ +??? Program.cs # Service host configuration +??? appsettings.json # Configuration +??? Services/ +? ??? ClientCredentialsTokenService.cs # Token acquisition service +??? Workers/ + ??? WeatherBackgroundService.cs # Background worker implementation +``` + +## Key Classes + +### ClientCredentialsTokenService + +Implements `IClientCredentialsTokenService` to acquire access tokens using Client Credentials Grant. Reuses `OpenIdConnectOptions` for configuration. + +**Key Methods:** +- `GetAccessTokenAsync()` - Acquires token with optional audience and scopes +- `GetTokenEndpointAsync()` - Discovers or constructs token endpoint URL + +### WeatherBackgroundService + +Inherits from `BackgroundService` to run periodic tasks. Demonstrates: +- Token acquisition before each API call +- Bearer token usage in HTTP requests +- Error handling and logging +- Configurable polling intervals + +## Logging + +The service provides detailed logging at different levels: + +``` +info: Poc.BackgroundWorker.Workers.WeatherBackgroundService[0] + === Starting Weather API Call === +info: Poc.BackgroundWorker.Workers.WeatherBackgroundService[0] + Step 1: Requesting access token for audience 'api'... +info: Poc.BackgroundWorker.Services.ClientCredentialsTokenService[0] + Successfully acquired client credentials token (expires in 300s) +info: Poc.BackgroundWorker.Workers.WeatherBackgroundService[0] + ? Access token acquired successfully (type: Bearer, expires in: 300s) +info: Poc.BackgroundWorker.Workers.WeatherBackgroundService[0] + Step 2: Calling Weather API at http://localhost:5198/weatherforecast... +info: Poc.BackgroundWorker.Workers.WeatherBackgroundService[0] + ? Weather API call successful (Status: 200) +info: Poc.BackgroundWorker.Workers.WeatherBackgroundService[0] + Weather forecast received: 5 entries, first entry: 12/10/2024 - 75°F (Warm) +``` + +## Troubleshooting + +### "invalid_client" Error + +**Cause:** Wrong client ID or secret + +**Solution:** +1. Verify credentials in Keycloak Admin Console +2. Check `appsettings.json` matches Keycloak configuration +3. Ensure client secret is correct (regenerate if needed) + +### "unauthorized_client" Error + +**Cause:** Client not configured for client credentials + +**Solution:** +1. Open Keycloak Admin Console +2. Go to Clients ? `bff` ? Settings +3. Enable "Client authentication" +4. Under "Authentication flow", check "Service accounts roles" + +### Connection Refused + +**Cause:** Services not running + +**Solution:** +```bash +# Start Keycloak +docker-compose up -d keycloak + +# Start YARP +cd samples/Poc.Yarp && dotnet run + +# Start API +cd samples/Poc.Api && dotnet run + +# Start Worker +cd samples/Poc.BackgroundWorker && dotnet run +``` + +### No Logs Appearing + +**Cause:** Log level too high + +**Solution:** Update `appsettings.json`: +```json +{ + "Logging": { + "LogLevel": { + "Poc.BackgroundWorker": "Debug" + } + } +} +``` + +## Related Documentation + +- [Background Services Authentication Guide](../../docs/BACKGROUND-SERVICES-AUTHENTICATION.md) +- [OAuth 2.0 Client Credentials Grant (RFC 6749)](https://datatracker.ietf.org/doc/html/rfc6749#section-4.4) +- [.NET Background Services](https://learn.microsoft.com/en-us/dotnet/core/extensions/workers) + +## Security Considerations + +1. **Client Secret Protection** + - Never commit secrets to source control + - Use User Secrets for development: `dotnet user-secrets set "Keycloak:ClientSecret" "your-secret"` + - Use Azure Key Vault or similar for production + +2. **Token Caching** + - Consider implementing token caching for production + - See [Token Caching section](../../docs/BACKGROUND-SERVICES-AUTHENTICATION.md#token-caching-important) in the guide + +3. **Separate Clients** + - Use dedicated client IDs for background services + - Don't reuse user-facing application clients + +4. **Minimal Scopes** + - Request only the scopes needed + - Configure scope restrictions in Keycloak + +## Production Deployment + +For production deployments: + +1. **Use environment variables** or secure configuration providers +2. **Implement token caching** to reduce token endpoint calls +3. **Add health checks** for monitoring +4. **Configure retry policies** with exponential backoff +5. **Use Application Insights** or similar for telemetry +6. **Run as a systemd service** or in Kubernetes + +## Next Steps + +- Implement token caching for production use +- Add health check endpoints +- Configure retry policies with Polly +- Add Application Insights telemetry +- Create unit and integration tests diff --git a/samples/Poc.BackgroundWorker/Services/ClientCredentialsTokenService.cs b/samples/Poc.BackgroundWorker/Services/ClientCredentialsTokenService.cs new file mode 100644 index 0000000..c22fb1a --- /dev/null +++ b/samples/Poc.BackgroundWorker/Services/ClientCredentialsTokenService.cs @@ -0,0 +1,182 @@ +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.Extensions.Options; +using System.Text.Json; + +namespace Poc.BackgroundWorker.Services; + +/// +/// Service for acquiring tokens using Client Credentials Grant. +/// Reuses OpenIdConnectOptions from the existing token handler configuration. +/// +public interface IClientCredentialsTokenService +{ + /// + /// Gets an access token for the specified audience using client credentials. + /// + Task GetAccessTokenAsync( + string? audience = null, + IEnumerable? scopes = null, + CancellationToken cancellationToken = default); +} + +public class ClientCredentialsTokenService : IClientCredentialsTokenService +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly OpenIdConnectOptions _oidcOptions; + private readonly ILogger _logger; + + public ClientCredentialsTokenService( + IHttpClientFactory httpClientFactory, + IOptionsMonitor oidcOptionsMonitor, + ILogger logger) + { + _httpClientFactory = httpClientFactory; + // Get the "oidc" named options configured in Program.cs + _oidcOptions = oidcOptionsMonitor.Get("oidc"); + _logger = logger; + } + + public async Task GetAccessTokenAsync( + string? audience = null, + IEnumerable? scopes = null, + CancellationToken cancellationToken = default) + { + var tokenEndpoint = await GetTokenEndpointAsync(cancellationToken); + + if (string.IsNullOrEmpty(tokenEndpoint)) + { + return ClientCredentialsResult.Failure("config_error", "Token endpoint not configured"); + } + + var clientId = _oidcOptions.ClientId; + var clientSecret = _oidcOptions.ClientSecret; + + if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(clientSecret)) + { + return ClientCredentialsResult.Failure("config_error", "Client credentials not configured"); + } + + var body = new Dictionary + { + ["grant_type"] = "client_credentials", + ["client_id"] = clientId, + ["client_secret"] = clientSecret + }; + + // Add audience if specified (for Keycloak, this targets a specific client) + if (!string.IsNullOrEmpty(audience)) + { + body["audience"] = audience; + } + + // Add scopes if specified + if (scopes?.Any() == true) + { + body["scope"] = string.Join(" ", scopes); + } + + try + { + var httpClient = _httpClientFactory.CreateClient("ClientCredentials"); + var content = new FormUrlEncodedContent(body); + + _logger.LogDebug("Requesting client credentials token from {TokenEndpoint}", tokenEndpoint); + + var response = await httpClient.PostAsync(tokenEndpoint, content, cancellationToken); + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Client credentials request failed: {StatusCode} - {Content}", + response.StatusCode, responseContent); + + var errorResponse = JsonSerializer.Deserialize(responseContent); + return ClientCredentialsResult.Failure( + errorResponse?.Error ?? "request_failed", + errorResponse?.ErrorDescription ?? $"HTTP {response.StatusCode}"); + } + + var tokenResponse = JsonSerializer.Deserialize(responseContent); + + if (tokenResponse is null || string.IsNullOrEmpty(tokenResponse.AccessToken)) + { + return ClientCredentialsResult.Failure("invalid_response", "No access token in response"); + } + + _logger.LogInformation("Successfully acquired client credentials token (expires in {ExpiresIn}s)", + tokenResponse.ExpiresIn); + + return ClientCredentialsResult.Success( + tokenResponse.AccessToken, + tokenResponse.ExpiresIn, + tokenResponse.TokenType); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error acquiring client credentials token"); + return ClientCredentialsResult.Failure("exception", ex.Message); + } + } + + private async Task GetTokenEndpointAsync(CancellationToken cancellationToken) + { + // Try to get from configuration manager (cached discovery document) + if (_oidcOptions.ConfigurationManager is not null) + { + try + { + var config = await _oidcOptions.ConfigurationManager.GetConfigurationAsync(cancellationToken); + return config?.TokenEndpoint; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get token endpoint from discovery"); + } + } + + // Fallback: construct from authority (Keycloak pattern) + if (!string.IsNullOrEmpty(_oidcOptions.Authority)) + { + var authority = _oidcOptions.Authority.TrimEnd('/'); + return $"{authority}/protocol/openid-connect/token"; + } + + return null; + } +} + +public record ClientCredentialsResult( + bool IsSuccess, + string? AccessToken, + int? ExpiresIn, + string? TokenType, + string? Error, + string? ErrorDescription) +{ + public static ClientCredentialsResult Success(string accessToken, int? expiresIn, string? tokenType) => + new(true, accessToken, expiresIn, tokenType, null, null); + + public static ClientCredentialsResult Failure(string error, string? description) => + new(false, null, null, null, error, description); +} + +public record TokenResponse +{ + [System.Text.Json.Serialization.JsonPropertyName("access_token")] + public string? AccessToken { get; init; } + + [System.Text.Json.Serialization.JsonPropertyName("expires_in")] + public int? ExpiresIn { get; init; } + + [System.Text.Json.Serialization.JsonPropertyName("token_type")] + public string? TokenType { get; init; } +} + +public record TokenErrorResponse +{ + [System.Text.Json.Serialization.JsonPropertyName("error")] + public string? Error { get; init; } + + [System.Text.Json.Serialization.JsonPropertyName("error_description")] + public string? ErrorDescription { get; init; } +} diff --git a/samples/Poc.BackgroundWorker/Workers/WeatherBackgroundService.cs b/samples/Poc.BackgroundWorker/Workers/WeatherBackgroundService.cs new file mode 100644 index 0000000..5ba0506 --- /dev/null +++ b/samples/Poc.BackgroundWorker/Workers/WeatherBackgroundService.cs @@ -0,0 +1,147 @@ +using Poc.BackgroundWorker.Services; + +namespace Poc.BackgroundWorker.Workers; + +/// +/// Background service that periodically calls the Weather API via YARP using client credentials authentication. +/// Demonstrates machine-to-machine authentication for background workers. +/// +public class WeatherBackgroundService : BackgroundService +{ + private readonly IClientCredentialsTokenService _tokenService; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public WeatherBackgroundService( + IClientCredentialsTokenService tokenService, + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + ILogger logger) + { + _tokenService = tokenService; + _httpClientFactory = httpClientFactory; + _configuration = configuration; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Weather Background Service starting..."); + + var pollingInterval = _configuration.GetValue("BackgroundWorker:PollingIntervalSeconds", 60); + var apiAudience = _configuration.GetValue("BackgroundWorker:ApiAudience"); + var baseUrl = _configuration.GetValue("YarpApi:BaseUrl"); + + _logger.LogInformation( + "Configuration: Polling every {PollingInterval}s, API: {BaseUrl}, Audience: {Audience}", + pollingInterval, baseUrl, apiAudience); + + // Wait a bit before starting to ensure services are ready + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + + while (!cancellationToken.IsCancellationRequested) + { + try + { + await FetchWeatherDataAsync(baseUrl!, apiAudience, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in weather background service iteration"); + } + + // Wait for the configured interval before next iteration + _logger.LogDebug("Waiting {PollingInterval} seconds before next poll...", pollingInterval); + await Task.Delay(TimeSpan.FromSeconds(pollingInterval), cancellationToken); + } + + _logger.LogInformation("Weather Background Service stopping..."); + } + + private async Task FetchWeatherDataAsync( + string baseUrl, + string? apiAudience, + CancellationToken cancellationToken) + { + _logger.LogInformation("=== Starting Weather API Call ==="); + + // Step 1: Get a token using client credentials + _logger.LogInformation("Step 1: Requesting access token for audience '{Audience}'...", apiAudience); + + var tokenResult = await _tokenService.GetAccessTokenAsync( + audience: apiAudience, + cancellationToken: cancellationToken); + + if (!tokenResult.IsSuccess) + { + _logger.LogError( + "Failed to acquire access token: {Error} - {Description}", + tokenResult.Error, + tokenResult.ErrorDescription); + return; + } + + _logger.LogInformation( + "? Access token acquired successfully (type: {TokenType}, expires in: {ExpiresIn}s)", + tokenResult.TokenType, + tokenResult.ExpiresIn); + + // Step 2: Call the Weather API via YARP with the Bearer token + _logger.LogInformation("Step 2: Calling Weather API at {BaseUrl}/weatherforecast...", baseUrl); + + var httpClient = _httpClientFactory.CreateClient(); + httpClient.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tokenResult.AccessToken); + + var weatherUrl = $"{baseUrl}/weatherforecast"; + var response = await httpClient.GetAsync(weatherUrl, cancellationToken); + + if (response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(cancellationToken); + + _logger.LogInformation( + "? Weather API call successful (Status: {StatusCode})", + response.StatusCode); + + _logger.LogDebug("Response: {Content}", content); + + // Try to parse and display weather data + try + { + var weatherData = System.Text.Json.JsonSerializer.Deserialize(content); + if (weatherData != null && weatherData.Length > 0) + { + _logger.LogInformation( + "Weather forecast received: {Count} entries, first entry: {Date} - {Temp}°F ({Summary})", + weatherData.Length, + weatherData[0].Date, + weatherData[0].TemperatureF, + weatherData[0].Summary); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not parse weather data"); + } + } + else + { + var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogError( + "? Weather API call failed (Status: {StatusCode}): {ErrorContent}", + response.StatusCode, + errorContent); + } + + _logger.LogInformation("=== Weather API Call Completed ==="); + } + + // Weather forecast model matching the API response + private record WeatherForecast( + DateOnly Date, + int TemperatureC, + int TemperatureF, + string? Summary); +} diff --git a/samples/Poc.BackgroundWorker/appsettings.Development.json b/samples/Poc.BackgroundWorker/appsettings.Development.json new file mode 100644 index 0000000..e203e94 --- /dev/null +++ b/samples/Poc.BackgroundWorker/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + } +} diff --git a/samples/Poc.BackgroundWorker/appsettings.json b/samples/Poc.BackgroundWorker/appsettings.json new file mode 100644 index 0000000..f85cc50 --- /dev/null +++ b/samples/Poc.BackgroundWorker/appsettings.json @@ -0,0 +1,21 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information", + "Poc.BackgroundWorker": "Debug" + } + }, + "Keycloak": { + "Authority": "http://localhost:8080/realms/poc", + "ClientId": "bff", + "ClientSecret": "your-client-secret-here" + }, + "YarpApi": { + "BaseUrl": "http://localhost:5198" + }, + "BackgroundWorker": { + "PollingIntervalSeconds": 60, + "ApiAudience": "api" + } +} From 5967ad8f5f8d9b01cd196817277d9ea9086a302b Mon Sep 17 00:00:00 2001 From: Artur Karbone Date: Tue, 24 Mar 2026 16:00:45 +0200 Subject: [PATCH 4/8] introduce CachedClientCredentialsTokenService --- .../CachedClientCredentialsTokenService.cs | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 samples/Poc.BackgroundWorker/Services/CachedClientCredentialsTokenService.cs diff --git a/samples/Poc.BackgroundWorker/Services/CachedClientCredentialsTokenService.cs b/samples/Poc.BackgroundWorker/Services/CachedClientCredentialsTokenService.cs new file mode 100644 index 0000000..c6da13d --- /dev/null +++ b/samples/Poc.BackgroundWorker/Services/CachedClientCredentialsTokenService.cs @@ -0,0 +1,46 @@ +using Microsoft.Extensions.Caching.Memory; + +namespace Poc.BackgroundWorker.Services; + +public class CachedClientCredentialsTokenService : IClientCredentialsTokenService +{ + private readonly IClientCredentialsTokenService _inner; + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + + public CachedClientCredentialsTokenService( + IClientCredentialsTokenService inner, + IMemoryCache cache, + ILogger logger) + { + _inner = inner; + _cache = cache; + _logger = logger; + } + + public async Task GetAccessTokenAsync( + string? audience = null, + IEnumerable? scopes = null, + CancellationToken cancellationToken = default) + { + var cacheKey = $"client_credentials:{audience ?? "default"}:{string.Join(",", scopes ?? [])}"; + + if (_cache.TryGetValue(cacheKey, out var cachedResult)) + { + _logger.LogDebug("Using cached client credentials token"); + return cachedResult!; + } + + var result = await _inner.GetAccessTokenAsync(audience, scopes, cancellationToken); + + if (result.IsSuccess && result.ExpiresIn.HasValue) + { + // Cache for 80% of token lifetime to allow for clock skew + var cacheTime = TimeSpan.FromSeconds(result.ExpiresIn.Value * 0.8); + _cache.Set(cacheKey, result, cacheTime); + _logger.LogDebug("Cached client credentials token for {Duration}", cacheTime); + } + + return result; + } +} \ No newline at end of file From c6f4fb2908e915065c6c9e2405662e57d0b7fec0 Mon Sep 17 00:00:00 2001 From: Artur Karbone Date: Tue, 24 Mar 2026 16:03:24 +0200 Subject: [PATCH 5/8] adjust sln launch --- AppForeach.TokenHandler.slnLaunch | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/AppForeach.TokenHandler.slnLaunch b/AppForeach.TokenHandler.slnLaunch index 98b76b3..a633132 100644 --- a/AppForeach.TokenHandler.slnLaunch +++ b/AppForeach.TokenHandler.slnLaunch @@ -1,6 +1,6 @@ [ { - "Name": "api + internal api + yarp", + "Name": "api + internal api + yarp + background worker", "Projects": [ { "Path": "samples\\Poc.Yarp\\Poc.Yarp.csproj", @@ -13,6 +13,10 @@ { "Path": "samples\\Poc.InternalApi\\Poc.InternalApi.csproj", "Action": "Start" + }, + { + "Path": "samples\\Poc.BackgroundWorker\\Poc.BackgroundWorker.csproj", + "Action": "Start" } ] } From ec5d832aa888f5bb38d74e3e1073a29c79297133 Mon Sep 17 00:00:00 2001 From: Artur Karbone Date: Sun, 29 Mar 2026 09:52:29 +0300 Subject: [PATCH 6/8] adjust readme --- samples/Poc.BackgroundWorker/README.md | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/samples/Poc.BackgroundWorker/README.md b/samples/Poc.BackgroundWorker/README.md index 312be7c..3903478 100644 --- a/samples/Poc.BackgroundWorker/README.md +++ b/samples/Poc.BackgroundWorker/README.md @@ -25,14 +25,6 @@ sequenceDiagram end ``` -## Features - -- ? **Client Credentials Authentication** - Uses OAuth 2.0 Client Credentials Grant -- ? **OpenIdConnectOptions Reuse** - Leverages existing OIDC configuration -- ? **Periodic API Calls** - Configurable polling interval -- ? **Comprehensive Logging** - Detailed logs for debugging and monitoring -- ? **Error Handling** - Graceful error recovery and retry logic - ## Configuration ### appsettings.json @@ -137,18 +129,6 @@ httpClient.DefaultRequestHeaders.Authorization = var response = await httpClient.GetAsync("http://localhost:5198/weatherforecast"); ``` -## Project Structure - -``` -Poc.BackgroundWorker/ -??? Program.cs # Service host configuration -??? appsettings.json # Configuration -??? Services/ -? ??? ClientCredentialsTokenService.cs # Token acquisition service -??? Workers/ - ??? WeatherBackgroundService.cs # Background worker implementation -``` - ## Key Classes ### ClientCredentialsTokenService From 2862561d04e70cff902a41729dbd589a64a8b09d Mon Sep 17 00:00:00 2001 From: Artur Karbone Date: Sun, 29 Mar 2026 10:51:54 +0300 Subject: [PATCH 7/8] register CachedClientCredentialsTokenService as a decorator --- .../Poc.BackgroundWorker.csproj | 1 + samples/Poc.BackgroundWorker/Program.cs | 8 ++++++-- .../CachedClientCredentialsTokenService.cs | 18 ++++++++++++------ 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/samples/Poc.BackgroundWorker/Poc.BackgroundWorker.csproj b/samples/Poc.BackgroundWorker/Poc.BackgroundWorker.csproj index c0d8707..343c5dd 100644 --- a/samples/Poc.BackgroundWorker/Poc.BackgroundWorker.csproj +++ b/samples/Poc.BackgroundWorker/Poc.BackgroundWorker.csproj @@ -11,6 +11,7 @@ + diff --git a/samples/Poc.BackgroundWorker/Program.cs b/samples/Poc.BackgroundWorker/Program.cs index 0bfd90c..07bb68c 100644 --- a/samples/Poc.BackgroundWorker/Program.cs +++ b/samples/Poc.BackgroundWorker/Program.cs @@ -10,7 +10,7 @@ options.Authority = builder.Configuration.GetValue("Keycloak:Authority"); options.ClientId = builder.Configuration.GetValue("Keycloak:ClientId"); options.ClientSecret = builder.Configuration.GetValue("Keycloak:ClientSecret"); - + // Note: These settings are not used for client credentials but kept for consistency options.RequireHttpsMetadata = false; // Development only }); @@ -18,8 +18,12 @@ // Register HTTP client for client credentials builder.Services.AddHttpClient("ClientCredentials"); -// Register the client credentials token service +// Register memory cache for token caching +builder.Services.AddDistributedMemoryCache(); + +// Register the client credentials token service with a cached decorator builder.Services.AddSingleton(); +builder.Services.Decorate(); // Register the weather background service builder.Services.AddHostedService(); diff --git a/samples/Poc.BackgroundWorker/Services/CachedClientCredentialsTokenService.cs b/samples/Poc.BackgroundWorker/Services/CachedClientCredentialsTokenService.cs index c6da13d..a23114b 100644 --- a/samples/Poc.BackgroundWorker/Services/CachedClientCredentialsTokenService.cs +++ b/samples/Poc.BackgroundWorker/Services/CachedClientCredentialsTokenService.cs @@ -1,16 +1,17 @@ -using Microsoft.Extensions.Caching.Memory; +using System.Text.Json; +using Microsoft.Extensions.Caching.Distributed; namespace Poc.BackgroundWorker.Services; public class CachedClientCredentialsTokenService : IClientCredentialsTokenService { private readonly IClientCredentialsTokenService _inner; - private readonly IMemoryCache _cache; + private readonly IDistributedCache _cache; private readonly ILogger _logger; public CachedClientCredentialsTokenService( IClientCredentialsTokenService inner, - IMemoryCache cache, + IDistributedCache cache, ILogger logger) { _inner = inner; @@ -25,10 +26,11 @@ public async Task GetAccessTokenAsync( { var cacheKey = $"client_credentials:{audience ?? "default"}:{string.Join(",", scopes ?? [])}"; - if (_cache.TryGetValue(cacheKey, out var cachedResult)) + var cachedBytes = await _cache.GetAsync(cacheKey, cancellationToken); + if (cachedBytes is not null) { _logger.LogDebug("Using cached client credentials token"); - return cachedResult!; + return JsonSerializer.Deserialize(cachedBytes)!; } var result = await _inner.GetAccessTokenAsync(audience, scopes, cancellationToken); @@ -37,7 +39,11 @@ public async Task GetAccessTokenAsync( { // Cache for 80% of token lifetime to allow for clock skew var cacheTime = TimeSpan.FromSeconds(result.ExpiresIn.Value * 0.8); - _cache.Set(cacheKey, result, cacheTime); + var options = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = cacheTime + }; + await _cache.SetAsync(cacheKey, JsonSerializer.SerializeToUtf8Bytes(result), options, cancellationToken); _logger.LogDebug("Cached client credentials token for {Duration}", cacheTime); } From c7ee056a23b928f405d780bdf6bba6982b6fdabe Mon Sep 17 00:00:00 2001 From: Artur Karbone Date: Sun, 29 Mar 2026 10:54:06 +0300 Subject: [PATCH 8/8] adjust docs --- docs/BACKGROUND-SERVICES-AUTHENTICATION.md | 2 +- samples/Poc.BackgroundWorker/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/BACKGROUND-SERVICES-AUTHENTICATION.md b/docs/BACKGROUND-SERVICES-AUTHENTICATION.md index 1b2f2dc..7447d98 100644 --- a/docs/BACKGROUND-SERVICES-AUTHENTICATION.md +++ b/docs/BACKGROUND-SERVICES-AUTHENTICATION.md @@ -412,7 +412,7 @@ public class CachedClientCredentialsTokenService : IClientCredentialsTokenServic ### Creating a Client for Background Services -1. In Keycloak Admin Console, go to **Clients** ? **Create Client** +1. In Keycloak Admin Console, go to **Clients** > **Create Client** 2. Configure the client: - **Client ID**: `background-worker` (or your service name) - **Client authentication**: `ON` (enables client credentials) diff --git a/samples/Poc.BackgroundWorker/README.md b/samples/Poc.BackgroundWorker/README.md index 3903478..7c866f7 100644 --- a/samples/Poc.BackgroundWorker/README.md +++ b/samples/Poc.BackgroundWorker/README.md @@ -185,7 +185,7 @@ info: Poc.BackgroundWorker.Workers.WeatherBackgroundService[0] **Solution:** 1. Open Keycloak Admin Console -2. Go to Clients ? `bff` ? Settings +2. Go to Clients > `bff` > Settings 3. Enable "Client authentication" 4. Under "Authentication flow", check "Service accounts roles"