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