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..1b2f2dc
--- /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