diff --git a/AppForeach.TokenHandler.sln b/AppForeach.TokenHandler.sln
index 667b665..4282b7d 100644
--- a/AppForeach.TokenHandler.sln
+++ b/AppForeach.TokenHandler.sln
@@ -31,14 +31,18 @@ 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
+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
@@ -65,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
@@ -78,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..a633132 100644
--- a/AppForeach.TokenHandler.slnLaunch
+++ b/AppForeach.TokenHandler.slnLaunch
@@ -1,6 +1,6 @@
[
{
- "Name": "api + yarp",
+ "Name": "api + internal api + yarp + background worker",
"Projects": [
{
"Path": "samples\\Poc.Yarp\\Poc.Yarp.csproj",
@@ -9,6 +9,14 @@
{
"Path": "samples\\Poc.Api\\Poc.Api.csproj",
"Action": "Start"
+ },
+ {
+ "Path": "samples\\Poc.InternalApi\\Poc.InternalApi.csproj",
+ "Action": "Start"
+ },
+ {
+ "Path": "samples\\Poc.BackgroundWorker\\Poc.BackgroundWorker.csproj",
+ "Action": "Start"
}
]
}
diff --git a/docs/BACKGROUND-SERVICES-AUTHENTICATION.md b/docs/BACKGROUND-SERVICES-AUTHENTICATION.md
new file mode 100644
index 0000000..7447d98
--- /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 cancellationToken)
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ try
+ {
+ // Get a token for the "api" audience
+ var tokenResult = await _tokenService.GetAccessTokenAsync(
+ audience: "api",
+ cancellationToken: cancellationToken);
+
+ if (!tokenResult.IsSuccess)
+ {
+ _logger.LogError("Failed to get token: {Error} - {Description}",
+ tokenResult.Error, tokenResult.ErrorDescription);
+ await Task.Delay(TimeSpan.FromSeconds(30), cancellationToken);
+ 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", cancellationToken);
+ var content = await response.Content.ReadAsStringAsync(cancellationToken);
+
+ _logger.LogInformation("API Response: {Content}", content);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error in background service");
+ }
+
+ await Task.Delay(TimeSpan.FromMinutes(1), cancellationToken);
+ }
+ }
+}
+```
+
+### 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
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..343c5dd
--- /dev/null
+++ b/samples/Poc.BackgroundWorker/Poc.BackgroundWorker.csproj
@@ -0,0 +1,17 @@
+
+
+
+ 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..07bb68c
--- /dev/null
+++ b/samples/Poc.BackgroundWorker/Program.cs
@@ -0,0 +1,32 @@
+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 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();
+
+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..7c866f7
--- /dev/null
+++ b/samples/Poc.BackgroundWorker/README.md
@@ -0,0 +1,268 @@
+# 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
+```
+
+## 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");
+```
+
+## 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/CachedClientCredentialsTokenService.cs b/samples/Poc.BackgroundWorker/Services/CachedClientCredentialsTokenService.cs
new file mode 100644
index 0000000..a23114b
--- /dev/null
+++ b/samples/Poc.BackgroundWorker/Services/CachedClientCredentialsTokenService.cs
@@ -0,0 +1,52 @@
+using System.Text.Json;
+using Microsoft.Extensions.Caching.Distributed;
+
+namespace Poc.BackgroundWorker.Services;
+
+public class CachedClientCredentialsTokenService : IClientCredentialsTokenService
+{
+ private readonly IClientCredentialsTokenService _inner;
+ private readonly IDistributedCache _cache;
+ private readonly ILogger _logger;
+
+ public CachedClientCredentialsTokenService(
+ IClientCredentialsTokenService inner,
+ IDistributedCache 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 ?? [])}";
+
+ var cachedBytes = await _cache.GetAsync(cacheKey, cancellationToken);
+ if (cachedBytes is not null)
+ {
+ _logger.LogDebug("Using cached client credentials token");
+ return JsonSerializer.Deserialize(cachedBytes)!;
+ }
+
+ 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);
+ var options = new DistributedCacheEntryOptions
+ {
+ AbsoluteExpirationRelativeToNow = cacheTime
+ };
+ await _cache.SetAsync(cacheKey, JsonSerializer.SerializeToUtf8Bytes(result), options, cancellationToken);
+ _logger.LogDebug("Cached client credentials token for {Duration}", cacheTime);
+ }
+
+ return result;
+ }
+}
\ No newline at end of file
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"
+ }
+}