mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-03-30 12:03:25 -04:00
Add OIDC support (#500)
This commit is contained in:
@@ -24,4 +24,8 @@
|
||||
<ProjectReference Include="..\Cleanuparr.Api\Cleanuparr.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -3,6 +3,12 @@ using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Xunit;
|
||||
|
||||
// Integration tests share file-system state (config-dir users.db used by SetupGuardMiddleware),
|
||||
// so we must run them sequentially to avoid interference between factories.
|
||||
[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||
|
||||
namespace Cleanuparr.Api.Tests;
|
||||
|
||||
@@ -41,10 +47,19 @@ public class CustomWebApplicationFactory : WebApplicationFactory<Program>
|
||||
options.UseSqlite($"Data Source={dbPath}");
|
||||
});
|
||||
|
||||
// Ensure DB is created
|
||||
var sp = services.BuildServiceProvider();
|
||||
using var scope = sp.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
// Remove all hosted services (Quartz scheduler, BackgroundJobManager) to prevent
|
||||
// Quartz.Logging.LogProvider.ResolvedLogProvider (a cached Lazy<T>) from being accessed
|
||||
// with a disposed ILoggerFactory from the previous factory lifecycle.
|
||||
// Auth tests don't depend on background job scheduling, so this is safe.
|
||||
foreach (var hostedService in services.Where(d => d.ServiceType == typeof(IHostedService)).ToList())
|
||||
services.Remove(hostedService);
|
||||
|
||||
// Ensure DB is created using a minimal isolated context (not the full app DI container)
|
||||
// to avoid any residual static state contamination.
|
||||
using var db = new UsersContext(
|
||||
new DbContextOptionsBuilder<UsersContext>()
|
||||
.UseSqlite($"Data Source={dbPath}")
|
||||
.Options);
|
||||
db.Database.EnsureCreated();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the OIDC account linking flow (POST /api/account/oidc/link and
|
||||
/// GET /api/account/oidc/link/callback). Uses a mock IOidcAuthService that tracks the
|
||||
/// initiatorUserId passed from StartOidcLink so OidcLinkCallback can complete the flow.
|
||||
/// </summary>
|
||||
[Collection("Auth Integration Tests")]
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class AccountControllerOidcTests : IClassFixture<AccountControllerOidcTests.OidcLinkWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly OidcLinkWebApplicationFactory _factory;
|
||||
|
||||
// Shared across ordered tests
|
||||
private static string? _accessToken;
|
||||
|
||||
public AccountControllerOidcTests(OidcLinkWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
if (_accessToken is not null)
|
||||
{
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task Setup_CreateAccountAndComplete()
|
||||
{
|
||||
var createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = "linkadmin",
|
||||
password = "LinkPassword123!"
|
||||
});
|
||||
createResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||
|
||||
var completeResponse = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
completeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(1)]
|
||||
public async Task Login_StoreAccessToken()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "linkadmin",
|
||||
password = "LinkPassword123!"
|
||||
});
|
||||
|
||||
var bodyText = await response.Content.ReadAsStringAsync();
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK, $"Login failed. Body: {bodyText}");
|
||||
|
||||
var body = JsonSerializer.Deserialize<JsonElement>(bodyText);
|
||||
body.TryGetProperty("requiresTwoFactor", out var rtf)
|
||||
.ShouldBeTrue($"Missing 'requiresTwoFactor' in body: {bodyText}");
|
||||
rtf.GetBoolean().ShouldBeFalse();
|
||||
// Tokens are nested: { "requiresTwoFactor": false, "tokens": { "accessToken": "..." } }
|
||||
_accessToken = body.GetProperty("tokens").GetProperty("accessToken").GetString();
|
||||
_accessToken.ShouldNotBeNullOrEmpty();
|
||||
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(2)]
|
||||
public async Task OidcLink_WhenOidcDisabled_ReturnsBadRequest()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/account/oidc/link", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("error").GetString().ShouldContain("OIDC is not enabled");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(3)]
|
||||
public async Task EnableOidcConfig_ViaDirectDbUpdate()
|
||||
{
|
||||
await _factory.EnableOidcAsync();
|
||||
|
||||
var statusResponse = await _client.GetAsync("/api/auth/status");
|
||||
statusResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await statusResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(4)]
|
||||
public async Task OidcLink_WhenAuthenticated_ReturnsAuthorizationUrl()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/account/oidc/link", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var authUrl = body.GetProperty("authorizationUrl").GetString();
|
||||
authUrl.ShouldNotBeNullOrEmpty();
|
||||
authUrl.ShouldContain("authorize");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(5)]
|
||||
public async Task OidcLinkCallback_WithErrorParam_RedirectsToSettingsWithError()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/account/oidc/link/callback?error=access_denied");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("/settings/account");
|
||||
location.ShouldContain("oidc_link_error=failed");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(6)]
|
||||
public async Task OidcLinkCallback_MissingCodeOrState_RedirectsWithError()
|
||||
{
|
||||
var noParams = await _client.GetAsync("/api/account/oidc/link/callback");
|
||||
noParams.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
noParams.Headers.Location?.ToString().ShouldContain("oidc_link_error=failed");
|
||||
|
||||
var onlyCode = await _client.GetAsync("/api/account/oidc/link/callback?code=some-code");
|
||||
onlyCode.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
onlyCode.Headers.Location?.ToString().ShouldContain("oidc_link_error=failed");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(7)]
|
||||
public async Task OidcLinkCallback_ValidFlow_SavesSubjectAndRedirectsToSuccess()
|
||||
{
|
||||
// First trigger StartOidcLink so the mock captures the initiatorUserId
|
||||
var linkResponse = await _client.PostAsync("/api/account/oidc/link", null);
|
||||
linkResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
// Now simulate the IdP callback with the mock's success state
|
||||
var callbackResponse = await _client.GetAsync(
|
||||
$"/api/account/oidc/link/callback?code=valid-code&state={MockOidcAuthService.LinkSuccessState}");
|
||||
|
||||
callbackResponse.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = callbackResponse.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("/settings/account");
|
||||
location.ShouldContain("oidc_link=success");
|
||||
location.ShouldNotContain("oidc_link_error");
|
||||
|
||||
// Verify the subject was saved to config
|
||||
var savedSubject = await _factory.GetAuthorizedSubjectAsync();
|
||||
savedSubject.ShouldBe(MockOidcAuthService.LinkedSubject);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(8)]
|
||||
public async Task OidcLinkCallback_NoInitiatorUserId_RedirectsWithError()
|
||||
{
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/account/oidc/link/callback?code=valid-code&state={MockOidcAuthService.NoInitiatorState}");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("oidc_link_error=failed");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(9)]
|
||||
public async Task OidcLink_WhenUnauthenticated_ReturnsUnauthorized()
|
||||
{
|
||||
// Create a fresh unauthenticated client
|
||||
var unauthClient = _factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
var response = await unauthClient.PostAsync("/api/account/oidc/link", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
#region Exclusive Mode
|
||||
|
||||
[Fact, TestPriority(10)]
|
||||
public async Task EnableExclusiveMode_ViaDirectDbUpdate()
|
||||
{
|
||||
await _factory.SetOidcExclusiveModeAsync(true);
|
||||
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcExclusiveMode").GetBoolean().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(11)]
|
||||
public async Task ChangePassword_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PutAsJsonAsync("/api/account/password", new
|
||||
{
|
||||
currentPassword = "LinkPassword123!",
|
||||
newPassword = "NewPassword456!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(12)]
|
||||
public async Task PlexLink_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/account/plex/link", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(13)]
|
||||
public async Task PlexUnlink_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.DeleteAsync("/api/account/plex/link");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(14)]
|
||||
public async Task OidcConfigUpdate_StillWorks_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PutAsJsonAsync("/api/account/oidc", new
|
||||
{
|
||||
enabled = true,
|
||||
issuerUrl = "https://mock-oidc-provider.test",
|
||||
clientId = "test-client",
|
||||
clientSecret = "test-secret",
|
||||
scopes = "openid profile email",
|
||||
authorizedSubject = MockOidcAuthService.LinkedSubject,
|
||||
providerName = "TestProvider",
|
||||
redirectUrl = "",
|
||||
exclusiveMode = true
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(15)]
|
||||
public async Task OidcUnlink_ResetsExclusiveMode()
|
||||
{
|
||||
var response = await _client.DeleteAsync("/api/account/oidc/link");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
// Verify exclusive mode was reset
|
||||
var exclusiveMode = await _factory.GetExclusiveModeAsync();
|
||||
exclusiveMode.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(16)]
|
||||
public async Task DisableExclusiveMode_PasswordChangeWorks_Again()
|
||||
{
|
||||
// Re-enable OIDC with a linked subject but without exclusive mode
|
||||
await _factory.EnableOidcAsync();
|
||||
await _factory.SetOidcExclusiveModeAsync(false);
|
||||
|
||||
var response = await _client.PutAsJsonAsync("/api/account/password", new
|
||||
{
|
||||
currentPassword = "LinkPassword123!",
|
||||
newPassword = "NewPassword789!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Infrastructure
|
||||
|
||||
public class OidcLinkWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly string _configDir;
|
||||
|
||||
public OidcLinkWebApplicationFactory()
|
||||
{
|
||||
_configDir = Cleanuparr.Shared.Helpers.ConfigurationPathProvider.GetConfigPath();
|
||||
|
||||
// Delete both databases (and their WAL sidecar files) so each test run starts clean.
|
||||
// users.db must be at the config path so CreateStaticInstance() (used by
|
||||
// SetupGuardMiddleware) reads the same file as the DI-injected UsersContext.
|
||||
// ClearAllPools() releases any SQLite connections held by the previous test factory's
|
||||
// connection pool, which would otherwise prevent file deletion on Windows or cause
|
||||
// SQLite to reconstruct a partial database from stale WAL files on other platforms.
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
foreach (var name in new[] {
|
||||
"users.db", "users.db-shm", "users.db-wal",
|
||||
"cleanuparr.db", "cleanuparr.db-shm", "cleanuparr.db-wal" })
|
||||
{
|
||||
var path = Path.Combine(_configDir, name);
|
||||
if (File.Exists(path))
|
||||
try { File.Delete(path); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<UsersContext>));
|
||||
if (descriptor != null) services.Remove(descriptor);
|
||||
|
||||
var contextDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(UsersContext));
|
||||
if (contextDescriptor != null) services.Remove(contextDescriptor);
|
||||
|
||||
// Use the config-path db so SetupGuardMiddleware (CreateStaticInstance) sees
|
||||
// the same data as the DI-injected context. Apply the same naming conventions
|
||||
// as CreateStaticInstance() so the schemas match.
|
||||
var dbPath = Path.Combine(_configDir, "users.db");
|
||||
services.AddDbContext<UsersContext>(options =>
|
||||
{
|
||||
options
|
||||
.UseSqlite($"Data Source={dbPath}")
|
||||
.UseLowerCaseNamingConvention()
|
||||
.UseSnakeCaseNamingConvention();
|
||||
});
|
||||
|
||||
var oidcDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IOidcAuthService));
|
||||
if (oidcDescriptor != null) services.Remove(oidcDescriptor);
|
||||
|
||||
services.AddSingleton<IOidcAuthService, MockOidcAuthService>();
|
||||
|
||||
// Remove all hosted services (Quartz scheduler, BackgroundJobManager) to prevent
|
||||
// Quartz.Logging.LogProvider.ResolvedLogProvider (a cached Lazy<T>) from being accessed
|
||||
// with a disposed ILoggerFactory from the previous factory lifecycle.
|
||||
// Auth tests don't depend on background job scheduling, so this is safe.
|
||||
foreach (var hostedService in services.Where(d => d.ServiceType == typeof(IHostedService)).ToList())
|
||||
services.Remove(hostedService);
|
||||
|
||||
// Ensure DB is created using a minimal isolated context (not the full app DI container)
|
||||
// to avoid any residual static state contamination.
|
||||
using var db = new UsersContext(
|
||||
new DbContextOptionsBuilder<UsersContext>()
|
||||
.UseSqlite($"Data Source={dbPath}")
|
||||
.UseLowerCaseNamingConvention()
|
||||
.UseSnakeCaseNamingConvention()
|
||||
.Options);
|
||||
db.Database.EnsureCreated();
|
||||
});
|
||||
}
|
||||
|
||||
public async Task EnableOidcAsync()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
user.Oidc = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "https://mock-oidc-provider.test",
|
||||
ClientId = "test-client",
|
||||
ClientSecret = "test-secret",
|
||||
Scopes = "openid profile email",
|
||||
AuthorizedSubject = "initial-subject",
|
||||
ProviderName = "TestProvider"
|
||||
};
|
||||
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<string?> GetAuthorizedSubjectAsync()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
return user?.Oidc.AuthorizedSubject;
|
||||
}
|
||||
|
||||
public async Task SetOidcExclusiveModeAsync(bool enabled)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is not null)
|
||||
{
|
||||
user.Oidc.ExclusiveMode = enabled;
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> GetExclusiveModeAsync()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
return user?.Oidc.ExclusiveMode ?? false;
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
if (disposing)
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
foreach (var name in new[] {
|
||||
"users.db", "users.db-shm", "users.db-wal",
|
||||
"cleanuparr.db", "cleanuparr.db-shm", "cleanuparr.db-wal" })
|
||||
{
|
||||
var path = Path.Combine(_configDir, name);
|
||||
if (File.Exists(path))
|
||||
try { File.Delete(path); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MockOidcAuthService : IOidcAuthService
|
||||
{
|
||||
public const string LinkSuccessState = "mock-link-success-state";
|
||||
public const string NoInitiatorState = "mock-no-initiator-state";
|
||||
public const string LinkedSubject = "newly-linked-subject-123";
|
||||
|
||||
private string? _lastInitiatorUserId;
|
||||
private readonly ConcurrentDictionary<string, OidcTokenExchangeResult> _oneTimeCodes = new();
|
||||
|
||||
public Task<OidcAuthorizationResult> StartAuthorization(string redirectUri, string? initiatorUserId = null)
|
||||
{
|
||||
_lastInitiatorUserId = initiatorUserId;
|
||||
return Task.FromResult(new OidcAuthorizationResult
|
||||
{
|
||||
AuthorizationUrl = $"https://mock-oidc-provider.test/authorize?state={LinkSuccessState}",
|
||||
State = LinkSuccessState
|
||||
});
|
||||
}
|
||||
|
||||
public Task<OidcCallbackResult> HandleCallback(string code, string state, string redirectUri)
|
||||
{
|
||||
if (state == LinkSuccessState)
|
||||
{
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = true,
|
||||
Subject = LinkedSubject,
|
||||
PreferredUsername = "linkuser",
|
||||
Email = "link@example.com",
|
||||
InitiatorUserId = _lastInitiatorUserId
|
||||
});
|
||||
}
|
||||
|
||||
if (state == NoInitiatorState)
|
||||
{
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = true,
|
||||
Subject = LinkedSubject,
|
||||
InitiatorUserId = null // No initiator — controller should redirect with error
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Invalid or expired OIDC state"
|
||||
});
|
||||
}
|
||||
|
||||
public string StoreOneTimeCode(string accessToken, string refreshToken, int expiresIn)
|
||||
{
|
||||
var code = Guid.NewGuid().ToString("N");
|
||||
_oneTimeCodes.TryAdd(code, new OidcTokenExchangeResult
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
RefreshToken = refreshToken,
|
||||
ExpiresIn = expiresIn
|
||||
});
|
||||
return code;
|
||||
}
|
||||
|
||||
public OidcTokenExchangeResult? ExchangeOneTimeCode(string code) =>
|
||||
_oneTimeCodes.TryRemove(code, out var result) ? result : null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -10,6 +10,7 @@ namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
/// Uses a single shared factory to avoid static state conflicts.
|
||||
/// Tests are ordered to build on each other: setup → login → protected endpoints.
|
||||
/// </summary>
|
||||
[Collection("Auth Integration Tests")]
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
@@ -243,6 +244,30 @@ public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
|
||||
body.GetProperty("setupCompleted").GetBoolean().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(16)]
|
||||
public async Task OidcExchange_WithNonexistentCode_ReturnsNotFound()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
|
||||
{
|
||||
code = "nonexistent-one-time-code"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(17)]
|
||||
public async Task AuthStatus_IncludesOidcFields()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
// Verify OIDC fields exist in the response (values depend on shared static DB state)
|
||||
body.TryGetProperty("oidcEnabled", out _).ShouldBeTrue();
|
||||
body.TryGetProperty("oidcProviderName", out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
#region TOTP helpers
|
||||
|
||||
private static string _totpSecret = "";
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the OIDC authentication flow.
|
||||
/// Uses a mock IOidcAuthService to simulate IdP behavior.
|
||||
/// Tests are ordered to build on each other: setup → enable OIDC → test flow.
|
||||
/// </summary>
|
||||
[Collection("Auth Integration Tests")]
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class OidcAuthControllerTests : IClassFixture<OidcAuthControllerTests.OidcWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly OidcWebApplicationFactory _factory;
|
||||
|
||||
public OidcAuthControllerTests(OidcWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false // We want to inspect redirects
|
||||
});
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task OidcStart_BeforeSetup_ReturnsBadRequest()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/oidc/start", null);
|
||||
|
||||
// OIDC start is on /api/auth/ path (not blocked by SetupGuardMiddleware)
|
||||
// but the controller returns BadRequest because OIDC is not configured
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(1)]
|
||||
public async Task Setup_CreateAccountAndComplete()
|
||||
{
|
||||
// Create account
|
||||
var createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
createResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||
|
||||
// Complete setup (skip 2FA for this test suite)
|
||||
var completeResponse = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
completeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(2)]
|
||||
public async Task OidcStart_WhenDisabled_ReturnsBadRequest()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/oidc/start", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("error").GetString()!.ShouldContain("OIDC is not enabled");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(3)]
|
||||
public async Task OidcExchange_WhenDisabled_ReturnsNotFound()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
|
||||
{
|
||||
code = "some-random-code"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(4)]
|
||||
public async Task OidcCallback_WithErrorParam_RedirectsToLoginWithError()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/oidc/callback?error=access_denied");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("/auth/login");
|
||||
location.ShouldContain("oidc_error=provider_error");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(5)]
|
||||
public async Task OidcCallback_WithoutCodeOrState_RedirectsToLoginWithError()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/oidc/callback");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("oidc_error=invalid_request");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(6)]
|
||||
public async Task OidcCallback_WithOnlyCode_RedirectsToLoginWithError()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/oidc/callback?code=some-code");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("oidc_error=invalid_request");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(7)]
|
||||
public async Task OidcCallback_WithInvalidState_RedirectsToLoginWithError()
|
||||
{
|
||||
// Even with code and state, if the state is invalid the mock will return failure
|
||||
var response = await _client.GetAsync("/api/auth/oidc/callback?code=some-code&state=invalid-state");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("oidc_error=authentication_failed");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(8)]
|
||||
public async Task EnableOidcConfig_ViaDirectDbUpdate()
|
||||
{
|
||||
// Simulate enabling OIDC via direct DB manipulation (since we'd normally do this through settings UI)
|
||||
await _factory.EnableOidcAsync();
|
||||
|
||||
// Verify auth status reflects OIDC enabled
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeTrue();
|
||||
body.GetProperty("oidcProviderName").GetString().ShouldBe("TestProvider");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(9)]
|
||||
public async Task OidcStart_WhenEnabled_ReturnsAuthorizationUrl()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/oidc/start", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var authUrl = body.GetProperty("authorizationUrl").GetString();
|
||||
authUrl.ShouldNotBeNullOrEmpty();
|
||||
authUrl.ShouldContain("authorize");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(10)]
|
||||
public async Task OidcCallback_ValidFlow_RedirectsWithOneTimeCode()
|
||||
{
|
||||
// Use the mock's valid state to simulate a successful callback
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.ValidState}");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("/auth/oidc/callback");
|
||||
location.ShouldContain("code=");
|
||||
// Should NOT contain oidc_error
|
||||
location.ShouldNotContain("oidc_error");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(11)]
|
||||
public async Task OidcExchange_ValidOneTimeCode_ReturnsTokens()
|
||||
{
|
||||
// First, trigger a valid callback to get a one-time code
|
||||
var callbackResponse = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.ValidState}");
|
||||
callbackResponse.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
|
||||
var location = callbackResponse.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
|
||||
// Extract the one-time code from the redirect URL
|
||||
var uri = new Uri("http://localhost" + location);
|
||||
var queryParams = System.Web.HttpUtility.ParseQueryString(uri.Query);
|
||||
var oneTimeCode = queryParams["code"];
|
||||
oneTimeCode.ShouldNotBeNullOrEmpty();
|
||||
|
||||
// Exchange the one-time code for tokens
|
||||
var exchangeResponse = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
|
||||
{
|
||||
code = oneTimeCode
|
||||
});
|
||||
|
||||
exchangeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await exchangeResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("accessToken").GetString().ShouldNotBeNullOrEmpty();
|
||||
body.GetProperty("refreshToken").GetString().ShouldNotBeNullOrEmpty();
|
||||
body.GetProperty("expiresIn").GetInt32().ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(12)]
|
||||
public async Task OidcExchange_SameCodeTwice_SecondFails()
|
||||
{
|
||||
// First, trigger a valid callback
|
||||
var callbackResponse = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.ValidState}");
|
||||
var location = callbackResponse.Headers.Location?.ToString()!;
|
||||
var uri = new Uri("http://localhost" + location);
|
||||
var queryParams = System.Web.HttpUtility.ParseQueryString(uri.Query);
|
||||
var oneTimeCode = queryParams["code"]!;
|
||||
|
||||
// First exchange succeeds
|
||||
var response1 = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new { code = oneTimeCode });
|
||||
response1.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
// Second exchange with same code fails
|
||||
var response2 = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new { code = oneTimeCode });
|
||||
response2.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(13)]
|
||||
public async Task OidcExchange_InvalidCode_ReturnsNotFound()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
|
||||
{
|
||||
code = "completely-invalid-code"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(14)]
|
||||
public async Task OidcCallback_UnauthorizedSubject_RedirectsWithError()
|
||||
{
|
||||
// Use the mock's state that returns a different subject
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.WrongSubjectState}");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("oidc_error=unauthorized");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(15)]
|
||||
public async Task AuthStatus_IncludesOidcFields()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("setupCompleted").GetBoolean().ShouldBeTrue();
|
||||
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeTrue();
|
||||
body.GetProperty("oidcProviderName").GetString().ShouldBe("TestProvider");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(16)]
|
||||
public async Task PasswordLogin_StillWorks_AfterOidcEnabled()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
// Should succeed (no 2FA since we skipped it in setup)
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
// No 2FA, so should have tokens directly
|
||||
body.GetProperty("requiresTwoFactor").GetBoolean().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(17)]
|
||||
public async Task OidcStatus_WhenSubjectCleared_StillEnabled()
|
||||
{
|
||||
// Clearing the authorized subject should NOT disable OIDC — it just means any user can log in
|
||||
await _factory.SetOidcAuthorizedSubjectAsync("");
|
||||
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeTrue();
|
||||
|
||||
// Restore for subsequent tests
|
||||
await _factory.SetOidcAuthorizedSubjectAsync(MockOidcAuthService.AuthorizedSubject);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(17)]
|
||||
public async Task OidcStatus_WhenMissingIssuerUrl_ReturnsFalse()
|
||||
{
|
||||
// OIDC should be disabled when essential config (IssuerUrl) is missing
|
||||
await _factory.SetOidcIssuerUrlAsync("");
|
||||
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeFalse();
|
||||
|
||||
// Restore for subsequent tests
|
||||
await _factory.SetOidcIssuerUrlAsync("https://mock-oidc-provider.test");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(17)]
|
||||
public async Task OidcCallback_WithoutLinkedSubject_AllowsAnyUser()
|
||||
{
|
||||
// Clear the authorized subject — any OIDC user should be allowed
|
||||
await _factory.SetOidcAuthorizedSubjectAsync("");
|
||||
|
||||
// Use the "wrong subject" state — this returns a different subject than the authorized one
|
||||
// With no linked subject, it should still succeed
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.WrongSubjectState}");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("code=");
|
||||
location.ShouldNotContain("oidc_error");
|
||||
|
||||
// Restore for subsequent tests
|
||||
await _factory.SetOidcAuthorizedSubjectAsync(MockOidcAuthService.AuthorizedSubject);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(18)]
|
||||
public async Task OidcExchange_RandomCode_ReturnsNotFound()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
|
||||
{
|
||||
code = "completely-random-nonexistent-code"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
#region Exclusive Mode
|
||||
|
||||
[Fact, TestPriority(19)]
|
||||
public async Task EnableExclusiveMode_AuthStatusReflectsIt()
|
||||
{
|
||||
await _factory.SetOidcExclusiveModeAsync(true);
|
||||
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcExclusiveMode").GetBoolean().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(20)]
|
||||
public async Task PasswordLogin_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(21)]
|
||||
public async Task TwoFactorLogin_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login/2fa", new
|
||||
{
|
||||
loginToken = "some-token",
|
||||
code = "123456"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(22)]
|
||||
public async Task PlexLoginPin_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/login/plex/pin", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(23)]
|
||||
public async Task PlexLoginVerify_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login/plex/verify", new
|
||||
{
|
||||
pinId = 12345
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(24)]
|
||||
public async Task OidcStart_StillWorks_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/oidc/start", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("authorizationUrl").GetString().ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(25)]
|
||||
public async Task OidcCallback_StillWorks_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.ValidState}");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("code=");
|
||||
location.ShouldNotContain("oidc_error");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(26)]
|
||||
public async Task DisableExclusiveMode_PasswordLoginWorks_Again()
|
||||
{
|
||||
await _factory.SetOidcExclusiveModeAsync(false);
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Infrastructure
|
||||
|
||||
/// <summary>
|
||||
/// Custom factory that replaces IOidcAuthService with a mock for testing.
|
||||
/// </summary>
|
||||
public class OidcWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public OidcWebApplicationFactory()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"cleanuparr-oidc-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
|
||||
// Clean up any existing DataContext DB from previous test runs.
|
||||
// DataContext.CreateStaticInstance() uses ConfigurationPathProvider which
|
||||
// resolves to {AppContext.BaseDirectory}/config/cleanuparr.db.
|
||||
// We need to ensure a clean state for our tests.
|
||||
var configDir = Cleanuparr.Shared.Helpers.ConfigurationPathProvider.GetConfigPath();
|
||||
var dataDbPath = Path.Combine(configDir, "cleanuparr.db");
|
||||
if (File.Exists(dataDbPath))
|
||||
{
|
||||
try { File.Delete(dataDbPath); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Remove existing UsersContext registration
|
||||
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<UsersContext>));
|
||||
if (descriptor != null) services.Remove(descriptor);
|
||||
|
||||
var contextDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(UsersContext));
|
||||
if (contextDescriptor != null) services.Remove(contextDescriptor);
|
||||
|
||||
var dbPath = Path.Combine(_tempDir, "users.db");
|
||||
services.AddDbContext<UsersContext>(options =>
|
||||
{
|
||||
options.UseSqlite($"Data Source={dbPath}");
|
||||
});
|
||||
|
||||
// Replace IOidcAuthService with mock
|
||||
var oidcDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IOidcAuthService));
|
||||
if (oidcDescriptor != null) services.Remove(oidcDescriptor);
|
||||
|
||||
services.AddSingleton<IOidcAuthService, MockOidcAuthService>();
|
||||
|
||||
// Remove all hosted services (Quartz scheduler, BackgroundJobManager) to prevent
|
||||
// Quartz.Logging.LogProvider.ResolvedLogProvider (a cached Lazy<T>) from being accessed
|
||||
// with a disposed ILoggerFactory from the previous factory lifecycle.
|
||||
// Auth tests don't depend on background job scheduling, so this is safe.
|
||||
foreach (var hostedService in services.Where(d => d.ServiceType == typeof(IHostedService)).ToList())
|
||||
services.Remove(hostedService);
|
||||
|
||||
// Ensure DB is created using a minimal isolated context (not the full app DI container)
|
||||
// to avoid any residual static state contamination.
|
||||
using var db = new UsersContext(
|
||||
new DbContextOptionsBuilder<UsersContext>()
|
||||
.UseSqlite($"Data Source={dbPath}")
|
||||
.Options);
|
||||
db.Database.EnsureCreated();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enables OIDC on the user in the UsersContext database.
|
||||
/// </summary>
|
||||
public async Task EnableOidcAsync()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
user.Oidc = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "https://mock-oidc-provider.test",
|
||||
ClientId = "test-client",
|
||||
ClientSecret = "test-secret",
|
||||
Scopes = "openid profile email",
|
||||
AuthorizedSubject = MockOidcAuthService.AuthorizedSubject,
|
||||
ProviderName = "TestProvider"
|
||||
};
|
||||
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task SetOidcIssuerUrlAsync(string issuerUrl)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is not null)
|
||||
{
|
||||
user.Oidc.IssuerUrl = issuerUrl;
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetOidcAuthorizedSubjectAsync(string subject)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is not null)
|
||||
{
|
||||
user.Oidc.AuthorizedSubject = subject;
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetOidcExclusiveModeAsync(bool enabled)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is not null)
|
||||
{
|
||||
user.Oidc.ExclusiveMode = enabled;
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
if (disposing && Directory.Exists(_tempDir))
|
||||
{
|
||||
try { Directory.Delete(_tempDir, true); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock OIDC auth service that simulates IdP behavior without network calls.
|
||||
/// </summary>
|
||||
private sealed class MockOidcAuthService : IOidcAuthService
|
||||
{
|
||||
public const string ValidState = "mock-valid-state";
|
||||
public const string WrongSubjectState = "mock-wrong-subject-state";
|
||||
public const string AuthorizedSubject = "mock-authorized-subject-123";
|
||||
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, OidcTokenExchangeResult> _oneTimeCodes = new();
|
||||
|
||||
public Task<OidcAuthorizationResult> StartAuthorization(string redirectUri, string? initiatorUserId = null)
|
||||
{
|
||||
return Task.FromResult(new OidcAuthorizationResult
|
||||
{
|
||||
AuthorizationUrl = $"https://mock-oidc-provider.test/authorize?redirect_uri={Uri.EscapeDataString(redirectUri)}&state={ValidState}",
|
||||
State = ValidState
|
||||
});
|
||||
}
|
||||
|
||||
public Task<OidcCallbackResult> HandleCallback(string code, string state, string redirectUri)
|
||||
{
|
||||
if (state == ValidState)
|
||||
{
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = true,
|
||||
Subject = AuthorizedSubject,
|
||||
PreferredUsername = "testuser",
|
||||
Email = "testuser@example.com"
|
||||
});
|
||||
}
|
||||
|
||||
if (state == WrongSubjectState)
|
||||
{
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = true,
|
||||
Subject = "wrong-subject-that-doesnt-match",
|
||||
PreferredUsername = "wronguser",
|
||||
Email = "wrong@example.com"
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Invalid or expired OIDC state"
|
||||
});
|
||||
}
|
||||
|
||||
public string StoreOneTimeCode(string accessToken, string refreshToken, int expiresIn)
|
||||
{
|
||||
var code = Guid.NewGuid().ToString("N");
|
||||
_oneTimeCodes.TryAdd(code, new OidcTokenExchangeResult
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
RefreshToken = refreshToken,
|
||||
ExpiresIn = expiresIn
|
||||
});
|
||||
return code;
|
||||
}
|
||||
|
||||
public OidcTokenExchangeResult? ExchangeOneTimeCode(string code)
|
||||
{
|
||||
return _oneTimeCodes.TryRemove(code, out var result) ? result : null;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
using Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.General.Contracts.Requests;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
@@ -314,4 +317,65 @@ public class SensitiveDataInputTests
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateOidcConfigRequest — UPDATE
|
||||
|
||||
[Fact]
|
||||
public void UpdateOidcConfigRequest_ApplyTo_WithPlaceholderClientSecret_PreservesExistingValue()
|
||||
{
|
||||
var request = new UpdateOidcConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "http://localhost:8080/realms/test",
|
||||
ClientId = "cleanuparr",
|
||||
ClientSecret = Placeholder,
|
||||
Scopes = "openid profile email",
|
||||
ProviderName = "Keycloak",
|
||||
};
|
||||
|
||||
var existingConfig = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "http://localhost:8080/realms/test",
|
||||
ClientId = "cleanuparr",
|
||||
ClientSecret = "original-secret",
|
||||
Scopes = "openid profile email",
|
||||
ProviderName = "OIDC",
|
||||
};
|
||||
|
||||
request.ApplyTo(existingConfig);
|
||||
|
||||
existingConfig.ClientSecret.ShouldBe("original-secret");
|
||||
existingConfig.ProviderName.ShouldBe("Keycloak");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateOidcConfigRequest_ApplyTo_WithRealClientSecret_UpdatesValue()
|
||||
{
|
||||
var request = new UpdateOidcConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "http://localhost:8080/realms/test",
|
||||
ClientId = "cleanuparr",
|
||||
ClientSecret = "brand-new-secret",
|
||||
Scopes = "openid profile email",
|
||||
ProviderName = "Keycloak",
|
||||
};
|
||||
|
||||
var existingConfig = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "http://localhost:8080/realms/test",
|
||||
ClientId = "cleanuparr",
|
||||
ClientSecret = "original-secret",
|
||||
Scopes = "openid profile email",
|
||||
ProviderName = "OIDC",
|
||||
};
|
||||
|
||||
request.ApplyTo(existingConfig);
|
||||
|
||||
existingConfig.ClientSecret.ShouldBe("brand-new-secret");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
9
code/backend/Cleanuparr.Api.Tests/TestCollections.cs
Normal file
9
code/backend/Cleanuparr.Api.Tests/TestCollections.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace Cleanuparr.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Auth integration tests share the file-system config directory (users.db via
|
||||
/// SetupGuardMiddleware.CreateStaticInstance). Grouping them in one collection
|
||||
/// forces sequential execution and prevents inter-factory interference.
|
||||
/// </summary>
|
||||
[CollectionDefinition("Auth Integration Tests")]
|
||||
public class AuthIntegrationTestsCollection { }
|
||||
5
code/backend/Cleanuparr.Api.Tests/xunit.runner.json
Normal file
5
code/backend/Cleanuparr.Api.Tests/xunit.runner.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"parallelizeAssembly": false,
|
||||
"parallelizeTestCollections": false
|
||||
}
|
||||
@@ -94,6 +94,9 @@ public static class MainDI
|
||||
// Add HTTP client for Plex authentication
|
||||
services.AddHttpClient("PlexAuth");
|
||||
|
||||
// Add HTTP client for OIDC authentication
|
||||
services.AddHttpClient("OidcAuth");
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ public static class ServicesDI
|
||||
.AddSingleton<IPasswordService, PasswordService>()
|
||||
.AddSingleton<ITotpService, TotpService>()
|
||||
.AddScoped<IPlexAuthService, PlexAuthService>()
|
||||
.AddScoped<IOidcAuthService, OidcAuthService>()
|
||||
.AddScoped<IEventPublisher, EventPublisher>()
|
||||
.AddHostedService<EventCleanupService>()
|
||||
.AddScoped<IDryRunInterceptor, DryRunInterceptor>()
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using Cleanuparr.Infrastructure.Extensions;
|
||||
|
||||
namespace Cleanuparr.Api.Extensions;
|
||||
|
||||
public static class HttpRequestExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the request PathBase as a safe relative path.
|
||||
/// Rejects absolute URLs (e.g. "://" or "//") to prevent open redirect attacks.
|
||||
/// </summary>
|
||||
public static string GetSafeBasePath(this HttpRequest request)
|
||||
{
|
||||
var basePath = request.PathBase.Value?.TrimEnd('/') ?? "";
|
||||
if (basePath.Contains("://") || basePath.StartsWith("//"))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
return basePath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the external base URL (scheme + host + basePath), respecting
|
||||
/// X-Forwarded-Proto and X-Forwarded-Host headers when the connection
|
||||
/// originates from a local address.
|
||||
/// </summary>
|
||||
public static string GetExternalBaseUrl(this HttpContext context)
|
||||
{
|
||||
var request = context.Request;
|
||||
var scheme = request.Scheme;
|
||||
var host = request.Host.ToString();
|
||||
var remoteIp = context.Connection.RemoteIpAddress;
|
||||
|
||||
// Trust forwarded headers only from local connections
|
||||
// (consistent with TrustedNetworkAuthenticationHandler)
|
||||
if (remoteIp is not null && remoteIp.IsLocalAddress())
|
||||
{
|
||||
scheme = request.Headers["X-Forwarded-Proto"].FirstOrDefault() ?? scheme;
|
||||
host = request.Headers["X-Forwarded-Host"].FirstOrDefault() ?? host;
|
||||
}
|
||||
|
||||
var basePath = request.GetSafeBasePath();
|
||||
return $"{scheme}://{host}{basePath}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record OidcExchangeRequest
|
||||
{
|
||||
[Required]
|
||||
public required string Code { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record UpdateOidcConfigRequest
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public string IssuerUrl { get; init; } = string.Empty;
|
||||
|
||||
public string ClientId { get; init; } = string.Empty;
|
||||
|
||||
public string ClientSecret { get; init; } = string.Empty;
|
||||
|
||||
public string Scopes { get; init; } = "openid profile email";
|
||||
|
||||
public string ProviderName { get; init; } = "OIDC";
|
||||
|
||||
public string RedirectUrl { get; init; } = string.Empty;
|
||||
|
||||
public bool ExclusiveMode { get; init; }
|
||||
|
||||
public void ApplyTo(OidcConfig existingConfig)
|
||||
{
|
||||
var previousIssuerUrl = existingConfig.IssuerUrl;
|
||||
|
||||
existingConfig.Enabled = Enabled;
|
||||
existingConfig.IssuerUrl = IssuerUrl;
|
||||
existingConfig.ClientId = ClientId;
|
||||
existingConfig.Scopes = Scopes;
|
||||
existingConfig.ProviderName = ProviderName;
|
||||
existingConfig.RedirectUrl = RedirectUrl;
|
||||
existingConfig.ExclusiveMode = ExclusiveMode;
|
||||
|
||||
if (!ClientSecret.IsPlaceholder())
|
||||
{
|
||||
existingConfig.ClientSecret = ClientSecret;
|
||||
}
|
||||
|
||||
// AuthorizedSubject is intentionally NOT mapped here — it is set only via the OIDC link callback
|
||||
|
||||
if (previousIssuerUrl != IssuerUrl)
|
||||
{
|
||||
OidcAuthService.ClearDiscoveryCache();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,7 @@ public sealed record AuthStatusResponse
|
||||
public required bool SetupCompleted { get; init; }
|
||||
public bool PlexLinked { get; init; }
|
||||
public bool AuthBypassActive { get; init; }
|
||||
public bool OidcEnabled { get; init; }
|
||||
public string OidcProviderName { get; init; } = string.Empty;
|
||||
public bool OidcExclusiveMode { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
|
||||
public sealed record OidcStartResponse
|
||||
{
|
||||
public required string AuthorizationUrl { get; init; }
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using Cleanuparr.Api.Extensions;
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
using Cleanuparr.Api.Filters;
|
||||
@@ -9,6 +10,7 @@ using Cleanuparr.Persistence.Models.Auth;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Controllers;
|
||||
|
||||
@@ -22,6 +24,7 @@ public sealed class AccountController : ControllerBase
|
||||
private readonly IPasswordService _passwordService;
|
||||
private readonly ITotpService _totpService;
|
||||
private readonly IPlexAuthService _plexAuthService;
|
||||
private readonly IOidcAuthService _oidcAuthService;
|
||||
private readonly ILogger<AccountController> _logger;
|
||||
|
||||
public AccountController(
|
||||
@@ -29,12 +32,14 @@ public sealed class AccountController : ControllerBase
|
||||
IPasswordService passwordService,
|
||||
ITotpService totpService,
|
||||
IPlexAuthService plexAuthService,
|
||||
IOidcAuthService oidcAuthService,
|
||||
ILogger<AccountController> logger)
|
||||
{
|
||||
_usersContext = usersContext;
|
||||
_passwordService = passwordService;
|
||||
_totpService = totpService;
|
||||
_plexAuthService = plexAuthService;
|
||||
_oidcAuthService = oidcAuthService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -42,7 +47,10 @@ public sealed class AccountController : ControllerBase
|
||||
public async Task<IActionResult> GetAccountInfo()
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
return Ok(new AccountInfoResponse
|
||||
{
|
||||
@@ -57,247 +65,230 @@ public sealed class AccountController : ControllerBase
|
||||
[HttpPut("password")]
|
||||
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.CurrentPassword, user.PasswordHash))
|
||||
{
|
||||
return BadRequest(new { error = "Current password is incorrect" });
|
||||
}
|
||||
|
||||
DateTime now = DateTime.UtcNow;
|
||||
|
||||
user.PasswordHash = _passwordService.HashPassword(request.NewPassword);
|
||||
user.UpdatedAt = now;
|
||||
|
||||
// Revoke all existing refresh tokens so old sessions can't be reused
|
||||
var activeTokens = await _usersContext.RefreshTokens
|
||||
.Where(r => r.UserId == user.Id && r.RevokedAt == null)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var token in activeTokens)
|
||||
{
|
||||
token.RevokedAt = now;
|
||||
}
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Password changed for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "Password changed" });
|
||||
return StatusCode(403, new { error = "Password changes are disabled while OIDC exclusive mode is active." });
|
||||
}
|
||||
finally
|
||||
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.CurrentPassword, user.PasswordHash))
|
||||
{
|
||||
return BadRequest(new { error = "Current password is incorrect" });
|
||||
}
|
||||
|
||||
DateTime now = DateTime.UtcNow;
|
||||
|
||||
user.PasswordHash = _passwordService.HashPassword(request.NewPassword);
|
||||
user.UpdatedAt = now;
|
||||
|
||||
// Revoke all existing refresh tokens so old sessions can't be reused
|
||||
var activeTokens = await _usersContext.RefreshTokens
|
||||
.Where(r => r.UserId == user.Id && r.RevokedAt == null)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var token in activeTokens)
|
||||
{
|
||||
token.RevokedAt = now;
|
||||
}
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Password changed for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "Password changed" });
|
||||
}
|
||||
|
||||
[HttpPost("2fa/regenerate")]
|
||||
public async Task<IActionResult> Regenerate2fa([FromBody] Regenerate2faRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
var user = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (user is null)
|
||||
{
|
||||
var user = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (user is null) return Unauthorized();
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
// Verify current credentials
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
// Verify current credentials
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
return BadRequest(new { error = "Incorrect password" });
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid 2FA code" });
|
||||
}
|
||||
|
||||
// Generate new TOTP
|
||||
var secret = _totpService.GenerateSecret();
|
||||
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
|
||||
var recoveryCodes = _totpService.GenerateRecoveryCodes();
|
||||
|
||||
user.TotpSecret = secret;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Replace recovery codes
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
foreach (var code in recoveryCodes)
|
||||
{
|
||||
_usersContext.RecoveryCodes.Add(new RecoveryCode
|
||||
{
|
||||
return BadRequest(new { error = "Incorrect password" });
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid 2FA code" });
|
||||
}
|
||||
|
||||
// Generate new TOTP
|
||||
var secret = _totpService.GenerateSecret();
|
||||
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
|
||||
var recoveryCodes = _totpService.GenerateRecoveryCodes();
|
||||
|
||||
user.TotpSecret = secret;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Replace recovery codes
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
foreach (var code in recoveryCodes)
|
||||
{
|
||||
_usersContext.RecoveryCodes.Add(new RecoveryCode
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
CodeHash = _totpService.HashRecoveryCode(code),
|
||||
IsUsed = false
|
||||
});
|
||||
}
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA regenerated for user {Username}", user.Username);
|
||||
|
||||
return Ok(new TotpSetupResponse
|
||||
{
|
||||
Secret = secret,
|
||||
QrCodeUri = qrUri,
|
||||
RecoveryCodes = recoveryCodes
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
CodeHash = _totpService.HashRecoveryCode(code),
|
||||
IsUsed = false
|
||||
});
|
||||
}
|
||||
finally
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA regenerated for user {Username}", user.Username);
|
||||
|
||||
return Ok(new TotpSetupResponse
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
Secret = secret,
|
||||
QrCodeUri = qrUri,
|
||||
RecoveryCodes = recoveryCodes
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("2fa/enable")]
|
||||
public async Task<IActionResult> Enable2fa([FromBody] Enable2faRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
var user = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (user is null)
|
||||
{
|
||||
var user = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (user is null) return Unauthorized();
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (user.TotpEnabled)
|
||||
if (user.TotpEnabled)
|
||||
{
|
||||
return Conflict(new { error = "2FA is already enabled" });
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
return BadRequest(new { error = "Incorrect password" });
|
||||
}
|
||||
|
||||
// Generate new TOTP
|
||||
var secret = _totpService.GenerateSecret();
|
||||
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
|
||||
var recoveryCodes = _totpService.GenerateRecoveryCodes();
|
||||
|
||||
user.TotpSecret = secret;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Replace any existing recovery codes
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
foreach (var code in recoveryCodes)
|
||||
{
|
||||
_usersContext.RecoveryCodes.Add(new RecoveryCode
|
||||
{
|
||||
return Conflict(new { error = "2FA is already enabled" });
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
return BadRequest(new { error = "Incorrect password" });
|
||||
}
|
||||
|
||||
// Generate new TOTP
|
||||
var secret = _totpService.GenerateSecret();
|
||||
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
|
||||
var recoveryCodes = _totpService.GenerateRecoveryCodes();
|
||||
|
||||
user.TotpSecret = secret;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Replace any existing recovery codes
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
foreach (var code in recoveryCodes)
|
||||
{
|
||||
_usersContext.RecoveryCodes.Add(new RecoveryCode
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
CodeHash = _totpService.HashRecoveryCode(code),
|
||||
IsUsed = false
|
||||
});
|
||||
}
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA setup generated for user {Username}", user.Username);
|
||||
|
||||
return Ok(new TotpSetupResponse
|
||||
{
|
||||
Secret = secret,
|
||||
QrCodeUri = qrUri,
|
||||
RecoveryCodes = recoveryCodes
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
CodeHash = _totpService.HashRecoveryCode(code),
|
||||
IsUsed = false
|
||||
});
|
||||
}
|
||||
finally
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA setup generated for user {Username}", user.Username);
|
||||
|
||||
return Ok(new TotpSetupResponse
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
Secret = secret,
|
||||
QrCodeUri = qrUri,
|
||||
RecoveryCodes = recoveryCodes
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("2fa/enable/verify")]
|
||||
public async Task<IActionResult> VerifyEnable2fa([FromBody] VerifyTotpRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
if (user.TotpEnabled)
|
||||
{
|
||||
return Conflict(new { error = "2FA is already enabled" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(user.TotpSecret))
|
||||
{
|
||||
return BadRequest(new { error = "Generate 2FA setup first" });
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.Code))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid verification code" });
|
||||
}
|
||||
|
||||
user.TotpEnabled = true;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA enabled for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "2FA enabled" });
|
||||
return Unauthorized();
|
||||
}
|
||||
finally
|
||||
|
||||
if (user.TotpEnabled)
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
return Conflict(new { error = "2FA is already enabled" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(user.TotpSecret))
|
||||
{
|
||||
return BadRequest(new { error = "Generate 2FA setup first" });
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.Code))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid verification code" });
|
||||
}
|
||||
|
||||
user.TotpEnabled = true;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA enabled for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "2FA enabled" });
|
||||
}
|
||||
|
||||
[HttpPost("2fa/disable")]
|
||||
public async Task<IActionResult> Disable2fa([FromBody] Disable2faRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
var user = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (user is null)
|
||||
{
|
||||
var user = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
if (!user.TotpEnabled)
|
||||
{
|
||||
return BadRequest(new { error = "2FA is not enabled" });
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
return BadRequest(new { error = "Incorrect password" });
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid 2FA code" });
|
||||
}
|
||||
|
||||
user.TotpEnabled = false;
|
||||
user.TotpSecret = string.Empty;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Remove all recovery codes
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA disabled for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "2FA disabled" });
|
||||
return Unauthorized();
|
||||
}
|
||||
finally
|
||||
|
||||
if (!user.TotpEnabled)
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
return BadRequest(new { error = "2FA is not enabled" });
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
return BadRequest(new { error = "Incorrect password" });
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid 2FA code" });
|
||||
}
|
||||
|
||||
user.TotpEnabled = false;
|
||||
user.TotpSecret = string.Empty;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Remove all recovery codes
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA disabled for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "2FA disabled" });
|
||||
}
|
||||
|
||||
[HttpGet("api-key")]
|
||||
public async Task<IActionResult> GetApiKey()
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
return Ok(new { apiKey = user.ApiKey });
|
||||
}
|
||||
@@ -305,33 +296,33 @@ public sealed class AccountController : ControllerBase
|
||||
[HttpPost("api-key/regenerate")]
|
||||
public async Task<IActionResult> RegenerateApiKey()
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var bytes = new byte[32];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
rng.GetBytes(bytes);
|
||||
|
||||
user.ApiKey = Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("API key regenerated for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { apiKey = user.ApiKey });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var bytes = new byte[32];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
rng.GetBytes(bytes);
|
||||
|
||||
user.ApiKey = Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("API key regenerated for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { apiKey = user.ApiKey });
|
||||
}
|
||||
|
||||
[HttpPost("plex/link")]
|
||||
public async Task<IActionResult> StartPlexLink()
|
||||
{
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
return StatusCode(403, new { error = "Plex account management is disabled while OIDC exclusive mode is active." });
|
||||
}
|
||||
|
||||
var pin = await _plexAuthService.RequestPin();
|
||||
|
||||
return Ok(new { pinId = pin.PinId, authUrl = pin.AuthUrl });
|
||||
@@ -340,6 +331,11 @@ public sealed class AccountController : ControllerBase
|
||||
[HttpPost("plex/link/verify")]
|
||||
public async Task<IActionResult> VerifyPlexLink([FromBody] PlexPinRequest request)
|
||||
{
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
return StatusCode(403, new { error = "Plex account management is disabled while OIDC exclusive mode is active." });
|
||||
}
|
||||
|
||||
var pinResult = await _plexAuthService.CheckPin(request.PinId);
|
||||
|
||||
if (!pinResult.Completed || pinResult.AuthToken is null)
|
||||
@@ -349,23 +345,194 @@ public sealed class AccountController : ControllerBase
|
||||
|
||||
var plexAccount = await _plexAuthService.GetAccount(pinResult.AuthToken);
|
||||
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
user.PlexAccountId = plexAccount.AccountId;
|
||||
user.PlexUsername = plexAccount.Username;
|
||||
user.PlexEmail = plexAccount.Email;
|
||||
user.PlexAuthToken = pinResult.AuthToken;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Plex account linked for user {Username}: {PlexUsername}",
|
||||
user.Username, plexAccount.Username);
|
||||
|
||||
return Ok(new { completed = true, plexUsername = plexAccount.Username });
|
||||
}
|
||||
|
||||
[HttpDelete("plex/link")]
|
||||
public async Task<IActionResult> UnlinkPlex()
|
||||
{
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
return StatusCode(403, new { error = "Plex account management is disabled while OIDC exclusive mode is active." });
|
||||
}
|
||||
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
user.PlexAccountId = null;
|
||||
user.PlexUsername = null;
|
||||
user.PlexEmail = null;
|
||||
user.PlexAuthToken = null;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Plex account unlinked for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "Plex account unlinked" });
|
||||
}
|
||||
|
||||
[HttpGet("oidc")]
|
||||
public async Task<IActionResult> GetOidcConfig()
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
return Ok(user.Oidc);
|
||||
}
|
||||
|
||||
[HttpPut("oidc")]
|
||||
public async Task<IActionResult> UpdateOidcConfig([FromBody] UpdateOidcConfigRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
request.ApplyTo(user.Oidc);
|
||||
user.Oidc.Validate();
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
return Ok(new { message = "OIDC configuration updated" });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("oidc/link")]
|
||||
public async Task<IActionResult> StartOidcLink()
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (user.Oidc is not { Enabled: true })
|
||||
{
|
||||
return BadRequest(new { error = "OIDC is not enabled" });
|
||||
}
|
||||
|
||||
var redirectUri = GetOidcLinkCallbackUrl(user.Oidc.RedirectUrl);
|
||||
_logger.LogDebug("OIDC link start: using redirect URI {RedirectUri}", redirectUri);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _oidcAuthService.StartAuthorization(redirectUri, user.Id.ToString());
|
||||
return Ok(new OidcStartResponse { AuthorizationUrl = result.AuthorizationUrl });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to start OIDC link authorization");
|
||||
return StatusCode(429, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// This endpoint must be [AllowAnonymous] because the IdP redirects the user's browser here
|
||||
/// without a Bearer token. Security is ensured by validating that the OIDC flow was initiated
|
||||
/// by an authenticated user (InitiatorUserId stored in the flow state during StartOidcLink).
|
||||
/// </remarks>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("oidc/link/callback")]
|
||||
public async Task<IActionResult> OidcLinkCallback(
|
||||
[FromQuery] string? code,
|
||||
[FromQuery] string? state,
|
||||
[FromQuery] string? error)
|
||||
{
|
||||
var basePath = HttpContext.Request.GetSafeBasePath();
|
||||
|
||||
if (!string.IsNullOrEmpty(error) || string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
|
||||
{
|
||||
return Redirect($"{basePath}/settings/account?oidc_link_error=failed");
|
||||
}
|
||||
|
||||
// Fetch any user to get the configured redirect URL for the OIDC callback
|
||||
var oidcConfig = (await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync())?.Oidc;
|
||||
var redirectUri = GetOidcLinkCallbackUrl(oidcConfig?.RedirectUrl);
|
||||
_logger.LogDebug("OIDC link callback: using redirect URI {RedirectUri}", redirectUri);
|
||||
var result = await _oidcAuthService.HandleCallback(code, state, redirectUri);
|
||||
|
||||
if (!result.Success || string.IsNullOrEmpty(result.Subject))
|
||||
{
|
||||
_logger.LogWarning("OIDC link callback failed: {Error}", result.Error);
|
||||
return Redirect($"{basePath}/settings/account?oidc_link_error=failed");
|
||||
}
|
||||
|
||||
// Verify the flow was initiated by an authenticated user
|
||||
if (string.IsNullOrEmpty(result.InitiatorUserId) ||
|
||||
!Guid.TryParse(result.InitiatorUserId, out var initiatorId))
|
||||
{
|
||||
_logger.LogWarning("OIDC link callback missing initiator user ID");
|
||||
return Redirect($"{basePath}/settings/account?oidc_link_error=failed");
|
||||
}
|
||||
|
||||
// Save the authorized subject to the user's OIDC config
|
||||
var user = await _usersContext.Users.FirstOrDefaultAsync(u => u.Id == initiatorId);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
_logger.LogWarning("OIDC link callback initiator user not found: {UserId}", result.InitiatorUserId);
|
||||
return Redirect($"{basePath}/settings/account?oidc_link_error=failed");
|
||||
}
|
||||
|
||||
user.Oidc.AuthorizedSubject = result.Subject;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("OIDC account linked with subject: {Subject} by user: {Username}",
|
||||
result.Subject, user.Username);
|
||||
|
||||
return Redirect($"{basePath}/settings/account?oidc_link=success");
|
||||
}
|
||||
|
||||
[HttpDelete("oidc/link")]
|
||||
public async Task<IActionResult> UnlinkOidc()
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
user.PlexAccountId = plexAccount.AccountId;
|
||||
user.PlexUsername = plexAccount.Username;
|
||||
user.PlexEmail = plexAccount.Email;
|
||||
user.PlexAuthToken = pinResult.AuthToken;
|
||||
user.Oidc.AuthorizedSubject = string.Empty;
|
||||
user.Oidc.ExclusiveMode = false;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Plex account linked for user {Username}: {PlexUsername}",
|
||||
user.Username, plexAccount.Username);
|
||||
_logger.LogInformation("OIDC account unlinked for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { completed = true, plexUsername = plexAccount.Username });
|
||||
return Ok(new { message = "OIDC account unlinked" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -373,30 +540,24 @@ public sealed class AccountController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("plex/link")]
|
||||
public async Task<IActionResult> UnlinkPlex()
|
||||
private string GetOidcLinkCallbackUrl(string? redirectUrl = null)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
var baseUrl = string.IsNullOrEmpty(redirectUrl)
|
||||
? HttpContext.GetExternalBaseUrl()
|
||||
: redirectUrl.TrimEnd('/');
|
||||
return $"{baseUrl}/api/account/oidc/link/callback";
|
||||
}
|
||||
|
||||
private async Task<bool> IsOidcExclusiveModeActive()
|
||||
{
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
if (user is not { SetupCompleted: true })
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
user.PlexAccountId = null;
|
||||
user.PlexUsername = null;
|
||||
user.PlexEmail = null;
|
||||
user.PlexAuthToken = null;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Plex account unlinked for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "Plex account unlinked" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
return false;
|
||||
}
|
||||
|
||||
var oidc = user.Oidc;
|
||||
return oidc is { Enabled: true, ExclusiveMode: true };
|
||||
}
|
||||
|
||||
private async Task<User?> GetCurrentUser(bool includeRecoveryCodes = false)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Security.Cryptography;
|
||||
using Cleanuparr.Api.Auth;
|
||||
using Cleanuparr.Api.Extensions;
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
using Cleanuparr.Api.Filters;
|
||||
@@ -19,25 +20,31 @@ namespace Cleanuparr.Api.Features.Auth.Controllers;
|
||||
public sealed class AuthController : ControllerBase
|
||||
{
|
||||
private readonly UsersContext _usersContext;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IJwtService _jwtService;
|
||||
private readonly IPasswordService _passwordService;
|
||||
private readonly ITotpService _totpService;
|
||||
private readonly IPlexAuthService _plexAuthService;
|
||||
private readonly IOidcAuthService _oidcAuthService;
|
||||
private readonly ILogger<AuthController> _logger;
|
||||
|
||||
public AuthController(
|
||||
UsersContext usersContext,
|
||||
DataContext dataContext,
|
||||
IJwtService jwtService,
|
||||
IPasswordService passwordService,
|
||||
ITotpService totpService,
|
||||
IPlexAuthService plexAuthService,
|
||||
IOidcAuthService oidcAuthService,
|
||||
ILogger<AuthController> logger)
|
||||
{
|
||||
_usersContext = usersContext;
|
||||
_dataContext = dataContext;
|
||||
_jwtService = jwtService;
|
||||
_passwordService = passwordService;
|
||||
_totpService = totpService;
|
||||
_plexAuthService = plexAuthService;
|
||||
_oidcAuthService = oidcAuthService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -47,8 +54,7 @@ public sealed class AuthController : ControllerBase
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
|
||||
var authBypass = false;
|
||||
await using var dataContext = DataContext.CreateStaticInstance();
|
||||
var generalConfig = await dataContext.GeneralConfigs.AsNoTracking().FirstOrDefaultAsync();
|
||||
var generalConfig = await _dataContext.GeneralConfigs.AsNoTracking().FirstOrDefaultAsync();
|
||||
if (generalConfig is { Auth.DisableAuthForLocalAddresses: true })
|
||||
{
|
||||
var clientIp = TrustedNetworkAuthenticationHandler.ResolveClientIp(
|
||||
@@ -60,11 +66,21 @@ public sealed class AuthController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
var oidcConfig = user?.Oidc;
|
||||
var oidcEnabled = oidcConfig is { Enabled: true } &&
|
||||
!string.IsNullOrEmpty(oidcConfig.IssuerUrl) &&
|
||||
!string.IsNullOrEmpty(oidcConfig.ClientId);
|
||||
|
||||
var oidcExclusiveMode = oidcEnabled && oidcConfig!.ExclusiveMode;
|
||||
|
||||
return Ok(new AuthStatusResponse
|
||||
{
|
||||
SetupCompleted = user is { SetupCompleted: true },
|
||||
PlexLinked = user?.PlexAccountId is not null,
|
||||
AuthBypassActive = authBypass
|
||||
AuthBypassActive = authBypass,
|
||||
OidcEnabled = oidcEnabled,
|
||||
OidcProviderName = oidcEnabled ? oidcConfig!.ProviderName : string.Empty,
|
||||
OidcExclusiveMode = oidcExclusiveMode
|
||||
});
|
||||
}
|
||||
|
||||
@@ -241,6 +257,11 @@ public sealed class AuthController : ControllerBase
|
||||
[HttpPost("login")]
|
||||
public async Task<IActionResult> Login([FromBody] LoginRequest request)
|
||||
{
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
return StatusCode(403, new { error = "Login with credentials is disabled. Use OIDC to sign in." });
|
||||
}
|
||||
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
|
||||
if (user is null || !user.SetupCompleted)
|
||||
@@ -294,6 +315,11 @@ public sealed class AuthController : ControllerBase
|
||||
[HttpPost("login/2fa")]
|
||||
public async Task<IActionResult> VerifyTwoFactor([FromBody] TwoFactorRequest request)
|
||||
{
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
return StatusCode(403, new { error = "Login with credentials is disabled. Use OIDC to sign in." });
|
||||
}
|
||||
|
||||
var userId = _jwtService.ValidateLoginToken(request.LoginToken);
|
||||
if (userId is null)
|
||||
{
|
||||
@@ -455,6 +481,11 @@ public sealed class AuthController : ControllerBase
|
||||
[HttpPost("login/plex/pin")]
|
||||
public async Task<IActionResult> RequestPlexPin()
|
||||
{
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
return StatusCode(403, new { error = "Plex login is disabled. Use OIDC to sign in." });
|
||||
}
|
||||
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
if (user is null || !user.SetupCompleted || user.PlexAccountId is null)
|
||||
{
|
||||
@@ -473,6 +504,11 @@ public sealed class AuthController : ControllerBase
|
||||
[HttpPost("login/plex/verify")]
|
||||
public async Task<IActionResult> VerifyPlexLogin([FromBody] PlexPinRequest request)
|
||||
{
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
return StatusCode(403, new { error = "Plex login is disabled. Use OIDC to sign in." });
|
||||
}
|
||||
|
||||
var user = await _usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is null || !user.SetupCompleted || user.PlexAccountId is null)
|
||||
{
|
||||
@@ -509,6 +545,119 @@ public sealed class AuthController : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("oidc/start")]
|
||||
public async Task<IActionResult> StartOidc()
|
||||
{
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
var oidcConfig = user?.Oidc;
|
||||
|
||||
if (oidcConfig is not { Enabled: true } ||
|
||||
string.IsNullOrEmpty(oidcConfig.IssuerUrl) ||
|
||||
string.IsNullOrEmpty(oidcConfig.ClientId))
|
||||
{
|
||||
return BadRequest(new { error = "OIDC is not enabled or not configured" });
|
||||
}
|
||||
|
||||
var redirectUri = GetOidcCallbackUrl(oidcConfig.RedirectUrl);
|
||||
_logger.LogDebug("OIDC login start: using redirect URI {RedirectUri}", redirectUri);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _oidcAuthService.StartAuthorization(redirectUri);
|
||||
return Ok(new OidcStartResponse { AuthorizationUrl = result.AuthorizationUrl });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to start OIDC authorization");
|
||||
return StatusCode(429, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("oidc/callback")]
|
||||
public async Task<IActionResult> OidcCallback(
|
||||
[FromQuery] string? code,
|
||||
[FromQuery] string? state,
|
||||
[FromQuery] string? error)
|
||||
{
|
||||
var basePath = HttpContext.Request.GetSafeBasePath();
|
||||
|
||||
// Handle IdP error responses
|
||||
if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
_logger.LogWarning("OIDC callback received error: {Error}", error);
|
||||
return Redirect($"{basePath}/auth/login?oidc_error=provider_error");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
|
||||
{
|
||||
return Redirect($"{basePath}/auth/login?oidc_error=invalid_request");
|
||||
}
|
||||
|
||||
// Load the user early so we can use the configured redirect URL
|
||||
var user = await _usersContext.Users.FirstOrDefaultAsync(u => u.SetupCompleted);
|
||||
if (user is null)
|
||||
{
|
||||
return Redirect($"{basePath}/auth/login?oidc_error=no_account");
|
||||
}
|
||||
|
||||
var redirectUri = GetOidcCallbackUrl(user.Oidc.RedirectUrl);
|
||||
_logger.LogDebug("OIDC login callback: using redirect URI {RedirectUri}", redirectUri);
|
||||
var result = await _oidcAuthService.HandleCallback(code, state, redirectUri);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
_logger.LogWarning("OIDC callback failed: {Error}", result.Error);
|
||||
return Redirect($"{basePath}/auth/login?oidc_error=authentication_failed");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(user.Oidc.AuthorizedSubject) &&
|
||||
result.Subject != user.Oidc.AuthorizedSubject)
|
||||
{
|
||||
_logger.LogWarning("OIDC subject mismatch. Expected: {Expected}, Got: {Got}",
|
||||
user.Oidc.AuthorizedSubject, result.Subject);
|
||||
return Redirect($"{basePath}/auth/login?oidc_error=unauthorized");
|
||||
}
|
||||
|
||||
var tokenResponse = await GenerateTokenResponse(user);
|
||||
|
||||
// Store tokens with a one-time code (never put tokens in the URL)
|
||||
var oneTimeCode = _oidcAuthService.StoreOneTimeCode(
|
||||
tokenResponse.AccessToken,
|
||||
tokenResponse.RefreshToken,
|
||||
tokenResponse.ExpiresIn);
|
||||
|
||||
_logger.LogInformation("User {Username} authenticated via OIDC (subject: {Subject})",
|
||||
user.Username, result.Subject);
|
||||
|
||||
return Redirect($"{basePath}/auth/oidc/callback?code={Uri.EscapeDataString(oneTimeCode)}");
|
||||
}
|
||||
|
||||
[HttpPost("oidc/exchange")]
|
||||
public IActionResult ExchangeOidcCode([FromBody] OidcExchangeRequest request)
|
||||
{
|
||||
var result = _oidcAuthService.ExchangeOneTimeCode(request.Code);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return NotFound(new { error = "Invalid or expired code" });
|
||||
}
|
||||
|
||||
return Ok(new TokenResponse
|
||||
{
|
||||
AccessToken = result.AccessToken,
|
||||
RefreshToken = result.RefreshToken,
|
||||
ExpiresIn = result.ExpiresIn
|
||||
});
|
||||
}
|
||||
|
||||
private string GetOidcCallbackUrl(string? redirectUrl = null)
|
||||
{
|
||||
var baseUrl = string.IsNullOrEmpty(redirectUrl)
|
||||
? HttpContext.GetExternalBaseUrl()
|
||||
: redirectUrl.TrimEnd('/');
|
||||
return $"{baseUrl}/api/auth/oidc/callback";
|
||||
}
|
||||
|
||||
private async Task<TokenResponse> GenerateTokenResponse(User user)
|
||||
{
|
||||
var accessToken = _jwtService.GenerateAccessToken(user);
|
||||
@@ -610,4 +759,16 @@ public sealed class AuthController : ControllerBase
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
|
||||
private async Task<bool> IsOidcExclusiveModeActive()
|
||||
{
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
if (user is not { SetupCompleted: true })
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var oidc = user.Oidc;
|
||||
return oidc is { Enabled: true, ExclusiveMode: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using System.Reflection;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Infrastructure.Logging;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@
|
||||
<PackageReference Include="Mapster" Version="7.4.0" />
|
||||
<PackageReference Include="MassTransit.Abstractions" Version="8.5.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.7.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.7.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.1" />
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
public sealed record OidcAuthorizationResult
|
||||
{
|
||||
public required string AuthorizationUrl { get; init; }
|
||||
public required string State { get; init; }
|
||||
}
|
||||
|
||||
public sealed record OidcCallbackResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public string? Subject { get; init; }
|
||||
public string? PreferredUsername { get; init; }
|
||||
public string? Email { get; init; }
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The user ID of the authenticated user who initiated this OIDC flow.
|
||||
/// Set when the flow is started from an authenticated context (e.g., account linking).
|
||||
/// Used to verify the callback is completing the correct user's flow.
|
||||
/// </summary>
|
||||
public string? InitiatorUserId { get; init; }
|
||||
}
|
||||
|
||||
public interface IOidcAuthService
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates the OIDC authorization URL and stores state/verifier for the callback.
|
||||
/// </summary>
|
||||
/// <param name="redirectUri">The callback URI for the OIDC provider.</param>
|
||||
/// <param name="initiatorUserId">Optional user ID of the authenticated user initiating the flow (for account linking).</param>
|
||||
Task<OidcAuthorizationResult> StartAuthorization(string redirectUri, string? initiatorUserId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Handles the OIDC callback: validates state, exchanges code for tokens, validates the ID token.
|
||||
/// </summary>
|
||||
Task<OidcCallbackResult> HandleCallback(string code, string state, string redirectUri);
|
||||
|
||||
/// <summary>
|
||||
/// Stores tokens associated with a one-time exchange code.
|
||||
/// Returns the one-time code.
|
||||
/// </summary>
|
||||
string StoreOneTimeCode(string accessToken, string refreshToken, int expiresIn);
|
||||
|
||||
/// <summary>
|
||||
/// Exchanges a one-time code for the stored tokens.
|
||||
/// The code is consumed (can only be used once).
|
||||
/// </summary>
|
||||
OidcTokenExchangeResult? ExchangeOneTimeCode(string code);
|
||||
}
|
||||
|
||||
public sealed record OidcTokenExchangeResult
|
||||
{
|
||||
public required string AccessToken { get; init; }
|
||||
public required string RefreshToken { get; init; }
|
||||
public required int ExpiresIn { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IdentityModel.Protocols;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
public sealed class OidcAuthService : IOidcAuthService
|
||||
{
|
||||
private const int MaxPendingFlows = 100;
|
||||
private const int MaxOneTimeCodes = 100;
|
||||
private static readonly TimeSpan FlowStateExpiry = TimeSpan.FromMinutes(10);
|
||||
private static readonly TimeSpan OneTimeCodeExpiry = TimeSpan.FromSeconds(30);
|
||||
|
||||
private static readonly ConcurrentDictionary<string, OidcFlowState> PendingFlows = new();
|
||||
private static readonly ConcurrentDictionary<string, OidcOneTimeCodeEntry> OneTimeCodes = new();
|
||||
private static readonly ConcurrentDictionary<string, ConfigurationManager<OpenIdConnectConfiguration>> ConfigManagers = new();
|
||||
|
||||
// Reference held to prevent GC collection; the timer fires CleanupExpiredEntries every minute
|
||||
#pragma warning disable IDE0052
|
||||
private static readonly Timer CleanupTimer = new(CleanupExpiredEntries, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
|
||||
#pragma warning restore IDE0052
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly UsersContext _usersContext;
|
||||
private readonly ILogger<OidcAuthService> _logger;
|
||||
|
||||
public OidcAuthService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
UsersContext usersContext,
|
||||
ILogger<OidcAuthService> logger)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient("OidcAuth");
|
||||
_usersContext = usersContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<OidcAuthorizationResult> StartAuthorization(string redirectUri, string? initiatorUserId = null)
|
||||
{
|
||||
var oidcConfig = await GetOidcConfig();
|
||||
|
||||
if (!oidcConfig.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("OIDC is not enabled");
|
||||
}
|
||||
|
||||
if (PendingFlows.Count >= MaxPendingFlows)
|
||||
{
|
||||
throw new InvalidOperationException("Too many pending OIDC flows. Please try again later.");
|
||||
}
|
||||
|
||||
var discovery = await GetDiscoveryDocument(oidcConfig.IssuerUrl);
|
||||
|
||||
var state = GenerateRandomString();
|
||||
var nonce = GenerateRandomString();
|
||||
var codeVerifier = GenerateRandomString();
|
||||
var codeChallenge = ComputeCodeChallenge(codeVerifier);
|
||||
|
||||
var flowState = new OidcFlowState
|
||||
{
|
||||
State = state,
|
||||
Nonce = nonce,
|
||||
CodeVerifier = codeVerifier,
|
||||
RedirectUri = redirectUri,
|
||||
InitiatorUserId = initiatorUserId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
if (!PendingFlows.TryAdd(state, flowState))
|
||||
{
|
||||
throw new InvalidOperationException("Failed to store OIDC flow state");
|
||||
}
|
||||
|
||||
var authUrl = BuildAuthorizationUrl(
|
||||
discovery.AuthorizationEndpoint,
|
||||
oidcConfig.ClientId,
|
||||
redirectUri,
|
||||
oidcConfig.Scopes,
|
||||
state,
|
||||
nonce,
|
||||
codeChallenge);
|
||||
|
||||
_logger.LogDebug("OIDC authorization started with state {State}", state);
|
||||
|
||||
return new OidcAuthorizationResult
|
||||
{
|
||||
AuthorizationUrl = authUrl,
|
||||
State = state
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<OidcCallbackResult> HandleCallback(string code, string state, string redirectUri)
|
||||
{
|
||||
if (!PendingFlows.TryGetValue(state, out var flowState))
|
||||
{
|
||||
_logger.LogWarning("OIDC callback with invalid or expired state: {State}", state);
|
||||
return new OidcCallbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Invalid or expired OIDC state"
|
||||
};
|
||||
}
|
||||
|
||||
if (DateTime.UtcNow - flowState.CreatedAt > FlowStateExpiry)
|
||||
{
|
||||
PendingFlows.TryRemove(state, out _);
|
||||
_logger.LogWarning("OIDC flow state expired for state: {State}", state);
|
||||
return new OidcCallbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "OIDC flow has expired"
|
||||
};
|
||||
}
|
||||
|
||||
if (flowState.RedirectUri != redirectUri)
|
||||
{
|
||||
_logger.LogWarning("OIDC callback redirect URI mismatch. Expected: {Expected}, Got: {Got}",
|
||||
flowState.RedirectUri, redirectUri);
|
||||
return new OidcCallbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Redirect URI mismatch"
|
||||
};
|
||||
}
|
||||
|
||||
// Validation passed — consume the state
|
||||
PendingFlows.TryRemove(state, out _);
|
||||
|
||||
var oidcConfig = await GetOidcConfig();
|
||||
var discovery = await GetDiscoveryDocument(oidcConfig.IssuerUrl);
|
||||
|
||||
// Exchange authorization code for tokens
|
||||
var tokenResponse = await ExchangeCodeForTokens(
|
||||
discovery.TokenEndpoint,
|
||||
code,
|
||||
flowState.CodeVerifier,
|
||||
redirectUri,
|
||||
oidcConfig.ClientId,
|
||||
oidcConfig.ClientSecret);
|
||||
|
||||
if (tokenResponse is null)
|
||||
{
|
||||
return new OidcCallbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Failed to exchange authorization code"
|
||||
};
|
||||
}
|
||||
|
||||
// Validate the ID token
|
||||
var validatedToken = await ValidateIdToken(
|
||||
tokenResponse.IdToken,
|
||||
oidcConfig,
|
||||
discovery,
|
||||
flowState.Nonce);
|
||||
|
||||
if (validatedToken is null)
|
||||
{
|
||||
return new OidcCallbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "ID token validation failed"
|
||||
};
|
||||
}
|
||||
|
||||
var subject = validatedToken.Claims.FirstOrDefault(c => c.Type == "sub")?.Value;
|
||||
var preferredUsername = validatedToken.Claims.FirstOrDefault(c => c.Type == "preferred_username")?.Value;
|
||||
var email = validatedToken.Claims.FirstOrDefault(c => c.Type == "email")?.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(subject))
|
||||
{
|
||||
return new OidcCallbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "ID token missing 'sub' claim"
|
||||
};
|
||||
}
|
||||
|
||||
_logger.LogInformation("OIDC authentication successful for subject: {Subject}", subject);
|
||||
|
||||
return new OidcCallbackResult
|
||||
{
|
||||
Success = true,
|
||||
Subject = subject,
|
||||
PreferredUsername = preferredUsername,
|
||||
Email = email,
|
||||
InitiatorUserId = flowState.InitiatorUserId
|
||||
};
|
||||
}
|
||||
|
||||
public string StoreOneTimeCode(string accessToken, string refreshToken, int expiresIn)
|
||||
{
|
||||
// Clean up if at capacity
|
||||
if (OneTimeCodes.Count >= MaxOneTimeCodes)
|
||||
{
|
||||
CleanupExpiredOneTimeCodes();
|
||||
|
||||
// If still at capacity after cleanup, evict oldest entries
|
||||
while (OneTimeCodes.Count >= MaxOneTimeCodes)
|
||||
{
|
||||
var oldest = OneTimeCodes.OrderBy(x => x.Value.CreatedAt).FirstOrDefault();
|
||||
if (oldest.Key is not null)
|
||||
{
|
||||
OneTimeCodes.TryRemove(oldest.Key, out _);
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var entry = new OidcOneTimeCodeEntry
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
RefreshToken = refreshToken,
|
||||
ExpiresIn = expiresIn,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// Retry with new codes on collision
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var code = GenerateRandomString();
|
||||
if (OneTimeCodes.TryAdd(code, entry))
|
||||
{
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Failed to generate a unique one-time code");
|
||||
}
|
||||
|
||||
public OidcTokenExchangeResult? ExchangeOneTimeCode(string code)
|
||||
{
|
||||
if (!OneTimeCodes.TryRemove(code, out var entry))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (DateTime.UtcNow - entry.CreatedAt > OneTimeCodeExpiry)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new OidcTokenExchangeResult
|
||||
{
|
||||
AccessToken = entry.AccessToken,
|
||||
RefreshToken = entry.RefreshToken,
|
||||
ExpiresIn = entry.ExpiresIn
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<OidcConfig> GetOidcConfig()
|
||||
{
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
return user?.Oidc ?? new OidcConfig();
|
||||
}
|
||||
|
||||
private async Task<OpenIdConnectConfiguration> GetDiscoveryDocument(string issuerUrl)
|
||||
{
|
||||
var metadataAddress = issuerUrl.TrimEnd('/') + "/.well-known/openid-configuration";
|
||||
|
||||
var configManager = ConfigManagers.GetOrAdd(issuerUrl, _ =>
|
||||
{
|
||||
var isLocalhost = Uri.TryCreate(issuerUrl, UriKind.Absolute, out var uri) &&
|
||||
uri.Host is "localhost" or "127.0.0.1" or "::1" or "[::1]";
|
||||
return new ConfigurationManager<OpenIdConnectConfiguration>(
|
||||
metadataAddress,
|
||||
new OpenIdConnectConfigurationRetriever(),
|
||||
new HttpDocumentRetriever(_httpClient) { RequireHttps = !isLocalhost });
|
||||
});
|
||||
|
||||
return await configManager.GetConfigurationAsync();
|
||||
}
|
||||
|
||||
private async Task<OidcTokenResponse?> ExchangeCodeForTokens(
|
||||
string tokenEndpoint,
|
||||
string code,
|
||||
string codeVerifier,
|
||||
string redirectUri,
|
||||
string clientId,
|
||||
string clientSecret)
|
||||
{
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "authorization_code",
|
||||
["code"] = code,
|
||||
["redirect_uri"] = redirectUri,
|
||||
["client_id"] = clientId,
|
||||
["code_verifier"] = codeVerifier
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(clientSecret))
|
||||
{
|
||||
parameters["client_secret"] = clientSecret;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint)
|
||||
{
|
||||
Content = new FormUrlEncodedContent(parameters)
|
||||
};
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogError("OIDC token exchange failed with status {Status}: {Body}",
|
||||
response.StatusCode, errorBody);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<OidcTokenResponse>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "OIDC token exchange failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<JwtSecurityToken?> ValidateIdToken(
|
||||
string idToken,
|
||||
OidcConfig oidcConfig,
|
||||
OpenIdConnectConfiguration discovery,
|
||||
string expectedNonce)
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
|
||||
var validationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuers = new[]
|
||||
{
|
||||
oidcConfig.IssuerUrl.TrimEnd('/'),
|
||||
oidcConfig.IssuerUrl.TrimEnd('/') + "/"
|
||||
},
|
||||
ValidateAudience = true,
|
||||
ValidAudience = oidcConfig.ClientId,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
// Bypass lifetime validation
|
||||
IssuerSigningKeyValidator = (_, _, _) => true,
|
||||
IssuerSigningKeys = discovery.SigningKeys,
|
||||
ClockSkew = TimeSpan.FromMinutes(2)
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
handler.ValidateToken(idToken, validationParameters, out var validatedSecurityToken);
|
||||
var jwtToken = (JwtSecurityToken)validatedSecurityToken;
|
||||
|
||||
return ValidateNonce(jwtToken, expectedNonce) ? jwtToken : null;
|
||||
}
|
||||
catch (SecurityTokenSignatureKeyNotFoundException)
|
||||
{
|
||||
// Try refreshing the configuration (JWKS key rotation)
|
||||
_logger.LogInformation("OIDC signing key not found, refreshing configuration");
|
||||
|
||||
if (ConfigManagers.TryGetValue(oidcConfig.IssuerUrl, out var configManager))
|
||||
{
|
||||
configManager.RequestRefresh();
|
||||
var refreshedConfig = await configManager.GetConfigurationAsync();
|
||||
validationParameters.IssuerSigningKeys = refreshedConfig.SigningKeys;
|
||||
|
||||
try
|
||||
{
|
||||
handler.ValidateToken(idToken, validationParameters, out var retryToken);
|
||||
var jwtRetryToken = (JwtSecurityToken)retryToken;
|
||||
|
||||
return ValidateNonce(jwtRetryToken, expectedNonce) ? jwtRetryToken : null;
|
||||
}
|
||||
catch (Exception retryEx)
|
||||
{
|
||||
_logger.LogError(retryEx, "OIDC ID token validation failed after key refresh");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "OIDC ID token validation failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildAuthorizationUrl(
|
||||
string authorizationEndpoint,
|
||||
string clientId,
|
||||
string redirectUri,
|
||||
string scopes,
|
||||
string state,
|
||||
string nonce,
|
||||
string codeChallenge)
|
||||
{
|
||||
var queryParams = new Dictionary<string, string>
|
||||
{
|
||||
["response_type"] = "code",
|
||||
["client_id"] = clientId,
|
||||
["redirect_uri"] = redirectUri,
|
||||
["scope"] = scopes,
|
||||
["state"] = state,
|
||||
["nonce"] = nonce,
|
||||
["code_challenge"] = codeChallenge,
|
||||
["code_challenge_method"] = "S256"
|
||||
};
|
||||
|
||||
var queryString = string.Join("&",
|
||||
queryParams.Select(kvp => $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value)}"));
|
||||
|
||||
return $"{authorizationEndpoint}?{queryString}";
|
||||
}
|
||||
|
||||
private bool ValidateNonce(JwtSecurityToken jwtToken, string expectedNonce)
|
||||
{
|
||||
var tokenNonce = jwtToken.Claims.FirstOrDefault(c => c.Type == "nonce")?.Value;
|
||||
if (tokenNonce == expectedNonce) return true;
|
||||
|
||||
_logger.LogWarning("OIDC ID token nonce mismatch. Expected: {Expected}, Got: {Got}",
|
||||
expectedNonce, tokenNonce);
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string GenerateRandomString()
|
||||
{
|
||||
var bytes = new byte[32];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
rng.GetBytes(bytes);
|
||||
return Base64UrlEncode(bytes);
|
||||
}
|
||||
|
||||
private static string ComputeCodeChallenge(string codeVerifier)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.ASCII.GetBytes(codeVerifier));
|
||||
return Base64UrlEncode(bytes);
|
||||
}
|
||||
|
||||
private static string Base64UrlEncode(byte[] bytes)
|
||||
{
|
||||
return Convert.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
|
||||
private static void CleanupExpiredEntries(object? state)
|
||||
{
|
||||
var flowCutoff = DateTime.UtcNow - FlowStateExpiry;
|
||||
foreach (var kvp in PendingFlows)
|
||||
{
|
||||
if (kvp.Value.CreatedAt < flowCutoff)
|
||||
{
|
||||
PendingFlows.TryRemove(kvp.Key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
CleanupExpiredOneTimeCodes();
|
||||
}
|
||||
|
||||
private static void CleanupExpiredOneTimeCodes()
|
||||
{
|
||||
var codeCutoff = DateTime.UtcNow - OneTimeCodeExpiry;
|
||||
foreach (var kvp in OneTimeCodes)
|
||||
{
|
||||
if (kvp.Value.CreatedAt < codeCutoff)
|
||||
{
|
||||
OneTimeCodes.TryRemove(kvp.Key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the cached OIDC discovery configuration. Used when issuer URL changes.
|
||||
/// </summary>
|
||||
public static void ClearDiscoveryCache()
|
||||
{
|
||||
ConfigManagers.Clear();
|
||||
}
|
||||
|
||||
private sealed class OidcFlowState
|
||||
{
|
||||
public required string State { get; init; }
|
||||
public required string Nonce { get; init; }
|
||||
public required string CodeVerifier { get; init; }
|
||||
public required string RedirectUri { get; init; }
|
||||
public string? InitiatorUserId { get; init; }
|
||||
public required DateTime CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
private sealed class OidcOneTimeCodeEntry
|
||||
{
|
||||
public required string AccessToken { get; init; }
|
||||
public required string RefreshToken { get; init; }
|
||||
public required int ExpiresIn { get; init; }
|
||||
public required DateTime CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
private sealed class OidcTokenResponse
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("id_token")]
|
||||
public string IdToken { get; set; } = string.Empty;
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("access_token")]
|
||||
public string AccessToken { get; set; } = string.Empty;
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("token_type")]
|
||||
public string TokenType { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Persistence.Tests.Models.Auth;
|
||||
|
||||
public sealed class OidcConfigTests
|
||||
{
|
||||
#region Validate - Disabled Config
|
||||
|
||||
[Fact]
|
||||
public void Validate_Disabled_WithEmptyFields_DoesNotThrow()
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = false,
|
||||
IssuerUrl = string.Empty,
|
||||
ClientId = string.Empty
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Disabled_WithPopulatedFields_DoesNotThrow()
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = false,
|
||||
IssuerUrl = "https://auth.example.com",
|
||||
ClientId = "my-client"
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate - Issuer URL
|
||||
|
||||
[Fact]
|
||||
public void Validate_Enabled_ValidHttpsIssuerUrl_DoesNotThrow()
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "https://auth.example.com/application/o/cleanuparr/",
|
||||
ClientId = "my-client",
|
||||
ProviderName = "Authentik"
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Validate_Enabled_EmptyIssuerUrl_ThrowsValidationException(string issuerUrl)
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = issuerUrl,
|
||||
ClientId = "my-client"
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldBe("OIDC Issuer URL is required when OIDC is enabled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Enabled_InvalidIssuerUrl_ThrowsValidationException()
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "not-a-valid-url",
|
||||
ClientId = "my-client"
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldBe("OIDC Issuer URL must be a valid absolute URL");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Enabled_HttpIssuerUrl_ThrowsValidationException()
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "http://auth.example.com",
|
||||
ClientId = "my-client"
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldBe("OIDC Issuer URL must use HTTPS");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("http://localhost:8080/auth")]
|
||||
[InlineData("http://127.0.0.1:9000/auth")]
|
||||
[InlineData("http://[::1]:9000/auth")]
|
||||
public void Validate_Enabled_HttpLocalhostIssuerUrl_DoesNotThrow(string issuerUrl)
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = issuerUrl,
|
||||
ClientId = "my-client",
|
||||
ProviderName = "Dev"
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Enabled_IssuerUrlWithTrailingSlash_DoesNotThrow()
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "https://auth.example.com/",
|
||||
ClientId = "my-client",
|
||||
ProviderName = "Authentik"
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate - Client ID
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Validate_Enabled_EmptyClientId_ThrowsValidationException(string clientId)
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "https://auth.example.com",
|
||||
ClientId = clientId
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldBe("OIDC Client ID is required when OIDC is enabled");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate - Provider Name
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Validate_Enabled_EmptyProviderName_ThrowsValidationException(string providerName)
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "https://auth.example.com",
|
||||
ClientId = "my-client",
|
||||
ProviderName = providerName
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldBe("OIDC Provider Name is required when OIDC is enabled");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate - Full Valid Configs
|
||||
|
||||
[Fact]
|
||||
public void Validate_Enabled_ValidFullConfig_DoesNotThrow()
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "https://authentik.example.com/application/o/cleanuparr/",
|
||||
ClientId = "cleanuparr-client-id",
|
||||
ClientSecret = "my-secret",
|
||||
Scopes = "openid profile email",
|
||||
AuthorizedSubject = "user-123-abc",
|
||||
ProviderName = "Authentik"
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Enabled_WithoutClientSecret_DoesNotThrow()
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "https://auth.example.com",
|
||||
ClientId = "my-client",
|
||||
ClientSecret = string.Empty,
|
||||
ProviderName = "Keycloak"
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Default Values
|
||||
|
||||
[Fact]
|
||||
public void DefaultValues_AreCorrect()
|
||||
{
|
||||
var config = new OidcConfig();
|
||||
|
||||
config.Enabled.ShouldBeFalse();
|
||||
config.IssuerUrl.ShouldBe(string.Empty);
|
||||
config.ClientId.ShouldBe(string.Empty);
|
||||
config.ClientSecret.ShouldBe(string.Empty);
|
||||
config.Scopes.ShouldBe("openid profile email");
|
||||
config.AuthorizedSubject.ShouldBe(string.Empty);
|
||||
config.ProviderName.ShouldBe("OIDC");
|
||||
config.ExclusiveMode.ShouldBeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate - Exclusive Mode
|
||||
|
||||
[Fact]
|
||||
public void Validate_ExclusiveMode_WhenOidcDisabled_Throws()
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = false,
|
||||
ExclusiveMode = true,
|
||||
AuthorizedSubject = "some-subject"
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldBe("OIDC must be enabled to use exclusive mode");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ExclusiveMode_WithoutAuthorizedSubject_DoesNotThrow()
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
ExclusiveMode = true,
|
||||
IssuerUrl = "https://auth.example.com",
|
||||
ClientId = "my-client",
|
||||
ProviderName = "Test",
|
||||
AuthorizedSubject = string.Empty
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ExclusiveMode_FullyConfigured_DoesNotThrow()
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
ExclusiveMode = true,
|
||||
IssuerUrl = "https://auth.example.com",
|
||||
ClientId = "my-client",
|
||||
ProviderName = "Test",
|
||||
AuthorizedSubject = "user-123"
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ExclusiveModeFalse_OidcDisabled_DoesNotThrow()
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = false,
|
||||
ExclusiveMode = false
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Validate_ExclusiveMode_WhitespaceAuthorizedSubject_DoesNotThrow(string subject)
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
ExclusiveMode = true,
|
||||
IssuerUrl = "https://auth.example.com",
|
||||
ClientId = "my-client",
|
||||
ProviderName = "Test",
|
||||
AuthorizedSubject = subject
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Additional Edge Cases
|
||||
|
||||
[Fact]
|
||||
public void Validate_Enabled_HttpLocalhostWithoutPort_DoesNotThrow()
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "http://localhost/auth",
|
||||
ClientId = "my-client",
|
||||
ProviderName = "Dev"
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Enabled_ScopesWithoutOpenid_StillPasses()
|
||||
{
|
||||
// Documenting current behavior: the Validate method does not enforce "openid" in scopes.
|
||||
// This is intentional — the IdP will reject if openid is missing, giving a clear error.
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "https://auth.example.com",
|
||||
ClientId = "my-client",
|
||||
Scopes = "profile email",
|
||||
ProviderName = "Test"
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Enabled_FtpScheme_ThrowsValidationException()
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "ftp://auth.example.com",
|
||||
ClientId = "my-client"
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldBe("OIDC Issuer URL must use HTTPS");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
271
code/backend/Cleanuparr.Persistence/Migrations/Users/20260312090408_AddOidcSupport.Designer.cs
generated
Normal file
271
code/backend/Cleanuparr.Persistence/Migrations/Users/20260312090408_AddOidcSupport.Designer.cs
generated
Normal file
@@ -0,0 +1,271 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Users
|
||||
{
|
||||
[DbContext(typeof(UsersContext))]
|
||||
[Migration("20260312090408_AddOidcSupport")]
|
||||
partial class AddOidcSupport
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.1");
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.RecoveryCode", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CodeHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("code_hash");
|
||||
|
||||
b.Property<bool>("IsUsed")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_used");
|
||||
|
||||
b.Property<DateTime?>("UsedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("used_at");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_recovery_codes");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.HasDatabaseName("ix_recovery_codes_user_id");
|
||||
|
||||
b.ToTable("recovery_codes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.RefreshToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("expires_at");
|
||||
|
||||
b.Property<DateTime?>("RevokedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("revoked_at");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("token_hash");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_refresh_tokens");
|
||||
|
||||
b.HasIndex("TokenHash")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_refresh_tokens_token_hash");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.HasDatabaseName("ix_refresh_tokens_user_id");
|
||||
|
||||
b.ToTable("refresh_tokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ApiKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("api_key");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("FailedLoginAttempts")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("failed_login_attempts");
|
||||
|
||||
b.Property<DateTime?>("LockoutEnd")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("lockout_end");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("password_hash");
|
||||
|
||||
b.Property<string>("PlexAccountId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("plex_account_id");
|
||||
|
||||
b.Property<string>("PlexAuthToken")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("plex_auth_token");
|
||||
|
||||
b.Property<string>("PlexEmail")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("plex_email");
|
||||
|
||||
b.Property<string>("PlexUsername")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("plex_username");
|
||||
|
||||
b.Property<bool>("SetupCompleted")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("setup_completed");
|
||||
|
||||
b.Property<bool>("TotpEnabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("totp_enabled");
|
||||
|
||||
b.Property<string>("TotpSecret")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("totp_secret");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("username");
|
||||
|
||||
b.ComplexProperty(typeof(Dictionary<string, object>), "Oidc", "Cleanuparr.Persistence.Models.Auth.User.Oidc#OidcConfig", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("AuthorizedSubject")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("oidc_authorized_subject");
|
||||
|
||||
b1.Property<string>("ClientId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("oidc_client_id");
|
||||
|
||||
b1.Property<string>("ClientSecret")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("oidc_client_secret");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("oidc_enabled");
|
||||
|
||||
b1.Property<bool>("ExclusiveMode")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("oidc_exclusive_mode");
|
||||
|
||||
b1.Property<string>("IssuerUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("oidc_issuer_url");
|
||||
|
||||
b1.Property<string>("ProviderName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("oidc_provider_name");
|
||||
|
||||
b1.Property<string>("RedirectUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("oidc_redirect_url");
|
||||
|
||||
b1.Property<string>("Scopes")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("oidc_scopes");
|
||||
});
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_users");
|
||||
|
||||
b.HasIndex("ApiKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_users_api_key");
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_users_username");
|
||||
|
||||
b.ToTable("users", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.RecoveryCode", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Auth.User", "User")
|
||||
.WithMany("RecoveryCodes")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_recovery_codes_users_user_id");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.RefreshToken", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Auth.User", "User")
|
||||
.WithMany("RefreshTokens")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_refresh_tokens_users_user_id");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.User", b =>
|
||||
{
|
||||
b.Navigation("RecoveryCodes");
|
||||
|
||||
b.Navigation("RefreshTokens");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Users
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddOidcSupport : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "oidc_authorized_subject",
|
||||
table: "users",
|
||||
type: "TEXT",
|
||||
maxLength: 500,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "oidc_client_id",
|
||||
table: "users",
|
||||
type: "TEXT",
|
||||
maxLength: 200,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "oidc_client_secret",
|
||||
table: "users",
|
||||
type: "TEXT",
|
||||
maxLength: 500,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "oidc_enabled",
|
||||
table: "users",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "oidc_exclusive_mode",
|
||||
table: "users",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "oidc_issuer_url",
|
||||
table: "users",
|
||||
type: "TEXT",
|
||||
maxLength: 500,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "oidc_provider_name",
|
||||
table: "users",
|
||||
type: "TEXT",
|
||||
maxLength: 100,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "oidc_redirect_url",
|
||||
table: "users",
|
||||
type: "TEXT",
|
||||
maxLength: 500,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "oidc_scopes",
|
||||
table: "users",
|
||||
type: "TEXT",
|
||||
maxLength: 500,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "oidc_authorized_subject",
|
||||
table: "users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "oidc_client_id",
|
||||
table: "users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "oidc_client_secret",
|
||||
table: "users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "oidc_enabled",
|
||||
table: "users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "oidc_exclusive_mode",
|
||||
table: "users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "oidc_issuer_url",
|
||||
table: "users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "oidc_provider_name",
|
||||
table: "users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "oidc_redirect_url",
|
||||
table: "users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "oidc_scopes",
|
||||
table: "users");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
@@ -162,6 +163,61 @@ namespace Cleanuparr.Persistence.Migrations.Users
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("username");
|
||||
|
||||
b.ComplexProperty(typeof(Dictionary<string, object>), "Oidc", "Cleanuparr.Persistence.Models.Auth.User.Oidc#OidcConfig", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("AuthorizedSubject")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("oidc_authorized_subject");
|
||||
|
||||
b1.Property<string>("ClientId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("oidc_client_id");
|
||||
|
||||
b1.Property<string>("ClientSecret")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("oidc_client_secret");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("oidc_enabled");
|
||||
|
||||
b1.Property<bool>("ExclusiveMode")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("oidc_exclusive_mode");
|
||||
|
||||
b1.Property<string>("IssuerUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("oidc_issuer_url");
|
||||
|
||||
b1.Property<string>("ProviderName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("oidc_provider_name");
|
||||
|
||||
b1.Property<string>("RedirectUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("oidc_redirect_url");
|
||||
|
||||
b1.Property<string>("Scopes")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("oidc_scopes");
|
||||
});
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_users");
|
||||
|
||||
|
||||
121
code/backend/Cleanuparr.Persistence/Models/Auth/OidcConfig.cs
Normal file
121
code/backend/Cleanuparr.Persistence/Models/Auth/OidcConfig.cs
Normal file
@@ -0,0 +1,121 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Cleanuparr.Shared.Attributes;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Persistence.Models.Auth;
|
||||
|
||||
[ComplexType]
|
||||
public sealed record OidcConfig
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The OIDC provider's issuer URL (e.g., https://authentik.example.com/application/o/cleanuparr/).
|
||||
/// Used to discover the .well-known/openid-configuration endpoints.
|
||||
/// </summary>
|
||||
[MaxLength(500)]
|
||||
public string IssuerUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The Client ID registered at the identity provider.
|
||||
/// </summary>
|
||||
[MaxLength(200)]
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The Client Secret (optional; for confidential clients).
|
||||
/// </summary>
|
||||
[SensitiveData]
|
||||
[MaxLength(500)]
|
||||
public string ClientSecret { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Space-separated OIDC scopes to request.
|
||||
/// </summary>
|
||||
[MaxLength(500)]
|
||||
public string Scopes { get; set; } = "openid profile email";
|
||||
|
||||
/// <summary>
|
||||
/// The OIDC subject ("sub" claim) that identifies the authorized user.
|
||||
/// Set during OIDC account linking. Only this subject can log in via OIDC.
|
||||
/// </summary>
|
||||
[MaxLength(500)]
|
||||
public string AuthorizedSubject { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Display name for the OIDC provider (shown on the login button, e.g., "Authentik").
|
||||
/// </summary>
|
||||
[MaxLength(100)]
|
||||
public string ProviderName { get; set; } = "OIDC";
|
||||
|
||||
/// <summary>
|
||||
/// Optional base URL for OIDC callback URIs (e.g., https://cleanuparr.example.com).
|
||||
/// When set, callback paths are appended to this URL instead of auto-detecting from the request.
|
||||
/// </summary>
|
||||
[MaxLength(500)]
|
||||
public string RedirectUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// When enabled, all non-OIDC login methods (username/password, Plex) are disabled.
|
||||
/// Requires OIDC to be fully configured with an authorized subject.
|
||||
/// </summary>
|
||||
public bool ExclusiveMode { get; set; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (ExclusiveMode && !Enabled)
|
||||
{
|
||||
throw new ValidationException("OIDC must be enabled to use exclusive mode");
|
||||
}
|
||||
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(IssuerUrl))
|
||||
{
|
||||
throw new ValidationException("OIDC Issuer URL is required when OIDC is enabled");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(IssuerUrl, UriKind.Absolute, out var issuerUri))
|
||||
{
|
||||
throw new ValidationException("OIDC Issuer URL must be a valid absolute URL");
|
||||
}
|
||||
|
||||
// Enforce HTTPS except for localhost (development)
|
||||
if (issuerUri.Scheme != "https" && !IsLocalhost(issuerUri))
|
||||
{
|
||||
throw new ValidationException("OIDC Issuer URL must use HTTPS");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ClientId))
|
||||
{
|
||||
throw new ValidationException("OIDC Client ID is required when OIDC is enabled");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ProviderName))
|
||||
{
|
||||
throw new ValidationException("OIDC Provider Name is required when OIDC is enabled");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(RedirectUrl))
|
||||
{
|
||||
if (!Uri.TryCreate(RedirectUrl, UriKind.Absolute, out var redirectUri))
|
||||
{
|
||||
throw new ValidationException("OIDC Redirect URL must be a valid absolute URL");
|
||||
}
|
||||
|
||||
if (redirectUri.Scheme is not ("http" or "https"))
|
||||
{
|
||||
throw new ValidationException("OIDC Redirect URL must use HTTP or HTTPS");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsLocalhost(Uri uri)
|
||||
{
|
||||
return uri.Host is "localhost" or "127.0.0.1" or "::1" or "[::1]";
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ public class User
|
||||
[SensitiveData]
|
||||
public string? PlexAuthToken { get; set; }
|
||||
|
||||
public OidcConfig Oidc { get; set; } = new();
|
||||
|
||||
[Required]
|
||||
[SensitiveData]
|
||||
public required string ApiKey { get; set; }
|
||||
|
||||
@@ -54,6 +54,8 @@ public class UsersContext : DbContext
|
||||
entity.Property(u => u.LockoutEnd)
|
||||
.HasConversion(new UtcDateTimeConverter());
|
||||
|
||||
entity.ComplexProperty(u => u.Oidc);
|
||||
|
||||
entity.HasMany(u => u.RecoveryCodes)
|
||||
.WithOne(r => r.User)
|
||||
.HasForeignKey(r => r.UserId)
|
||||
|
||||
2
code/frontend/package-lock.json
generated
2
code/frontend/package-lock.json
generated
@@ -783,6 +783,7 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -7283,6 +7284,7 @@
|
||||
"integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cli-truncate": "^5.0.0",
|
||||
"colorette": "^2.0.20",
|
||||
|
||||
@@ -135,6 +135,13 @@ export const routes: Routes = [
|
||||
(m) => m.SetupComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'oidc/callback',
|
||||
loadComponent: () =>
|
||||
import(
|
||||
'@features/auth/oidc-callback/oidc-callback.component'
|
||||
).then((m) => m.OidcCallbackComponent),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: '**', redirectTo: 'dashboard' },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { OidcConfig } from '@shared/models/oidc-config.model';
|
||||
|
||||
export interface AccountInfo {
|
||||
username: string;
|
||||
@@ -83,4 +84,16 @@ export class AccountApi {
|
||||
unlinkPlex(): Observable<void> {
|
||||
return this.http.delete<void>('/api/account/plex/link');
|
||||
}
|
||||
|
||||
getOidcConfig(): Observable<OidcConfig> {
|
||||
return this.http.get<OidcConfig>('/api/account/oidc');
|
||||
}
|
||||
|
||||
updateOidcConfig(config: Partial<OidcConfig>): Observable<void> {
|
||||
return this.http.put<void>('/api/account/oidc', config);
|
||||
}
|
||||
|
||||
unlinkOidc(): Observable<void> {
|
||||
return this.http.delete<void>('/api/account/oidc/link');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ export interface AuthStatus {
|
||||
setupCompleted: boolean;
|
||||
plexLinked: boolean;
|
||||
authBypassActive?: boolean;
|
||||
oidcEnabled?: boolean;
|
||||
oidcProviderName?: string;
|
||||
oidcExclusiveMode?: boolean;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
@@ -46,11 +49,17 @@ export class AuthService {
|
||||
private readonly _isSetupComplete = signal(false);
|
||||
private readonly _plexLinked = signal(false);
|
||||
private readonly _isLoading = signal(true);
|
||||
private readonly _oidcEnabled = signal(false);
|
||||
private readonly _oidcProviderName = signal('');
|
||||
private readonly _oidcExclusiveMode = signal(false);
|
||||
|
||||
readonly isAuthenticated = this._isAuthenticated.asReadonly();
|
||||
readonly isSetupComplete = this._isSetupComplete.asReadonly();
|
||||
readonly plexLinked = this._plexLinked.asReadonly();
|
||||
readonly isLoading = this._isLoading.asReadonly();
|
||||
readonly oidcEnabled = this._oidcEnabled.asReadonly();
|
||||
readonly oidcProviderName = this._oidcProviderName.asReadonly();
|
||||
readonly oidcExclusiveMode = this._oidcExclusiveMode.asReadonly();
|
||||
|
||||
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private refreshInFlight$: Observable<TokenResponse | null> | null = null;
|
||||
@@ -61,6 +70,9 @@ export class AuthService {
|
||||
tap((status) => {
|
||||
this._isSetupComplete.set(status.setupCompleted);
|
||||
this._plexLinked.set(status.plexLinked);
|
||||
this._oidcEnabled.set(status.oidcEnabled ?? false);
|
||||
this._oidcProviderName.set(status.oidcProviderName ?? '');
|
||||
this._oidcExclusiveMode.set(status.oidcExclusiveMode ?? false);
|
||||
|
||||
// Trusted network bypass — no tokens needed
|
||||
if (status.authBypassActive && status.setupCompleted) {
|
||||
@@ -160,6 +172,21 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
|
||||
// OIDC login
|
||||
startOidcLogin(): Observable<{ authorizationUrl: string }> {
|
||||
return this.http.post<{ authorizationUrl: string }>('/api/auth/oidc/start', {});
|
||||
}
|
||||
|
||||
exchangeOidcCode(code: string): Observable<TokenResponse> {
|
||||
return this.http
|
||||
.post<TokenResponse>('/api/auth/oidc/exchange', { code })
|
||||
.pipe(tap((tokens) => this.handleTokens(tokens)));
|
||||
}
|
||||
|
||||
startOidcLink(): Observable<{ authorizationUrl: string }> {
|
||||
return this.http.post<{ authorizationUrl: string }>('/api/account/oidc/link', {});
|
||||
}
|
||||
|
||||
// Token management
|
||||
refreshToken(): Observable<TokenResponse | null> {
|
||||
// Deduplicate: if a refresh is already in-flight, share the same observable
|
||||
|
||||
@@ -175,6 +175,17 @@ export class DocumentationService {
|
||||
'apiKey': 'api-key',
|
||||
'version': 'api-version',
|
||||
},
|
||||
'account': {
|
||||
'oidcEnabled': 'enable-oidc',
|
||||
'oidcProviderName': 'provider-name',
|
||||
'oidcIssuerUrl': 'issuer-url',
|
||||
'oidcClientId': 'client-id',
|
||||
'oidcClientSecret': 'client-secret',
|
||||
'oidcScopes': 'scopes',
|
||||
'oidcRedirectUrl': 'redirect-url',
|
||||
'oidcLinkAccount': 'link-account',
|
||||
'oidcExclusiveMode': 'exclusive-mode',
|
||||
},
|
||||
'notifications/gotify': {
|
||||
'serverUrl': 'server-url',
|
||||
'applicationToken': 'application-token',
|
||||
|
||||
@@ -11,53 +11,76 @@
|
||||
<!-- Credentials view -->
|
||||
@if (view() === 'credentials') {
|
||||
<div class="login-view">
|
||||
<form class="login-form" (ngSubmit)="submitLogin()">
|
||||
<app-input
|
||||
#usernameInput
|
||||
label="Username"
|
||||
placeholder="Enter your username"
|
||||
type="text"
|
||||
[value]="username()"
|
||||
(valueChange)="username.set($event)"
|
||||
/>
|
||||
<app-input
|
||||
label="Password"
|
||||
placeholder="Enter your password"
|
||||
type="password"
|
||||
[value]="password()"
|
||||
(valueChange)="password.set($event)"
|
||||
/>
|
||||
<app-button
|
||||
variant="primary"
|
||||
class="login-submit"
|
||||
[disabled]="!username() || !password() || loading() || retryCountdown() > 0"
|
||||
type="submit"
|
||||
>
|
||||
@if (loading()) {
|
||||
<app-spinner size="sm" />
|
||||
} @else {
|
||||
Sign In
|
||||
}
|
||||
</app-button>
|
||||
</form>
|
||||
@if (!oidcExclusiveMode()) {
|
||||
<form class="login-form" (ngSubmit)="submitLogin()">
|
||||
<app-input
|
||||
#usernameInput
|
||||
label="Username"
|
||||
placeholder="Enter your username"
|
||||
type="text"
|
||||
[value]="username()"
|
||||
(valueChange)="username.set($event)"
|
||||
/>
|
||||
<app-input
|
||||
label="Password"
|
||||
placeholder="Enter your password"
|
||||
type="password"
|
||||
[value]="password()"
|
||||
(valueChange)="password.set($event)"
|
||||
/>
|
||||
<app-button
|
||||
variant="primary"
|
||||
class="login-submit"
|
||||
[disabled]="!username() || !password() || loading() || retryCountdown() > 0"
|
||||
type="submit"
|
||||
>
|
||||
@if (loading()) {
|
||||
<app-spinner size="sm" />
|
||||
} @else {
|
||||
Sign In
|
||||
}
|
||||
</app-button>
|
||||
</form>
|
||||
|
||||
@if (plexLinked()) {
|
||||
<div class="divider">
|
||||
<span>or</span>
|
||||
</div>
|
||||
@if (plexLinked() || oidcEnabled()) {
|
||||
<div class="divider">
|
||||
<span>or</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (plexLinked()) {
|
||||
<button
|
||||
class="plex-login-btn"
|
||||
[disabled]="plexLoading()"
|
||||
(click)="startPlexLogin()"
|
||||
>
|
||||
@if (plexLoading()) {
|
||||
<app-spinner size="sm" />
|
||||
Waiting for Plex...
|
||||
} @else {
|
||||
<svg class="plex-login-btn__icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M5.074 1L12 12 5.074 23h5.854L18.926 12 11.928 1z"/>
|
||||
</svg>
|
||||
Sign in with Plex
|
||||
}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
@if (oidcEnabled()) {
|
||||
<button
|
||||
class="plex-login-btn"
|
||||
[disabled]="plexLoading()"
|
||||
(click)="startPlexLogin()"
|
||||
class="oidc-login-btn"
|
||||
[disabled]="oidcLoading()"
|
||||
(click)="startOidcLogin()"
|
||||
>
|
||||
@if (plexLoading()) {
|
||||
@if (oidcLoading()) {
|
||||
<app-spinner size="sm" />
|
||||
Waiting for Plex...
|
||||
Redirecting...
|
||||
} @else {
|
||||
<svg class="plex-login-btn__icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M5.074 1L12 12 5.074 23h5.854L18.926 12 11.928 1z"/>
|
||||
<svg class="oidc-login-btn__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
Sign in with Plex
|
||||
Sign in with {{ oidcProviderName() }}
|
||||
}
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -118,6 +118,45 @@
|
||||
}
|
||||
}
|
||||
|
||||
.oidc-login-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 var(--space-4);
|
||||
margin-top: var(--space-2);
|
||||
font-family: var(--font-family);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: #ffffff;
|
||||
background: #7E57C2;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) var(--ease-default);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #6D28D9;
|
||||
box-shadow: 0 0 20px rgba(126, 87, 194, 0.4);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, viewChild, effect, afterNextRender, OnInit, OnDestroy } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { ButtonComponent, InputComponent, SpinnerComponent } from '@ui';
|
||||
import { AuthService } from '@core/auth/auth.service';
|
||||
import { ApiError } from '@core/interceptors/error.interceptor';
|
||||
@@ -18,6 +18,7 @@ type LoginView = 'credentials' | '2fa' | 'recovery';
|
||||
export class LoginComponent implements OnInit, OnDestroy {
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
|
||||
view = signal<LoginView>('credentials');
|
||||
loading = signal(false);
|
||||
@@ -41,6 +42,12 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
plexLoading = signal(false);
|
||||
plexPinId = signal(0);
|
||||
|
||||
// OIDC
|
||||
oidcEnabled = this.auth.oidcEnabled;
|
||||
oidcProviderName = this.auth.oidcProviderName;
|
||||
oidcExclusiveMode = this.auth.oidcExclusiveMode;
|
||||
oidcLoading = signal(false);
|
||||
|
||||
// Auto-focus refs
|
||||
usernameInput = viewChild<InputComponent>('usernameInput');
|
||||
totpInput = viewChild<InputComponent>('totpInput');
|
||||
@@ -65,6 +72,18 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
|
||||
ngOnInit(): void {
|
||||
this.auth.checkStatus().subscribe();
|
||||
|
||||
const oidcError = this.route.snapshot.queryParams['oidc_error'];
|
||||
if (oidcError) {
|
||||
const messages: Record<string, string> = {
|
||||
provider_error: 'The identity provider returned an error',
|
||||
invalid_request: 'Invalid authentication request',
|
||||
authentication_failed: 'Authentication failed',
|
||||
unauthorized: 'Your account is not authorized for OIDC login',
|
||||
no_account: 'No account found',
|
||||
};
|
||||
this.error.set(messages[oidcError] || 'OIDC authentication failed');
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
@@ -166,6 +185,22 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
startOidcLogin(): void {
|
||||
this.oidcLoading.set(true);
|
||||
this.error.set('');
|
||||
|
||||
this.auth.startOidcLogin().subscribe({
|
||||
next: (result) => {
|
||||
// Full page redirect to IdP
|
||||
window.location.href = result.authorizationUrl;
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message || 'Failed to start OIDC login');
|
||||
this.oidcLoading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private pollPlexPin(): void {
|
||||
let attempts = 0;
|
||||
this.plexPollTimer = setInterval(() => {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Component, ChangeDetectionStrategy, inject, OnInit, signal } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { SpinnerComponent } from '@ui';
|
||||
import { AuthService } from '@core/auth/auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-oidc-callback',
|
||||
standalone: true,
|
||||
imports: [SpinnerComponent],
|
||||
template: `
|
||||
<div class="oidc-callback">
|
||||
@if (error()) {
|
||||
<p class="oidc-callback__error">{{ error() }}</p>
|
||||
<p class="oidc-callback__redirect">Redirecting to login...</p>
|
||||
} @else {
|
||||
<app-spinner />
|
||||
<p class="oidc-callback__message">Completing sign in...</p>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
.oidc-callback {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-8);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.oidc-callback__message {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.oidc-callback__error {
|
||||
color: var(--color-error);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.oidc-callback__redirect {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class OidcCallbackComponent implements OnInit {
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
readonly error = signal('');
|
||||
|
||||
ngOnInit(): void {
|
||||
const params = this.route.snapshot.queryParams;
|
||||
const code = params['code'];
|
||||
const oidcError = params['oidc_error'];
|
||||
|
||||
if (oidcError) {
|
||||
this.handleError(oidcError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
this.handleError('missing_code');
|
||||
return;
|
||||
}
|
||||
|
||||
this.auth.exchangeOidcCode(code).subscribe({
|
||||
next: () => {
|
||||
this.router.navigate(['/dashboard']);
|
||||
},
|
||||
error: () => {
|
||||
this.handleError('exchange_failed');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private handleError(errorCode: string): void {
|
||||
const messages: Record<string, string> = {
|
||||
provider_error: 'The identity provider returned an error',
|
||||
invalid_request: 'Invalid authentication request',
|
||||
authentication_failed: 'Authentication failed',
|
||||
unauthorized: 'Your account is not authorized for OIDC login',
|
||||
no_account: 'No account found',
|
||||
missing_code: 'Invalid callback - missing authorization code',
|
||||
exchange_failed: 'Failed to complete sign in',
|
||||
};
|
||||
|
||||
this.error.set(messages[errorCode] || 'An unknown error occurred');
|
||||
|
||||
setTimeout(() => {
|
||||
this.router.navigate(['/auth/login']);
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,11 @@
|
||||
<!-- Change Password -->
|
||||
<app-card header="Change Password">
|
||||
<div class="form-stack">
|
||||
@if (oidcExclusiveMode()) {
|
||||
<div class="section-notice">
|
||||
Password login is disabled while OIDC exclusive mode is active.
|
||||
</div>
|
||||
}
|
||||
<app-input
|
||||
label="Current Password"
|
||||
type="password"
|
||||
@@ -54,8 +59,8 @@
|
||||
<div class="form-actions">
|
||||
<app-button
|
||||
variant="primary"
|
||||
[glowing]="!!currentPassword() && !!newPassword() && !!confirmPassword()"
|
||||
[disabled]="!currentPassword() || !newPassword() || !confirmPassword() || changingPassword()"
|
||||
[glowing]="!!currentPassword() && !!newPassword() && !!confirmPassword() && !oidcExclusiveMode()"
|
||||
[disabled]="!currentPassword() || !newPassword() || !confirmPassword() || changingPassword() || oidcExclusiveMode()"
|
||||
(clicked)="changePassword()"
|
||||
>
|
||||
@if (changingPassword()) {
|
||||
@@ -283,6 +288,11 @@
|
||||
<!-- Plex Integration -->
|
||||
<app-card header="Plex Integration">
|
||||
<div class="form-stack">
|
||||
@if (oidcExclusiveMode()) {
|
||||
<div class="section-notice">
|
||||
Plex login is disabled while OIDC exclusive mode is active.
|
||||
</div>
|
||||
}
|
||||
@if (account()!.plexLinked) {
|
||||
<div class="status-row">
|
||||
<span class="status-label">Linked Account</span>
|
||||
@@ -291,7 +301,7 @@
|
||||
<div class="form-actions">
|
||||
<app-button
|
||||
variant="destructive"
|
||||
[disabled]="plexUnlinking()"
|
||||
[disabled]="plexUnlinking() || oidcExclusiveMode()"
|
||||
(clicked)="confirmUnlinkPlex()"
|
||||
>
|
||||
@if (plexUnlinking()) {
|
||||
@@ -308,7 +318,7 @@
|
||||
<div class="form-actions">
|
||||
<app-button
|
||||
variant="primary"
|
||||
[disabled]="plexLinking()"
|
||||
[disabled]="plexLinking() || oidcExclusiveMode()"
|
||||
(clicked)="startPlexLink()"
|
||||
>
|
||||
@if (plexLinking()) {
|
||||
@@ -321,5 +331,78 @@
|
||||
}
|
||||
</div>
|
||||
</app-card>
|
||||
|
||||
<!-- OIDC / SSO -->
|
||||
<app-card header="OIDC / SSO">
|
||||
<div class="form-stack">
|
||||
<app-toggle label="Enable OIDC" [(checked)]="oidcEnabled"
|
||||
helpKey="account:oidcEnabled"
|
||||
hint="Allow signing in with an external identity provider (Authentik, Authelia, Keycloak, etc.)" />
|
||||
@if (oidcEnabled()) {
|
||||
<app-input label="Provider Name" [(value)]="oidcProviderName"
|
||||
helpKey="account:oidcProviderName"
|
||||
hint="Display name shown on the login button (e.g. Authentik, Keycloak)" />
|
||||
<app-input label="Issuer URL" [(value)]="oidcIssuerUrl"
|
||||
helpKey="account:oidcIssuerUrl"
|
||||
placeholder="https://auth.example.com/application/o/cleanuparr/"
|
||||
hint="The OpenID Connect issuer URL from your identity provider. Must use HTTPS." />
|
||||
<div class="form-row">
|
||||
<app-input label="Client ID" [(value)]="oidcClientId"
|
||||
helpKey="account:oidcClientId"
|
||||
hint="The client ID assigned by your identity provider" />
|
||||
<app-input label="Client Secret" [(value)]="oidcClientSecret" type="password" [revealable]="false"
|
||||
helpKey="account:oidcClientSecret"
|
||||
hint="Optional. Required for confidential clients." />
|
||||
</div>
|
||||
<app-input label="Scopes" [(value)]="oidcScopes"
|
||||
helpKey="account:oidcScopes"
|
||||
hint="Space-separated list of OIDC scopes to request (default: openid profile email)" />
|
||||
<app-input label="Redirect URL" [(value)]="oidcRedirectUrl"
|
||||
helpKey="account:oidcRedirectUrl"
|
||||
placeholder="https://cleanuparr.example.com"
|
||||
hint="The base URL where Cleanuparr is accessible. Callback paths are appended automatically. Leave empty to auto-detect." />
|
||||
|
||||
<div class="form-divider"></div>
|
||||
|
||||
<div class="oidc-link-section">
|
||||
<div class="oidc-link-section__info">
|
||||
<app-label label="Link OIDC Account" helpKey="account:oidcLinkAccount" />
|
||||
@if (oidcAuthorizedSubject()) {
|
||||
<span class="oidc-link-section__hint">
|
||||
Linked to subject: <code class="oidc-link-section__subject">{{ oidcAuthorizedSubject() }}</code>
|
||||
</span>
|
||||
} @else {
|
||||
<span class="oidc-link-section__hint">
|
||||
No account linked — any user who can authenticate with your provider and is allowed to access this app can sign in. Link an account to restrict access.
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="oidc-link-section__actions">
|
||||
<app-button variant="secondary" size="sm" [loading]="oidcLinking()" [disabled]="oidcLinking()" (clicked)="startOidcLink()">
|
||||
{{ oidcAuthorizedSubject() ? 'Re-link' : 'Link Account' }}
|
||||
</app-button>
|
||||
@if (oidcAuthorizedSubject()) {
|
||||
<app-button variant="destructive" size="sm" [loading]="oidcUnlinking()" [disabled]="oidcUnlinking()" (clicked)="confirmUnlinkOidc()">
|
||||
Unlink
|
||||
</app-button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-divider"></div>
|
||||
<app-toggle label="Exclusive Mode" [(checked)]="oidcExclusiveMode"
|
||||
helpKey="account:oidcExclusiveMode"
|
||||
hint="When enabled, only OIDC login is allowed. Username/password and Plex login will be disabled." />
|
||||
}
|
||||
|
||||
<div class="form-divider"></div>
|
||||
|
||||
<div class="form-actions">
|
||||
<app-button variant="primary" [loading]="oidcSaving()" [disabled]="oidcSaving() || oidcSaved()" (clicked)="saveOidcConfig()">
|
||||
{{ oidcSaved() ? 'Saved!' : 'Save OIDC Settings' }}
|
||||
</app-button>
|
||||
</div>
|
||||
</div>
|
||||
</app-card>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -8,6 +8,15 @@
|
||||
.form-divider { @include form-divider; }
|
||||
.form-actions { @include form-actions; }
|
||||
|
||||
.section-notice {
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(234, 179, 8, 0.1);
|
||||
color: var(--color-warning);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.section-hint {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
@@ -168,6 +177,40 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// OIDC link section
|
||||
.oidc-link-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__subject {
|
||||
font-family: var(--font-family-mono, monospace);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
padding: var(--space-0-5) var(--space-1);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
}
|
||||
|
||||
// Password strength indicator
|
||||
.password-strength {
|
||||
display: flex;
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, OnInit, OnDestroy } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { PageHeaderComponent } from '@layout/page-header/page-header.component';
|
||||
import {
|
||||
CardComponent, ButtonComponent, InputComponent, SpinnerComponent,
|
||||
AccordionComponent, ToggleComponent, LabelComponent,
|
||||
EmptyStateComponent, LoadingStateComponent,
|
||||
} from '@ui';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { AccountApi, AccountInfo } from '@core/api/account.api';
|
||||
import { AuthService } from '@core/auth/auth.service';
|
||||
import { ToastService } from '@core/services/toast.service';
|
||||
import { ConfirmService } from '@core/services/confirm.service';
|
||||
import { DeferredLoader } from '@shared/utils/loading.util';
|
||||
@@ -15,7 +19,8 @@ import { QRCodeComponent } from 'angularx-qrcode';
|
||||
standalone: true,
|
||||
imports: [
|
||||
PageHeaderComponent, CardComponent, ButtonComponent, InputComponent,
|
||||
SpinnerComponent, EmptyStateComponent, LoadingStateComponent, QRCodeComponent,
|
||||
SpinnerComponent, AccordionComponent, ToggleComponent,
|
||||
EmptyStateComponent, LoadingStateComponent, QRCodeComponent, LabelComponent,
|
||||
],
|
||||
templateUrl: './account-settings.component.html',
|
||||
styleUrl: './account-settings.component.scss',
|
||||
@@ -23,8 +28,10 @@ import { QRCodeComponent } from 'angularx-qrcode';
|
||||
})
|
||||
export class AccountSettingsComponent implements OnInit, OnDestroy {
|
||||
private readonly api = inject(AccountApi);
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly confirmService = inject(ConfirmService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
|
||||
readonly loader = new DeferredLoader();
|
||||
readonly loadError = signal(false);
|
||||
@@ -78,7 +85,40 @@ export class AccountSettingsComponent implements OnInit, OnDestroy {
|
||||
readonly plexUnlinking = signal(false);
|
||||
private plexPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// OIDC
|
||||
readonly oidcEnabled = signal(false);
|
||||
readonly oidcIssuerUrl = signal('');
|
||||
readonly oidcClientId = signal('');
|
||||
readonly oidcClientSecret = signal('');
|
||||
readonly oidcScopes = signal('openid profile email');
|
||||
readonly oidcProviderName = signal('OIDC');
|
||||
readonly oidcRedirectUrl = signal('');
|
||||
readonly oidcAuthorizedSubject = signal('');
|
||||
readonly oidcExpanded = signal(false);
|
||||
readonly oidcExclusiveMode = signal(false);
|
||||
readonly oidcLinking = signal(false);
|
||||
readonly oidcUnlinking = signal(false);
|
||||
readonly oidcSaving = signal(false);
|
||||
readonly oidcSaved = signal(false);
|
||||
|
||||
constructor() {
|
||||
// Reset exclusive mode when OIDC is toggled off
|
||||
effect(() => {
|
||||
if (!this.oidcEnabled()) {
|
||||
this.oidcExclusiveMode.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
const params = this.route.snapshot.queryParams;
|
||||
if (params['oidc_link'] === 'success') {
|
||||
this.toast.success('OIDC account linked successfully');
|
||||
this.oidcExpanded.set(true);
|
||||
} else if (params['oidc_link_error']) {
|
||||
this.toast.error('Failed to link OIDC account');
|
||||
this.oidcExpanded.set(true);
|
||||
}
|
||||
this.loadAccount();
|
||||
}
|
||||
|
||||
@@ -90,9 +130,18 @@ export class AccountSettingsComponent implements OnInit, OnDestroy {
|
||||
|
||||
private loadAccount(): void {
|
||||
this.loader.start();
|
||||
this.api.getInfo().subscribe({
|
||||
next: (info) => {
|
||||
forkJoin([this.api.getInfo(), this.api.getOidcConfig()]).subscribe({
|
||||
next: ([info, oidc]) => {
|
||||
this.account.set(info);
|
||||
this.oidcEnabled.set(oidc.enabled);
|
||||
this.oidcIssuerUrl.set(oidc.issuerUrl);
|
||||
this.oidcClientId.set(oidc.clientId);
|
||||
this.oidcClientSecret.set(oidc.clientSecret);
|
||||
this.oidcScopes.set(oidc.scopes || 'openid profile email');
|
||||
this.oidcProviderName.set(oidc.providerName || 'OIDC');
|
||||
this.oidcRedirectUrl.set(oidc.redirectUrl || '');
|
||||
this.oidcAuthorizedSubject.set(oidc.authorizedSubject);
|
||||
this.oidcExclusiveMode.set(oidc.exclusiveMode);
|
||||
this.loader.stop();
|
||||
},
|
||||
error: () => {
|
||||
@@ -362,4 +411,68 @@ export class AccountSettingsComponent implements OnInit, OnDestroy {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// OIDC
|
||||
saveOidcConfig(): void {
|
||||
this.oidcSaving.set(true);
|
||||
this.api.updateOidcConfig({
|
||||
enabled: this.oidcEnabled(),
|
||||
issuerUrl: this.oidcIssuerUrl(),
|
||||
clientId: this.oidcClientId(),
|
||||
clientSecret: this.oidcClientSecret(),
|
||||
scopes: this.oidcScopes(),
|
||||
authorizedSubject: this.oidcAuthorizedSubject(),
|
||||
providerName: this.oidcProviderName(),
|
||||
redirectUrl: this.oidcRedirectUrl(),
|
||||
exclusiveMode: this.oidcExclusiveMode(),
|
||||
}).subscribe({
|
||||
next: () => {
|
||||
this.toast.success('OIDC settings saved');
|
||||
this.oidcSaving.set(false);
|
||||
this.oidcSaved.set(true);
|
||||
setTimeout(() => this.oidcSaved.set(false), 1500);
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error('Failed to save OIDC settings');
|
||||
this.oidcSaving.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
startOidcLink(): void {
|
||||
this.oidcLinking.set(true);
|
||||
this.auth.startOidcLink().subscribe({
|
||||
next: (result) => {
|
||||
window.location.href = result.authorizationUrl;
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error('Failed to start OIDC account linking');
|
||||
this.oidcLinking.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async confirmUnlinkOidc(): Promise<void> {
|
||||
const confirmed = await this.confirmService.confirm({
|
||||
title: 'Unlink OIDC Account',
|
||||
message: 'This will remove the linked identity. Anyone who can authenticate with your identity provider and is allowed to access this application will be able to sign in.',
|
||||
confirmLabel: 'Unlink',
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
this.oidcUnlinking.set(true);
|
||||
this.api.unlinkOidc().subscribe({
|
||||
next: () => {
|
||||
this.oidcAuthorizedSubject.set('');
|
||||
this.oidcExclusiveMode.set(false);
|
||||
this.toast.success('OIDC account unlinked');
|
||||
this.oidcUnlinking.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error('Failed to unlink OIDC account');
|
||||
this.oidcUnlinking.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit, viewChildren } from '@angular/core';
|
||||
import { PageHeaderComponent } from '@layout/page-header/page-header.component';
|
||||
import {
|
||||
CardComponent, ButtonComponent, ToggleComponent,
|
||||
CardComponent, ButtonComponent, ToggleComponent, InputComponent,
|
||||
NumberInputComponent, SelectComponent, ChipInputComponent, AccordionComponent,
|
||||
EmptyStateComponent, LoadingStateComponent,
|
||||
type SelectOption,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { GeneralConfigApi } from '@core/api/general-config.api';
|
||||
import { ToastService } from '@core/services/toast.service';
|
||||
import { ConfirmService } from '@core/services/confirm.service';
|
||||
import { GeneralConfig, LoggingConfig } from '@shared/models/general-config.model';
|
||||
import { GeneralConfig } from '@shared/models/general-config.model';
|
||||
import { CertificateValidationType, LogEventLevel } from '@shared/models/enums';
|
||||
import { HasPendingChanges } from '@core/guards/pending-changes.guard';
|
||||
import { DeferredLoader } from '@shared/utils/loading.util';
|
||||
@@ -34,7 +34,7 @@ const LOG_LEVEL_OPTIONS: SelectOption[] = [
|
||||
standalone: true,
|
||||
imports: [
|
||||
PageHeaderComponent, CardComponent, ButtonComponent,
|
||||
ToggleComponent, NumberInputComponent, SelectComponent, ChipInputComponent,
|
||||
ToggleComponent, InputComponent, NumberInputComponent, SelectComponent, ChipInputComponent,
|
||||
AccordionComponent, EmptyStateComponent, LoadingStateComponent,
|
||||
],
|
||||
templateUrl: './general-settings.component.html',
|
||||
|
||||
11
code/frontend/src/app/shared/models/oidc-config.model.ts
Normal file
11
code/frontend/src/app/shared/models/oidc-config.model.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export interface OidcConfig {
|
||||
enabled: boolean;
|
||||
issuerUrl: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
scopes: string;
|
||||
authorizedSubject: string;
|
||||
providerName: string;
|
||||
redirectUrl: string;
|
||||
exclusiveMode: boolean;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export { ButtonComponent } from './button/button.component';
|
||||
export type { ButtonVariant, ButtonSize } from './button/button.component';
|
||||
export { CardComponent } from './card/card.component';
|
||||
export { InputComponent } from './input/input.component';
|
||||
export { LabelComponent } from './label/label.component';
|
||||
export { SpinnerComponent } from './spinner/spinner.component';
|
||||
export { ToggleComponent } from './toggle/toggle.component';
|
||||
export { IconComponent } from './icon/icon.component';
|
||||
|
||||
9
code/frontend/src/app/ui/label/label.component.html
Normal file
9
code/frontend/src/app/ui/label/label.component.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<span class="label">
|
||||
{{ label() }}
|
||||
@if (helpKey()) {
|
||||
<button type="button" class="field-help-btn" (click)="onHelpClick($event)"
|
||||
[attr.aria-label]="'Open documentation for ' + label()" tabindex="-1">
|
||||
<ng-icon name="tablerQuestionMark" size="14" />
|
||||
</button>
|
||||
}
|
||||
</span>
|
||||
5
code/frontend/src/app/ui/label/label.component.scss
Normal file
5
code/frontend/src/app/ui/label/label.component.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
.label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
28
code/frontend/src/app/ui/label/label.component.ts
Normal file
28
code/frontend/src/app/ui/label/label.component.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Component, ChangeDetectionStrategy, input, inject } from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { DocumentationService } from '@core/services/documentation.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-label',
|
||||
standalone: true,
|
||||
imports: [NgIcon],
|
||||
templateUrl: './label.component.html',
|
||||
styleUrl: './label.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class LabelComponent {
|
||||
private readonly docs = inject(DocumentationService);
|
||||
|
||||
label = input.required<string>();
|
||||
helpKey = input<string>();
|
||||
|
||||
onHelpClick(event: Event): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const key = this.helpKey();
|
||||
if (key) {
|
||||
const [section, field] = key.split(':');
|
||||
this.docs.openFieldDocumentation(section, field);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user