Aller au contenu

.NET Core Integration

Ce contenu n’est pas encore disponible dans votre langue.

This guide covers how to integrate Skycloak authentication into .NET Core applications using OpenID Connect middleware and .NET best practices.

  • .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
dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package Microsoft.IdentityModel.Protocols.OpenIdConnect
{
  "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"
    }
  }
}

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();
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();
        }
    }
}
<!-- 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>
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>();
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")));
});
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();
    }
}
// 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;
            }
        };
    });
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; }
}
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>();
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>();
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; }
}
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());
    }
}
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);
    }
}
// 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 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
});
// 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>();
  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
// 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;
                }
            };
        });
}
// 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());
    });
});