.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.OpenIdConnect

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

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

  1. IDX10214: Audience validation failed

    • Verify audience claim in token matches ClientId
    • Check if resource/client configuration in Keycloak includes audience
  2. Unable to obtain configuration from authority

    • Ensure Keycloak URL is accessible from the application
    • Check for SSL/TLS certificate issues
    • Verify firewall rules
  3. 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());
    });
});

Next Steps