using System.IdentityModel.Tokens.Jwt; using System.Net; using System.Reflection; using System.Security.Claims; using System.Security.Cryptography; using System.Text; using System.Text.Json; using Cleanuparr.Infrastructure.Features.Auth; using Microsoft.IdentityModel.Tokens; using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Auth; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Moq; using Shouldly; using Xunit; namespace Cleanuparr.Infrastructure.Tests.Features.Auth; public sealed class OidcAuthServiceTests : IDisposable { private readonly SqliteConnection _connection; private readonly UsersContext _usersContext; private readonly Mock _httpClientFactory; private readonly Mock> _logger; public OidcAuthServiceTests() { _connection = new SqliteConnection("DataSource=:memory:"); _connection.Open(); var options = new DbContextOptionsBuilder() .UseSqlite(_connection) .Options; _usersContext = new UsersContext(options); _usersContext.Database.EnsureCreated(); // Seed a user _usersContext.Users.Add(new User { Id = Guid.NewGuid(), Username = "admin", PasswordHash = "hash", TotpSecret = "secret", ApiKey = "test-api-key", SetupCompleted = true, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, }); _usersContext.SaveChanges(); _httpClientFactory = new Mock(); _logger = new Mock>(); // Set up a default HttpClient for the factory _httpClientFactory .Setup(f => f.CreateClient("OidcAuth")) .Returns(new HttpClient()); } private OidcAuthService CreateService() { return new OidcAuthService(_httpClientFactory.Object, _usersContext, _logger.Object); } #region StoreOneTimeCode Tests [Fact] public void StoreOneTimeCode_ReturnsNonEmptyCode() { var service = CreateService(); var code = service.StoreOneTimeCode("access-token", "refresh-token", 3600); code.ShouldNotBeNullOrEmpty(); } [Fact] public void StoreOneTimeCode_ReturnsDifferentCodesEachTime() { var service = CreateService(); var code1 = service.StoreOneTimeCode("access-1", "refresh-1", 3600); var code2 = service.StoreOneTimeCode("access-2", "refresh-2", 3600); code1.ShouldNotBe(code2); } #endregion #region ExchangeOneTimeCode Tests [Fact] public void ExchangeOneTimeCode_ValidCode_ReturnsTokens() { var service = CreateService(); var code = service.StoreOneTimeCode("test-access", "test-refresh", 1800); var result = service.ExchangeOneTimeCode(code); result.ShouldNotBeNull(); result.AccessToken.ShouldBe("test-access"); result.RefreshToken.ShouldBe("test-refresh"); result.ExpiresIn.ShouldBe(1800); } [Fact] public void ExchangeOneTimeCode_InvalidCode_ReturnsNull() { var service = CreateService(); var result = service.ExchangeOneTimeCode("nonexistent-code"); result.ShouldBeNull(); } [Fact] public void ExchangeOneTimeCode_SameCodeTwice_SecondReturnsNull() { var service = CreateService(); var code = service.StoreOneTimeCode("test-access", "test-refresh", 3600); var result1 = service.ExchangeOneTimeCode(code); var result2 = service.ExchangeOneTimeCode(code); result1.ShouldNotBeNull(); result2.ShouldBeNull(); } [Fact] public void ExchangeOneTimeCode_EmptyCode_ReturnsNull() { var service = CreateService(); var result = service.ExchangeOneTimeCode(string.Empty); result.ShouldBeNull(); } #endregion #region StartAuthorization Tests [Fact] public async Task StartAuthorization_WhenOidcDisabled_ThrowsInvalidOperationException() { // Ensure OIDC is disabled in config (default state from seed data) var service = CreateService(); await Should.ThrowAsync( () => service.StartAuthorization("https://app.test/api/auth/oidc/callback")); } [Fact] public async Task StartAuthorization_WhenEnabled_ReturnsAuthorizationUrlWithRequiredParams() { await EnableOidcInConfig(); var service = CreateService(); // This will fail at the discovery document fetch since we don't have a real IdP, // but we can at least verify the config check passes. // The actual StartAuthorization requires a reachable discovery endpoint. // Full flow testing is done in integration tests. await Should.ThrowAsync( () => service.StartAuthorization("https://app.test/api/auth/oidc/callback")); } #endregion #region HandleCallback Tests [Fact] public async Task HandleCallback_InvalidState_ReturnsFailure() { var service = CreateService(); var result = await service.HandleCallback("some-code", "invalid-state", "https://app.test/callback"); result.Success.ShouldBeFalse(); result.Error.ShouldContain("Invalid or expired"); } #endregion #region ClearDiscoveryCache Tests [Fact] public void ClearDiscoveryCache_DoesNotThrow() { Should.NotThrow(() => OidcAuthService.ClearDiscoveryCache()); } #endregion #region HandleCallback Edge Cases [Fact] public async Task HandleCallback_EmptyCode_ReturnsFailure() { var service = CreateService(); // Even with a valid-looking state, empty code still fails because the state won't match var result = await service.HandleCallback("", "nonexistent-state", "https://app.test/callback"); result.Success.ShouldBeFalse(); } [Fact] public async Task HandleCallback_EmptyState_ReturnsFailure() { var service = CreateService(); var result = await service.HandleCallback("some-code", "", "https://app.test/callback"); result.Success.ShouldBeFalse(); result.Error.ShouldContain("Invalid or expired"); } #endregion #region StoreOneTimeCode Capacity Tests [Fact] public void StoreOneTimeCode_MultipleStores_AllReturnUniqueCodes() { var service = CreateService(); var codes = new HashSet(); for (int i = 0; i < 10; i++) { var code = service.StoreOneTimeCode($"access-{i}", $"refresh-{i}", 3600); codes.Add(code).ShouldBeTrue($"Code {i} was not unique"); } codes.Count.ShouldBe(10); } [Fact] public void StoreOneTimeCode_Concurrent_AllCodesAreUnique() { var service = CreateService(); var codes = new System.Collections.Concurrent.ConcurrentBag(); Parallel.For(0, 50, i => { var code = service.StoreOneTimeCode($"access-{i}", $"refresh-{i}", 3600); codes.Add(code); }); codes.Count.ShouldBe(50); codes.Distinct().Count().ShouldBe(50); } #endregion #region Helpers private async Task EnableOidcInConfig() { var user = await _usersContext.Users.FirstAsync(); user.Oidc = new OidcConfig { Enabled = true, IssuerUrl = "https://mock-oidc-provider.test", ClientId = "test-client", Scopes = "openid profile email", AuthorizedSubject = "test-subject", ProviderName = "TestProvider" }; await _usersContext.SaveChangesAsync(); } /// /// Creates an OidcAuthService using the given HttpMessageHandler instead of the default mock. /// private OidcAuthService CreateServiceWithHandler(HttpMessageHandler handler) { var factory = new Mock(); factory.Setup(f => f.CreateClient("OidcAuth")).Returns(new HttpClient(handler)); return new OidcAuthService(factory.Object, _usersContext, _logger.Object); } /// /// Uses reflection to retrieve the nonce stored in a pending OIDC flow state. /// Required for constructing a valid JWT in tests before HandleCallback is called. /// private static string GetFlowNonce(string state) { var pendingFlowsField = typeof(OidcAuthService) .GetField("PendingFlows", BindingFlags.NonPublic | BindingFlags.Static)!; var pendingFlows = pendingFlowsField.GetValue(null)!; // Use ConcurrentDictionary indexer: pendingFlows[state] var indexer = pendingFlows.GetType().GetProperty("Item")!; var flowState = indexer.GetValue(pendingFlows, new object[] { state }) ?? throw new InvalidOperationException($"No pending flow found for state: {state}"); var nonceProp = flowState.GetType() .GetProperty("Nonce", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)!; return (string)nonceProp.GetValue(flowState)!; } /// /// Returns a handler that serves a minimal OIDC discovery document for the mock issuer. /// Optionally also handles a token endpoint and JWKS endpoint. /// private static MockHttpMessageHandler CreateDiscoveryHandler( string? tokenResponse = null, HttpStatusCode tokenStatusCode = HttpStatusCode.OK, bool throwNetworkErrorOnToken = false, string? jwksJson = null, Func? tokenResponseFactory = null) { const string issuer = "https://mock-oidc-provider.test"; var discoveryJson = JsonSerializer.Serialize(new { issuer, authorization_endpoint = $"{issuer}/authorize", token_endpoint = $"{issuer}/token", jwks_uri = $"{issuer}/.well-known/jwks", response_types_supported = new[] { "code" }, subject_types_supported = new[] { "public" }, id_token_signing_alg_values_supported = new[] { "RS256" } }); return new MockHttpMessageHandler(request => { var url = request.RequestUri?.ToString() ?? ""; if (url.Contains("/.well-known/openid-configuration")) { return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(discoveryJson, Encoding.UTF8, "application/json") }; } if (url.Contains("/.well-known/jwks")) { // Default to an empty JWKS (sufficient for PKCE/URL tests; JWT tests pass a real key) var keysJson = jwksJson ?? """{"keys": []}"""; return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(keysJson, Encoding.UTF8, "application/json") }; } if (url.Contains("/token")) { if (throwNetworkErrorOnToken) throw new HttpRequestException("Simulated network failure"); // tokenResponseFactory allows dynamic response generation (needed for JWT nonce) var body = tokenResponseFactory?.Invoke() ?? tokenResponse ?? "{}"; return new HttpResponseMessage(tokenStatusCode) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; } return new HttpResponseMessage(HttpStatusCode.NotFound); }); } #endregion #region JWT ID Token Validation Tests private const string MockIssuer = "https://mock-oidc-provider.test"; private const string MockClientId = "test-client"; private const string MockSubject = "test-subject-123"; private const string MockRedirectUri = "https://app.test/api/auth/oidc/callback"; [Fact] public async Task HandleCallback_ValidIdToken_ReturnsSuccessWithSubject() { await EnableOidcInConfig(); OidcAuthService.ClearDiscoveryCache(); var jwt = new JwtTestHelper(); string? capturedJwt = null; var handler = CreateDiscoveryHandler( jwksJson: jwt.GetJwksJson(), tokenResponseFactory: () => $$"""{"id_token":"{{capturedJwt}}","access_token":"access-123","token_type":"Bearer"}"""); var service = CreateServiceWithHandler(handler); try { var startResult = await service.StartAuthorization(MockRedirectUri); var nonce = GetFlowNonce(startResult.State); capturedJwt = jwt.CreateIdToken(MockIssuer, MockClientId, MockSubject, nonce); var callbackResult = await service.HandleCallback("code", startResult.State, MockRedirectUri); callbackResult.Success.ShouldBeTrue(); callbackResult.Subject.ShouldBe(MockSubject); } finally { OidcAuthService.ClearDiscoveryCache(); } } [Fact] public async Task HandleCallback_ExpiredIdToken_ReturnsFailure() { await EnableOidcInConfig(); OidcAuthService.ClearDiscoveryCache(); var jwt = new JwtTestHelper(); string? capturedJwt = null; var handler = CreateDiscoveryHandler( jwksJson: jwt.GetJwksJson(), tokenResponseFactory: () => $$"""{"id_token":"{{capturedJwt}}","access_token":"access-123","token_type":"Bearer"}"""); var service = CreateServiceWithHandler(handler); try { var startResult = await service.StartAuthorization(MockRedirectUri); var nonce = GetFlowNonce(startResult.State); // Token expired 1 hour ago (well outside the 2-minute clock skew) capturedJwt = jwt.CreateIdToken(MockIssuer, MockClientId, MockSubject, nonce, expiry: DateTime.UtcNow.AddHours(-1), notBefore: DateTime.UtcNow.AddHours(-2)); var callbackResult = await service.HandleCallback("code", startResult.State, MockRedirectUri); callbackResult.Success.ShouldBeFalse(); callbackResult.Error.ShouldContain("ID token validation failed"); } finally { OidcAuthService.ClearDiscoveryCache(); } } [Fact] public async Task HandleCallback_WrongNonce_ReturnsFailure() { await EnableOidcInConfig(); OidcAuthService.ClearDiscoveryCache(); var jwt = new JwtTestHelper(); var handler = CreateDiscoveryHandler( jwksJson: jwt.GetJwksJson(), tokenResponseFactory: () => $$"""{"id_token":"{{jwt.CreateIdToken(MockIssuer, MockClientId, MockSubject, "wrong-nonce")}}","access_token":"access-123","token_type":"Bearer"}"""); var service = CreateServiceWithHandler(handler); try { var startResult = await service.StartAuthorization(MockRedirectUri); var callbackResult = await service.HandleCallback("code", startResult.State, MockRedirectUri); callbackResult.Success.ShouldBeFalse(); callbackResult.Error.ShouldContain("ID token validation failed"); } finally { OidcAuthService.ClearDiscoveryCache(); } } [Fact] public async Task HandleCallback_WrongIssuer_ReturnsFailure() { await EnableOidcInConfig(); OidcAuthService.ClearDiscoveryCache(); var jwt = new JwtTestHelper(); string? capturedJwt = null; var handler = CreateDiscoveryHandler( jwksJson: jwt.GetJwksJson(), tokenResponseFactory: () => $$"""{"id_token":"{{capturedJwt}}","access_token":"access-123","token_type":"Bearer"}"""); var service = CreateServiceWithHandler(handler); try { var startResult = await service.StartAuthorization(MockRedirectUri); var nonce = GetFlowNonce(startResult.State); // Use a different issuer than what's in config capturedJwt = jwt.CreateIdToken("https://evil-issuer.test", MockClientId, MockSubject, nonce); var callbackResult = await service.HandleCallback("code", startResult.State, MockRedirectUri); callbackResult.Success.ShouldBeFalse(); callbackResult.Error.ShouldContain("ID token validation failed"); } finally { OidcAuthService.ClearDiscoveryCache(); } } [Fact] public async Task HandleCallback_IssuerWithTrailingSlash_ReturnsSuccess() { await EnableOidcInConfig(); OidcAuthService.ClearDiscoveryCache(); var jwt = new JwtTestHelper(); string? capturedJwt = null; var handler = CreateDiscoveryHandler( jwksJson: jwt.GetJwksJson(), tokenResponseFactory: () => $$"""{"id_token":"{{capturedJwt}}","access_token":"access-123","token_type":"Bearer"}"""); var service = CreateServiceWithHandler(handler); try { var startResult = await service.StartAuthorization(MockRedirectUri); var nonce = GetFlowNonce(startResult.State); // Use issuer WITH trailing slash (Authentik-style) while config has no trailing slash capturedJwt = jwt.CreateIdToken(MockIssuer + "/", MockClientId, MockSubject, nonce); var callbackResult = await service.HandleCallback("code", startResult.State, MockRedirectUri); callbackResult.Success.ShouldBeTrue(); callbackResult.Subject.ShouldBe(MockSubject); } finally { OidcAuthService.ClearDiscoveryCache(); } } [Fact] public async Task HandleCallback_MissingSubClaim_ReturnsFailure() { await EnableOidcInConfig(); OidcAuthService.ClearDiscoveryCache(); var jwt = new JwtTestHelper(); string? capturedJwt = null; var handler = CreateDiscoveryHandler( jwksJson: jwt.GetJwksJson(), tokenResponseFactory: () => $$"""{"id_token":"{{capturedJwt}}","access_token":"access-123","token_type":"Bearer"}"""); var service = CreateServiceWithHandler(handler); try { var startResult = await service.StartAuthorization(MockRedirectUri); var nonce = GetFlowNonce(startResult.State); capturedJwt = jwt.CreateIdToken(MockIssuer, MockClientId, subject: null, nonce); var callbackResult = await service.HandleCallback("code", startResult.State, MockRedirectUri); callbackResult.Success.ShouldBeFalse(); callbackResult.Error.ShouldContain("missing 'sub' claim"); } finally { OidcAuthService.ClearDiscoveryCache(); } } #endregion #region Token Exchange Error Handling Tests [Fact] public async Task HandleCallback_TokenEndpointReturnsHttpError_ReturnsFailure() { await EnableOidcInConfig(); OidcAuthService.ClearDiscoveryCache(); const string redirectUri = "https://app.test/api/auth/oidc/callback"; var handler = CreateDiscoveryHandler(tokenResponse: """{"error":"invalid_grant"}""", tokenStatusCode: HttpStatusCode.BadRequest); var service = CreateServiceWithHandler(handler); try { var startResult = await service.StartAuthorization(redirectUri); var callbackResult = await service.HandleCallback("some-code", startResult.State, redirectUri); callbackResult.Success.ShouldBeFalse(); callbackResult.Error.ShouldContain("Failed to exchange authorization code"); } finally { OidcAuthService.ClearDiscoveryCache(); } } [Fact] public async Task HandleCallback_TokenEndpointThrowsNetworkError_ReturnsFailure() { await EnableOidcInConfig(); OidcAuthService.ClearDiscoveryCache(); const string redirectUri = "https://app.test/api/auth/oidc/callback"; var handler = CreateDiscoveryHandler(throwNetworkErrorOnToken: true); var service = CreateServiceWithHandler(handler); try { var startResult = await service.StartAuthorization(redirectUri); var callbackResult = await service.HandleCallback("some-code", startResult.State, redirectUri); callbackResult.Success.ShouldBeFalse(); callbackResult.Error.ShouldContain("Failed to exchange authorization code"); } finally { OidcAuthService.ClearDiscoveryCache(); } } [Fact] public async Task HandleCallback_TokenResponseMissingIdToken_ReturnsFailure() { await EnableOidcInConfig(); OidcAuthService.ClearDiscoveryCache(); const string redirectUri = "https://app.test/api/auth/oidc/callback"; // Token response with access_token but no id_token — ValidateIdToken will fail on empty string var handler = CreateDiscoveryHandler(tokenResponse: """{"access_token":"abc","token_type":"Bearer"}"""); var service = CreateServiceWithHandler(handler); try { var startResult = await service.StartAuthorization(redirectUri); var callbackResult = await service.HandleCallback("some-code", startResult.State, redirectUri); callbackResult.Success.ShouldBeFalse(); callbackResult.Error.ShouldContain("ID token validation failed"); } finally { OidcAuthService.ClearDiscoveryCache(); } } #endregion #region Expiry and Capacity Tests (via reflection) [Fact] public void ExchangeOneTimeCode_ExpiredCode_ReturnsNull() { var service = CreateService(); // Insert a pre-expired entry directly into the static dictionary var code = InsertExpiredOneTimeCode(); var result = service.ExchangeOneTimeCode(code); result.ShouldBeNull(); } [Fact] public async Task HandleCallback_ExpiredFlowState_ReturnsExpiredError() { await EnableOidcInConfig(); OidcAuthService.ClearDiscoveryCache(); const string redirectUri = "https://app.test/api/auth/oidc/callback"; var handler = CreateDiscoveryHandler(); var service = CreateServiceWithHandler(handler); try { // Get a valid state from StartAuthorization, then backdate its CreatedAt var startResult = await service.StartAuthorization(redirectUri); BackdateFlowState(startResult.State, TimeSpan.FromMinutes(11)); var callbackResult = await service.HandleCallback("some-code", startResult.State, redirectUri); callbackResult.Success.ShouldBeFalse(); callbackResult.Error.ShouldContain("OIDC flow has expired"); } finally { OidcAuthService.ClearDiscoveryCache(); } } [Fact] public async Task StartAuthorization_WhenAtCapacity_ThrowsInvalidOperationException() { await EnableOidcInConfig(); OidcAuthService.ClearDiscoveryCache(); const string redirectUri = "https://app.test/api/auth/oidc/callback"; var handler = CreateDiscoveryHandler(); var service = CreateServiceWithHandler(handler); var insertedKeys = new List(); try { // Fill PendingFlows up to the maximum (100 entries) for (var i = 0; i < 100; i++) { var key = InsertPendingFlowState(redirectUri); insertedKeys.Add(key); } // The 101st attempt should throw await Should.ThrowAsync( () => service.StartAuthorization(redirectUri), "Too many pending OIDC flows"); } finally { RemovePendingFlowStates(insertedKeys); OidcAuthService.ClearDiscoveryCache(); } } // --- Reflection helpers --- private static string InsertExpiredOneTimeCode() { var oneTimeCodesField = typeof(OidcAuthService) .GetField("OneTimeCodes", BindingFlags.NonPublic | BindingFlags.Static)!; var oneTimeCodes = oneTimeCodesField.GetValue(null)!; var entryType = typeof(OidcAuthService) .GetNestedType("OidcOneTimeCodeEntry", BindingFlags.NonPublic)!; var entry = Activator.CreateInstance(entryType)!; SetReflectionProperty(entry, "AccessToken", "test-access"); SetReflectionProperty(entry, "RefreshToken", "test-refresh"); SetReflectionProperty(entry, "ExpiresIn", 3600); SetReflectionProperty(entry, "CreatedAt", DateTime.UtcNow - TimeSpan.FromSeconds(31)); var code = "expired-test-code-" + Guid.NewGuid().ToString("N"); oneTimeCodes.GetType().GetMethod("TryAdd")!.Invoke(oneTimeCodes, new[] { code, entry }); return code; } /// Replaces the stored OidcFlowState with one whose CreatedAt is backdated by the given age. private static void BackdateFlowState(string state, TimeSpan age) { var pendingFlowsField = typeof(OidcAuthService) .GetField("PendingFlows", BindingFlags.NonPublic | BindingFlags.Static)!; var pendingFlows = pendingFlowsField.GetValue(null)!; var dictType = pendingFlows.GetType(); // Get the existing entry var indexer = dictType.GetProperty("Item")!; var existing = indexer.GetValue(pendingFlows, new object[] { state })!; // Build a new entry with CreatedAt backdated var flowType = existing.GetType(); var newEntry = Activator.CreateInstance(flowType)!; foreach (var prop in flowType.GetProperties()) { var value = prop.Name == "CreatedAt" ? DateTime.UtcNow - age : prop.GetValue(existing); SetReflectionProperty(newEntry, prop.Name, value!); } // Replace the entry: TryUpdate(state, newEntry, existing) var tryUpdate = dictType.GetMethod("TryUpdate")!; tryUpdate.Invoke(pendingFlows, new[] { state, newEntry, existing }); } private static string InsertPendingFlowState(string redirectUri) { var pendingFlowsField = typeof(OidcAuthService) .GetField("PendingFlows", BindingFlags.NonPublic | BindingFlags.Static)!; var pendingFlows = pendingFlowsField.GetValue(null)!; var flowType = typeof(OidcAuthService) .GetNestedType("OidcFlowState", BindingFlags.NonPublic)!; var entry = Activator.CreateInstance(flowType)!; var key = "capacity-test-" + Guid.NewGuid().ToString("N"); SetReflectionProperty(entry, "State", key); SetReflectionProperty(entry, "Nonce", "test-nonce"); SetReflectionProperty(entry, "CodeVerifier", "test-verifier"); SetReflectionProperty(entry, "RedirectUri", redirectUri); SetReflectionProperty(entry, "CreatedAt", DateTime.UtcNow); pendingFlows.GetType().GetMethod("TryAdd")!.Invoke(pendingFlows, new[] { key, entry }); return key; } private static void RemovePendingFlowStates(IEnumerable keys) { var pendingFlowsField = typeof(OidcAuthService) .GetField("PendingFlows", BindingFlags.NonPublic | BindingFlags.Static)!; var pendingFlows = pendingFlowsField.GetValue(null)!; var tryRemove = pendingFlows.GetType().GetMethod("TryRemove", new[] { typeof(string), pendingFlows.GetType().GetGenericArguments()[1].MakeByRefType() })!; foreach (var key in keys) { var args = new object?[] { key, null }; tryRemove.Invoke(pendingFlows, args); } } private static void SetReflectionProperty(object obj, string propertyName, object value) { var prop = obj.GetType() .GetProperty(propertyName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)!; prop.SetValue(obj, value); } #endregion #region PKCE and Authorization URL Tests [Fact] public async Task StartAuthorization_ReturnUrl_ContainsPkceParameters() { await EnableOidcInConfig(); OidcAuthService.ClearDiscoveryCache(); var service = CreateServiceWithHandler(CreateDiscoveryHandler()); try { var result = await service.StartAuthorization("https://app.test/api/auth/oidc/callback"); result.AuthorizationUrl.ShouldContain("code_challenge="); result.AuthorizationUrl.ShouldContain("code_challenge_method=S256"); } finally { OidcAuthService.ClearDiscoveryCache(); } } [Fact] public async Task StartAuthorization_ReturnUrl_ContainsAllRequiredOAuthParams() { await EnableOidcInConfig(); OidcAuthService.ClearDiscoveryCache(); var service = CreateServiceWithHandler(CreateDiscoveryHandler()); const string redirectUri = "https://app.test/api/auth/oidc/callback"; try { var result = await service.StartAuthorization(redirectUri); var url = result.AuthorizationUrl; url.ShouldContain("response_type=code"); url.ShouldContain("client_id="); url.ShouldContain("redirect_uri="); url.ShouldContain("scope="); url.ShouldContain("state="); url.ShouldContain("nonce="); } finally { OidcAuthService.ClearDiscoveryCache(); } } [Fact] public async Task StartAuthorization_PkceChallenge_IsValidBase64Url() { await EnableOidcInConfig(); OidcAuthService.ClearDiscoveryCache(); var service = CreateServiceWithHandler(CreateDiscoveryHandler()); try { var result = await service.StartAuthorization("https://app.test/api/auth/oidc/callback"); // Extract code_challenge from URL var uri = new Uri(result.AuthorizationUrl); var queryParts = uri.Query.TrimStart('?').Split('&'); var challengePart = queryParts.FirstOrDefault(p => p.StartsWith("code_challenge=")); challengePart.ShouldNotBeNull(); var challengeValue = Uri.UnescapeDataString(challengePart.Substring("code_challenge=".Length)); // Base64url characters: A-Z a-z 0-9 - _ (no +, /, or =) challengeValue.ShouldNotContain("+"); challengeValue.ShouldNotContain("/"); challengeValue.ShouldNotContain("="); challengeValue.Length.ShouldBeGreaterThan(0); } finally { OidcAuthService.ClearDiscoveryCache(); } } [Fact] public async Task StartAuthorization_SpecialCharsInConfig_UrlEncodesParameters() { // Configure OIDC with special characters in ClientId and Scopes var user = await _usersContext.Users.FirstAsync(); user.Oidc = new OidcConfig { Enabled = true, IssuerUrl = "https://mock-oidc-provider.test", ClientId = "test client+id", // space and plus sign require encoding Scopes = "openid profile email", // spaces between scopes require encoding AuthorizedSubject = "test-subject", ProviderName = "TestProvider" }; await _usersContext.SaveChangesAsync(); OidcAuthService.ClearDiscoveryCache(); var service = CreateServiceWithHandler(CreateDiscoveryHandler()); try { var result = await service.StartAuthorization("https://app.test/api/auth/oidc/callback"); var url = result.AuthorizationUrl; // Uri.EscapeDataString: space → %20, + → %2B url.ShouldContain("client_id=test%20client%2Bid"); url.ShouldContain("scope=openid%20profile%20email"); } finally { OidcAuthService.ClearDiscoveryCache(); } } #endregion #region Cleanup Timer Tests [Fact] public void CleanupExpiredEntries_RemovesExpiredFlowsAndCodes() { const string redirectUri = "https://app.test/api/auth/oidc/callback"; var service = CreateService(); // Insert an expired flow state and backdate it beyond the expiry window var expiredFlowKey = InsertPendingFlowState(redirectUri); BackdateFlowState(expiredFlowKey, TimeSpan.FromMinutes(11)); // Insert a valid (non-expired) flow state that cleanup must leave in place var validFlowKey = InsertPendingFlowState(redirectUri); // Insert an expired one-time code and a valid one-time code var expiredCodeKey = InsertExpiredOneTimeCode(); var validCodeKey = service.StoreOneTimeCode("access", "refresh", 3600); try { // Invoke the private static CleanupExpiredEntries directly (bypassing the timer) var method = typeof(OidcAuthService) .GetMethod("CleanupExpiredEntries", BindingFlags.NonPublic | BindingFlags.Static)!; method.Invoke(null, new object?[] { null }); // Expired flow state must have been removed var pendingFlowsField = typeof(OidcAuthService) .GetField("PendingFlows", BindingFlags.NonPublic | BindingFlags.Static)!; var pendingFlows = pendingFlowsField.GetValue(null)!; var containsKeyFlow = pendingFlows.GetType().GetMethod("ContainsKey")!; ((bool)containsKeyFlow.Invoke(pendingFlows, new object[] { expiredFlowKey })!).ShouldBeFalse(); // Valid flow state must still be present ((bool)containsKeyFlow.Invoke(pendingFlows, new object[] { validFlowKey })!).ShouldBeTrue(); // Expired one-time code must have been removed var oneTimeCodesField = typeof(OidcAuthService) .GetField("OneTimeCodes", BindingFlags.NonPublic | BindingFlags.Static)!; var oneTimeCodes = oneTimeCodesField.GetValue(null)!; var containsKeyCode = oneTimeCodes.GetType().GetMethod("ContainsKey")!; ((bool)containsKeyCode.Invoke(oneTimeCodes, new object[] { expiredCodeKey })!).ShouldBeFalse(); // Valid one-time code must still be present ((bool)containsKeyCode.Invoke(oneTimeCodes, new object[] { validCodeKey })!).ShouldBeTrue(); } finally { RemovePendingFlowStates(new[] { validFlowKey }); service.ExchangeOneTimeCode(validCodeKey); // consume to clean up } } #endregion public void Dispose() { _usersContext.Dispose(); _connection.Dispose(); } /// /// Creates RSA-signed JWTs for use in ID token validation tests. /// private sealed class JwtTestHelper { private readonly RSA _rsa = RSA.Create(2048); private readonly RsaSecurityKey _key; public JwtTestHelper() { _key = new RsaSecurityKey(_rsa) { KeyId = "test-key-1" }; } /// Creates a signed JWT. Pass subject=null to produce a token with no 'sub' claim. public string CreateIdToken(string issuer, string audience, string? subject, string nonce, DateTime? expiry = null, DateTime? notBefore = null) { var claims = new List { new("nonce", nonce) }; if (subject is not null) claims.Add(new Claim("sub", subject)); var expiresAt = expiry ?? DateTime.UtcNow.AddHours(1); var notBeforeAt = notBefore ?? DateTime.UtcNow.AddMinutes(-1); var descriptor = new SecurityTokenDescriptor { Issuer = issuer, Audience = audience, Subject = new ClaimsIdentity(claims), NotBefore = notBeforeAt, Expires = expiresAt, IssuedAt = notBeforeAt, SigningCredentials = new SigningCredentials(_key, SecurityAlgorithms.RsaSha256) }; var handler = new JwtSecurityTokenHandler(); return handler.WriteToken(handler.CreateToken(descriptor)); } public string GetJwksJson() { var rsaParams = _rsa.ExportParameters(includePrivateParameters: false); return JsonSerializer.Serialize(new { keys = new[] { new { kty = "RSA", use = "sig", kid = _key.KeyId, alg = "RS256", n = Base64UrlEncode(rsaParams.Modulus!), e = Base64UrlEncode(rsaParams.Exponent!) } } }); } private static string Base64UrlEncode(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); } private sealed class MockHttpMessageHandler : HttpMessageHandler { private readonly Func _handler; public MockHttpMessageHandler(Func handler) { _handler = handler; } protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { try { return Task.FromResult(_handler(request)); } catch (Exception ex) { // Convert synchronous exceptions to faulted Tasks so HttpClient propagates them correctly return Task.FromException(ex); } } } }