.NET Core Integration
.NET Core Integration
This guide covers how to integrate Skycloak authentication into .NET Core applications using OpenID Connect middleware and .NET best practices.
Prerequisites
- .NET Core 3.1+ or .NET 5/6/7/8
- Skycloak cluster with configured realm and client
- Visual Studio 2019+ or VS Code with C# extension
- Basic understanding of ASP.NET Core authentication
Quick Start
1. Install NuGet Packages
dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package Microsoft.IdentityModel.Protocols.OpenIdConnect2. Configure appsettings.json
{
"Skycloak": {
"Authority": "https://your-cluster-id.app.skycloak.io/realms/your-realm",
"ClientId": "your-dotnet-app",
"ClientSecret": "your-secret",
"ResponseType": "code",
"Scope": "openid profile email",
"SaveTokens": true,
"GetClaimsFromUserInfoEndpoint": true,
"RequireHttpsMetadata": true
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}3. Configure Startup/Program.cs
For .NET 6+ (Program.cs):
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllersWithViews();
// Configure authentication
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
options.LoginPath = "/Account/Login";
options.LogoutPath = "/Account/Logout";
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = true;
})
.AddOpenIdConnect(options =>
{
var skycloakConfig = builder.Configuration.GetSection("Skycloak");
options.Authority = skycloakConfig["Authority"];
options.ClientId = skycloakConfig["ClientId"];
options.ClientSecret = skycloakConfig["ClientSecret"];
options.ResponseType = OpenIdConnectResponseType.Code;
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.RequireHttpsMetadata = true;
// Configure scopes
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
// Configure claim mappings
options.ClaimActions.MapJsonKey("preferred_username", "preferred_username");
options.ClaimActions.MapJsonKey("email_verified", "email_verified");
options.ClaimActions.MapJsonKey("realm_roles", "realm_access.roles");
options.ClaimActions.MapJsonKey("client_roles", "resource_access");
// Configure events
options.Events = new OpenIdConnectEvents
{
OnTokenValidated = async context =>
{
var principal = context.Principal;
var accessToken = context.TokenEndpointResponse.AccessToken;
// Add custom claims processing here
await Task.CompletedTask;
},
OnRemoteFailure = context =>
{
context.Response.Redirect("/Error?message=" + context.Failure.Message);
context.HandleResponse();
return Task.CompletedTask;
}
};
});
// Add authorization
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("RequireAuthenticatedUser", policy =>
policy.RequireAuthenticatedUser());
options.AddPolicy("RequireAdminRole", policy =>
policy.RequireClaim("realm_roles", "admin"));
options.AddPolicy("RequireManagerRole", policy =>
policy.RequireClaim("realm_roles", "manager"));
});
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.Run();4. Create Account Controller
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace YourApp.Controllers
{
public class AccountController : Controller
{
private readonly IConfiguration _configuration;
private readonly ILogger<AccountController> _logger;
public AccountController(IConfiguration configuration, ILogger<AccountController> logger)
{
_configuration = configuration;
_logger = logger;
}
[HttpGet]
public IActionResult Login(string returnUrl = "/")
{
// Store return URL
HttpContext.Session.SetString("ReturnUrl", returnUrl);
return Challenge(new AuthenticationProperties
{
RedirectUri = Url.Action("LoginCallback", "Account")
}, OpenIdConnectDefaults.AuthenticationScheme);
}
[HttpGet]
public IActionResult LoginCallback()
{
// Get stored return URL
var returnUrl = HttpContext.Session.GetString("ReturnUrl") ?? "/";
return LocalRedirect(returnUrl);
}
[HttpPost]
[Authorize]
public async Task<IActionResult> Logout()
{
var idToken = await HttpContext.GetTokenAsync("id_token");
var authority = _configuration["Skycloak:Authority"];
var clientId = _configuration["Skycloak:ClientId"];
// Sign out from cookie authentication
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
// Sign out from Skycloak
var logoutUrl = $"{authority}/protocol/openid-connect/logout";
var postLogoutUrl = Url.Action("Index", "Home", null, Request.Scheme);
return Redirect($"{logoutUrl}?id_token_hint={idToken}&post_logout_redirect_uri={postLogoutUrl}");
}
[HttpGet]
[Authorize]
public IActionResult Profile()
{
return View();
}
[HttpGet]
public IActionResult AccessDenied(string returnUrl)
{
ViewBag.ReturnUrl = returnUrl;
return View();
}
}
}5. Create Views
<!-- Views/Account/Profile.cshtml -->
@{
ViewData["Title"] = "User Profile";
}
<h2>User Profile</h2>
<div class="card">
<div class="card-body">
<h5 class="card-title">User Information</h5>
<dl class="row">
<dt class="col-sm-3">Username</dt>
<dd class="col-sm-9">@User.FindFirst("preferred_username")?.Value</dd>
<dt class="col-sm-3">Email</dt>
<dd class="col-sm-9">@User.FindFirst("email")?.Value</dd>
<dt class="col-sm-3">Name</dt>
<dd class="col-sm-9">@User.Identity?.Name</dd>
<dt class="col-sm-3">Email Verified</dt>
<dd class="col-sm-9">@User.FindFirst("email_verified")?.Value</dd>
</dl>
<h5 class="card-title mt-4">Claims</h5>
<table class="table table-sm">
<thead>
<tr>
<th>Type</th>
<th>Value</th>
</tr>
</thead>
<tbody>
@foreach (var claim in User.Claims)
{
<tr>
<td>@claim.Type</td>
<td>@claim.Value</td>
</tr>
}
</tbody>
</table>
</div>
</div>Advanced Authentication
Custom Claims Transformation
using Microsoft.AspNetCore.Authentication;
using System.Security.Claims;
using System.Text.Json;
public class SkycloakClaimsTransformation : IClaimsTransformation
{
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
var claimsIdentity = (ClaimsIdentity)principal.Identity;
// Parse realm roles
var realmRolesJson = principal.FindFirst("realm_roles")?.Value;
if (!string.IsNullOrEmpty(realmRolesJson))
{
try
{
var roles = JsonSerializer.Deserialize<string[]>(realmRolesJson);
foreach (var role in roles)
{
claimsIdentity.AddClaim(new Claim(ClaimTypes.Role, role));
}
}
catch (JsonException) { }
}
// Parse client roles
var clientRolesJson = principal.FindFirst("client_roles")?.Value;
if (!string.IsNullOrEmpty(clientRolesJson))
{
try
{
var clientRoles = JsonSerializer.Deserialize<Dictionary<string, ClientRole>>(clientRolesJson);
foreach (var client in clientRoles)
{
foreach (var role in client.Value.Roles)
{
claimsIdentity.AddClaim(new Claim($"client_role:{client.Key}", role));
}
}
}
catch (JsonException) { }
}
return Task.FromResult(principal);
}
private class ClientRole
{
public string[] Roles { get; set; } = Array.Empty<string>();
}
}
// Register in Program.cs
builder.Services.AddTransient<IClaimsTransformation, SkycloakClaimsTransformation>();Role-Based Authorization
using Microsoft.AspNetCore.Authorization;
// Custom authorization requirements
public class RealmRoleRequirement : IAuthorizationRequirement
{
public string Role { get; }
public RealmRoleRequirement(string role)
{
Role = role;
}
}
public class ClientRoleRequirement : IAuthorizationRequirement
{
public string ClientId { get; }
public string Role { get; }
public ClientRoleRequirement(string clientId, string role)
{
ClientId = clientId;
Role = role;
}
}
// Authorization handlers
public class RealmRoleHandler : AuthorizationHandler<RealmRoleRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
RealmRoleRequirement requirement)
{
if (context.User.HasClaim(ClaimTypes.Role, requirement.Role))
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
public class ClientRoleHandler : AuthorizationHandler<ClientRoleRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ClientRoleRequirement requirement)
{
var claimType = $"client_role:{requirement.ClientId}";
if (context.User.HasClaim(claimType, requirement.Role))
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
// Register handlers and policies
builder.Services.AddSingleton<IAuthorizationHandler, RealmRoleHandler>();
builder.Services.AddSingleton<IAuthorizationHandler, ClientRoleHandler>();
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy =>
policy.AddRequirements(new RealmRoleRequirement("admin")));
options.AddPolicy("ManagerOnly", policy =>
policy.AddRequirements(new RealmRoleRequirement("manager")));
options.AddPolicy("PremiumUser", policy =>
policy.AddRequirements(new ClientRoleRequirement("my-app", "premium")));
});Authorization Attributes
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
// Custom authorization attribute
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class RequireRealmRoleAttribute : AuthorizeAttribute, IAuthorizationFilter
{
private readonly string[] _roles;
public RequireRealmRoleAttribute(params string[] roles)
{
_roles = roles;
}
public void OnAuthorization(AuthorizationFilterContext context)
{
var user = context.HttpContext.User;
if (!user.Identity.IsAuthenticated)
{
context.Result = new ChallengeResult();
return;
}
var hasRole = _roles.Any(role => user.IsInRole(role));
if (!hasRole)
{
context.Result = new ForbidResult();
}
}
}
// Usage in controllers
[RequireRealmRole("admin", "manager")]
public class AdminController : Controller
{
public IActionResult Index()
{
return View();
}
}API Authentication
JWT Bearer Configuration
// Program.cs for API projects
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
var skycloakConfig = builder.Configuration.GetSection("Skycloak");
options.Authority = skycloakConfig["Authority"];
options.Audience = skycloakConfig["ClientId"];
options.RequireHttpsMetadata = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ClockSkew = TimeSpan.FromMinutes(5)
};
options.Events = new JwtBearerEvents
{
OnTokenValidated = async context =>
{
// Add custom claims from token
var claimsIdentity = (ClaimsIdentity)context.Principal.Identity;
// Extract realm roles
var realmAccess = context.Principal.FindFirst("realm_access")?.Value;
if (!string.IsNullOrEmpty(realmAccess))
{
// Parse and add roles as claims
}
await Task.CompletedTask;
},
OnAuthenticationFailed = context =>
{
if (context.Exception.GetType() == typeof(SecurityTokenExpiredException))
{
context.Response.Headers.Add("Token-Expired", "true");
}
return Task.CompletedTask;
}
};
});API Controller with Authorization
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public class UserApiController : ControllerBase
{
private readonly ILogger<UserApiController> _logger;
public UserApiController(ILogger<UserApiController> logger)
{
_logger = logger;
}
[HttpGet("profile")]
public IActionResult GetProfile()
{
var userId = User.FindFirst("sub")?.Value;
var username = User.FindFirst("preferred_username")?.Value;
var email = User.FindFirst("email")?.Value;
return Ok(new
{
UserId = userId,
Username = username,
Email = email,
Claims = User.Claims.Select(c => new { c.Type, c.Value })
});
}
[HttpGet("admin")]
[Authorize(Policy = "AdminOnly")]
public IActionResult GetAdminData()
{
return Ok(new { message = "Admin data" });
}
[HttpPost("validate-token")]
[AllowAnonymous]
public async Task<IActionResult> ValidateToken([FromBody] TokenValidationRequest request)
{
// Validate token manually if needed
var handler = new JwtSecurityTokenHandler();
try
{
var jsonToken = handler.ReadJwtToken(request.Token);
return Ok(new
{
Valid = true,
ExpiresAt = jsonToken.ValidTo,
Claims = jsonToken.Claims.Select(c => new { c.Type, c.Value })
});
}
catch (Exception ex)
{
return BadRequest(new { Valid = false, Error = ex.Message });
}
}
}
public class TokenValidationRequest
{
public string Token { get; set; }
}Token Management
Token Service
public interface ITokenService
{
Task<string> GetAccessTokenAsync();
Task<string> RefreshTokenAsync();
Task<bool> IsTokenExpiredAsync();
}
public class TokenService : ITokenService
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IConfiguration _configuration;
private readonly ILogger<TokenService> _logger;
public TokenService(
IHttpContextAccessor httpContextAccessor,
IHttpClientFactory httpClientFactory,
IConfiguration configuration,
ILogger<TokenService> logger)
{
_httpContextAccessor = httpContextAccessor;
_httpClientFactory = httpClientFactory;
_configuration = configuration;
_logger = logger;
}
public async Task<string> GetAccessTokenAsync()
{
var context = _httpContextAccessor.HttpContext;
var accessToken = await context.GetTokenAsync("access_token");
// Check if token is expired
if (await IsTokenExpiredAsync())
{
accessToken = await RefreshTokenAsync();
}
return accessToken;
}
public async Task<string> RefreshTokenAsync()
{
var context = _httpContextAccessor.HttpContext;
var refreshToken = await context.GetTokenAsync("refresh_token");
if (string.IsNullOrEmpty(refreshToken))
{
throw new InvalidOperationException("No refresh token available");
}
var client = _httpClientFactory.CreateClient();
var authority = _configuration["Skycloak:Authority"];
var tokenRequest = new Dictionary<string, string>
{
["grant_type"] = "refresh_token",
["refresh_token"] = refreshToken,
["client_id"] = _configuration["Skycloak:ClientId"],
["client_secret"] = _configuration["Skycloak:ClientSecret"]
};
var response = await client.PostAsync(
$"{authority}/protocol/openid-connect/token",
new FormUrlEncodedContent(tokenRequest));
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync();
_logger.LogError("Token refresh failed: {Error}", error);
throw new InvalidOperationException("Failed to refresh token");
}
var tokenResponse = await response.Content.ReadFromJsonAsync<TokenResponse>();
// Update tokens in auth properties
var authenticateResult = await context.AuthenticateAsync();
if (authenticateResult.Succeeded)
{
authenticateResult.Properties.StoreTokens(new[]
{
new AuthenticationToken
{
Name = "access_token",
Value = tokenResponse.AccessToken
},
new AuthenticationToken
{
Name = "refresh_token",
Value = tokenResponse.RefreshToken ?? refreshToken
},
new AuthenticationToken
{
Name = "expires_at",
Value = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn).ToString()
}
});
await context.SignInAsync(
authenticateResult.Principal,
authenticateResult.Properties);
}
return tokenResponse.AccessToken;
}
public async Task<bool> IsTokenExpiredAsync()
{
var context = _httpContextAccessor.HttpContext;
var expiresAt = await context.GetTokenAsync("expires_at");
if (string.IsNullOrEmpty(expiresAt))
{
return true;
}
var expirationTime = DateTimeOffset.Parse(expiresAt);
return expirationTime <= DateTimeOffset.UtcNow.AddMinutes(5); // 5 minute buffer
}
private class TokenResponse
{
[JsonPropertyName("access_token")]
public string AccessToken { get; set; }
[JsonPropertyName("refresh_token")]
public string RefreshToken { get; set; }
[JsonPropertyName("expires_in")]
public int ExpiresIn { get; set; }
}
}
// Register in Program.cs
builder.Services.AddHttpContextAccessor();
builder.Services.AddHttpClient();
builder.Services.AddScoped<ITokenService, TokenService>();Delegating Handler for HTTP Client
public class SkycloakAuthenticationHandler : DelegatingHandler
{
private readonly ITokenService _tokenService;
public SkycloakAuthenticationHandler(ITokenService tokenService)
{
_tokenService = tokenService;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var token = await _tokenService.GetAccessTokenAsync();
if (!string.IsNullOrEmpty(token))
{
request.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
}
return await base.SendAsync(request, cancellationToken);
}
}
// Register typed HTTP client
builder.Services.AddTransient<SkycloakAuthenticationHandler>();
builder.Services.AddHttpClient<ApiService>(client =>
{
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]);
})
.AddHttpMessageHandler<SkycloakAuthenticationHandler>();User Management
User Service
public interface IUserService
{
Task<UserProfile> GetUserProfileAsync(string userId);
Task UpdateUserProfileAsync(string userId, UserProfile profile);
Task<IEnumerable<string>> GetUserRolesAsync(string userId);
}
public class UserService : IUserService
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ITokenService _tokenService;
private readonly IConfiguration _configuration;
public UserService(
IHttpClientFactory httpClientFactory,
ITokenService tokenService,
IConfiguration configuration)
{
_httpClientFactory = httpClientFactory;
_tokenService = tokenService;
_configuration = configuration;
}
public async Task<UserProfile> GetUserProfileAsync(string userId)
{
var client = _httpClientFactory.CreateClient();
var token = await _tokenService.GetAccessTokenAsync();
var authority = _configuration["Skycloak:Authority"];
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
var response = await client.GetAsync($"{authority}/account");
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<UserProfile>();
}
public async Task UpdateUserProfileAsync(string userId, UserProfile profile)
{
var client = _httpClientFactory.CreateClient();
var token = await _tokenService.GetAccessTokenAsync();
var authority = _configuration["Skycloak:Authority"];
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
var response = await client.PutAsJsonAsync($"{authority}/account", profile);
response.EnsureSuccessStatusCode();
}
public async Task<IEnumerable<string>> GetUserRolesAsync(string userId)
{
// Implementation depends on Keycloak admin API access
return new List<string>();
}
}
public class UserProfile
{
public string Id { get; set; }
public string Username { get; set; }
public string Email { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public Dictionary<string, object> Attributes { get; set; }
}Testing
Unit Tests
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using System.Security.Claims;
using System.Text.Encodings.Web;
using Xunit;
public class TestAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public TestAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock)
: base(options, logger, encoder, clock)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, "test-user-id"),
new Claim("preferred_username", "testuser"),
new Claim(ClaimTypes.Email, "[email protected]"),
new Claim(ClaimTypes.Role, "admin")
};
var identity = new ClaimsIdentity(claims, "Test");
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, "Test");
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
public class AccountControllerTests
{
[Fact]
public async Task Profile_ReturnsViewForAuthenticatedUser()
{
// Arrange
var controller = new AccountController(
Mock.Of<IConfiguration>(),
Mock.Of<ILogger<AccountController>>());
var user = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim("preferred_username", "testuser"),
new Claim(ClaimTypes.Email, "[email protected]")
}, "Test"));
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = user }
};
// Act
var result = controller.Profile();
// Assert
Assert.IsType<ViewResult>(result);
}
}
public class TokenServiceTests
{
[Fact]
public async Task GetAccessToken_ReturnsValidToken()
{
// Arrange
var httpContext = new DefaultHttpContext();
httpContext.Features.Set<IAuthenticationFeature>(new AuthenticationFeature
{
User = new ClaimsPrincipal()
});
var httpContextAccessor = new Mock<IHttpContextAccessor>();
httpContextAccessor.Setup(x => x.HttpContext).Returns(httpContext);
var tokenService = new TokenService(
httpContextAccessor.Object,
Mock.Of<IHttpClientFactory>(),
Mock.Of<IConfiguration>(),
Mock.Of<ILogger<TokenService>>());
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(
() => tokenService.GetAccessTokenAsync());
}
}Integration Tests
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using System.Net.Http.Headers;
using Xunit;
public class SkycloakIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public SkycloakIntegrationTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task Get_SecureEndpoint_RedirectsToLogin()
{
// Arrange
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
// Act
var response = await client.GetAsync("/Account/Profile");
// Assert
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
Assert.Contains("login", response.Headers.Location.ToString().ToLower());
}
[Fact]
public async Task Get_ApiEndpoint_RequiresBearer()
{
// Arrange
var client = _factory.CreateClient();
// Act
var response = await client.GetAsync("/api/user/profile");
// Assert
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Get_ApiEndpoint_WithToken_ReturnsSuccess()
{
// Arrange
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthenticationHandler>(
"Test", options => { });
});
}).CreateClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Test");
// Act
var response = await client.GetAsync("/api/user/profile");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}Production Considerations
Configuration
// appsettings.Production.json
{
"Skycloak": {
"Authority": "https://auth.yourcompany.com/realms/production",
"ClientId": "production-app",
"ClientSecret": "", // Use Azure Key Vault or similar
"RequireHttpsMetadata": true,
"ValidateIssuer": true,
"ValidateAudience": true
},
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft.AspNetCore.Authentication": "Information"
}
}
}
// Program.cs - Production configuration
if (builder.Environment.IsProduction())
{
// Use Azure Key Vault for secrets
var keyVaultEndpoint = new Uri(Environment.GetEnvironmentVariable("KEYVAULT_ENDPOINT"));
builder.Configuration.AddAzureKeyVault(keyVaultEndpoint, new DefaultAzureCredential());
// Configure data protection
builder.Services.AddDataProtection()
.PersistKeysToAzureBlobStorage(connectionString, "keys", "keys.xml")
.ProtectKeysWithAzureKeyVault(keyVaultEndpoint, new DefaultAzureCredential());
}Health Checks
// Health check for Keycloak
public class SkycloakHealthCheck : IHealthCheck
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly IConfiguration _configuration;
public SkycloakHealthCheck(IHttpClientFactory httpClientFactory, IConfiguration configuration)
{
_httpClientFactory = httpClientFactory;
_configuration = configuration;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
var client = _httpClientFactory.CreateClient();
var authority = _configuration["Skycloak:Authority"];
var response = await client.GetAsync(
$"{authority}/.well-known/openid-configuration",
cancellationToken);
if (response.IsSuccessStatusCode)
{
return HealthCheckResult.Healthy("Skycloak is reachable");
}
return HealthCheckResult.Unhealthy(
$"Skycloak returned {response.StatusCode}");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy(
"Skycloak is not reachable",
ex);
}
}
}
// Register health checks
builder.Services.AddHealthChecks()
.AddCheck<SkycloakHealthCheck>("skycloak", tags: new[] { "ready" });
// Map health check endpoints
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready")
});
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = _ => false
});Logging and Monitoring
// Structured logging with Serilog
builder.Host.UseSerilog((context, services, configuration) => configuration
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
.Enrich.WithProperty("Application", "SkycloakApp")
.WriteTo.Console()
.WriteTo.ApplicationInsights(services.GetRequiredService<TelemetryConfiguration>(),
TelemetryConverter.Traces));
// Custom middleware for request logging
public class RequestLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestLoggingMiddleware> _logger;
public RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var userId = context.User.FindFirst("sub")?.Value;
using (_logger.BeginScope(new Dictionary<string, object>
{
["UserId"] = userId,
["RequestId"] = context.TraceIdentifier
}))
{
await _next(context);
}
}
}
app.UseMiddleware<RequestLoggingMiddleware>();Troubleshooting
Common Issues
-
IDX10214: Audience validation failed
- Verify
audienceclaim in token matches ClientId - Check if resource/client configuration in Keycloak includes audience
- Verify
-
Unable to obtain configuration from authority
- Ensure Keycloak URL is accessible from the application
- Check for SSL/TLS certificate issues
- Verify firewall rules
-
Correlation failed
- Check cookie settings and SameSite configuration
- Ensure state parameter is being preserved
- Verify session configuration
Debug Configuration
// Enable detailed logging in development
if (builder.Environment.IsDevelopment())
{
IdentityModelEventSource.ShowPII = true;
builder.Services.AddAuthentication()
.AddOpenIdConnect(options =>
{
options.Events = new OpenIdConnectEvents
{
OnAuthenticationFailed = context =>
{
Console.WriteLine($"Authentication failed: {context.Exception}");
return Task.CompletedTask;
},
OnTokenResponseReceived = context =>
{
Console.WriteLine($"Token received: {context.TokenEndpointResponse.AccessToken}");
return Task.CompletedTask;
}
};
});
}Performance Optimization
// Cache JWKS keys
builder.Services.AddMemoryCache();
builder.Services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.RefreshOnIssuerKeyNotFound = true;
options.AutomaticRefreshInterval = TimeSpan.FromHours(24);
});
// Response caching for authorized endpoints
builder.Services.AddResponseCaching();
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("CachedApiPolicy", policy =>
{
policy.RequireAuthenticatedUser();
policy.Requirements.Add(new CacheableRequirement());
});
});