.NET Core Integration
This guide covers how to integrate Skycloak authentication into .NET Core applications using OpenID Connect middleware and .NET best practices.
Prerequisites
Section titled “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
Section titled “Quick Start”1. Install NuGet Packages
Section titled “1. Install NuGet Packages”dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package Microsoft.IdentityModel.Protocols.OpenIdConnect
2. Configure appsettings.json
Section titled “2. 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
Section titled “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
Section titled “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
Section titled “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
Section titled “Advanced Authentication”Custom Claims Transformation
Section titled “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
Section titled “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
Section titled “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
Section titled “API Authentication”JWT Bearer Configuration
Section titled “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
Section titled “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
Section titled “Token Management”Token Service
Section titled “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
Section titled “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
Section titled “User Management”User Service
Section titled “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
Section titled “Testing”Unit Tests
Section titled “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, "test@example.com"),
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, "test@example.com")
}, "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
Section titled “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
Section titled “Production Considerations”Configuration
Section titled “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
Section titled “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
Section titled “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
Section titled “Troubleshooting”Common Issues
Section titled “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
Section titled “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
Section titled “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());
});
});