Compare commits

...
11 Commits
140 changed files with 12942 additions and 1102 deletions

No files matched your search

+2 -2
View File
@@ -74,9 +74,9 @@ jobs:
token: ${{ env.REPO_READONLY_PAT }}
- name: Setup dotnet
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
dotnet-version: 10.0.200
- name: Cache NuGet packages
uses: actions/cache@v4
+2 -2
View File
@@ -84,9 +84,9 @@ jobs:
path: code/frontend/dist/ui/browser
- name: Setup .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
dotnet-version: 10.0.200
- name: Restore .NET dependencies
run: |
@@ -68,9 +68,9 @@ jobs:
path: code/frontend/dist/ui/browser
- name: Setup .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
dotnet-version: 10.0.200
- name: Restore .NET dependencies
run: |
@@ -20,6 +20,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Create directory for static files
run: |
mkdir -p Cloudflare/static
- name: Copy root static files to Cloudflare static directory
run: |
+94
View File
@@ -0,0 +1,94 @@
name: E2E Tests
on:
push:
branches:
- main
paths:
- 'code/**'
- 'e2e/**'
- '.github/workflows/e2e.yml'
pull_request:
paths:
- 'code/**'
- 'e2e/**'
- '.github/workflows/e2e.yml'
workflow_call:
concurrency:
group: E2E Tests-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v4
timeout-minutes: 1
- name: Get vault secrets
uses: hashicorp/vault-action@v2
with:
url: ${{ secrets.VAULT_HOST }}
method: approle
roleId: ${{ secrets.VAULT_ROLE_ID }}
secretId: ${{ secrets.VAULT_SECRET_ID }}
secrets:
secrets/data/github packages_pat | PACKAGES_PAT
- name: Start services
working-directory: e2e
run: docker compose -f docker-compose.e2e.yml up -d --build
env:
PACKAGES_USERNAME: ${{ github.repository_owner }}
PACKAGES_PAT: ${{ env.PACKAGES_PAT }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install E2E dependencies
working-directory: e2e
run: npm ci
- name: Install Playwright browsers
working-directory: e2e
run: npx playwright install --with-deps chromium
- name: Wait for Keycloak
run: |
echo "Waiting for Keycloak realm to be ready..."
timeout 120 bash -c 'until curl -sf http://localhost:8080/realms/cleanuparr-test/.well-known/openid-configuration; do sleep 3; done'
echo "Keycloak ready!"
- name: Wait for app
run: |
echo "Waiting for Cleanuparr to be ready..."
timeout 120 bash -c 'until curl -sf http://localhost:5000/health; do sleep 3; done'
echo "App ready!"
- name: Run E2E tests
working-directory: e2e
run: npx playwright test
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: e2e-test-results
path: |
e2e/playwright-report/
e2e/test-results/
retention-days: 7
- name: Stop services
if: always()
working-directory: e2e
run: docker compose -f docker-compose.e2e.yml down
+19 -6
View File
@@ -96,24 +96,33 @@ jobs:
uses: ./.github/workflows/test.yml
secrets: inherit
# Run E2E tests
e2e:
needs: validate
if: ${{ needs.validate.outputs.is_tag == 'true' || github.event.inputs.runTests == 'true' }}
uses: ./.github/workflows/e2e.yml
secrets: inherit
# Build frontend once for all build jobs and cache it
build-frontend:
needs: [validate, test]
needs: [validate, test, e2e]
if: |
always() &&
needs.validate.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
uses: ./.github/workflows/build-frontend.yml
secrets: inherit
# Build portable executables
build-executables:
needs: [validate, test, build-frontend]
needs: [validate, test, e2e, build-frontend]
if: |
always() &&
needs.validate.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
needs.build-frontend.result == 'success' &&
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
uses: ./.github/workflows/build-executable.yml
@@ -123,11 +132,12 @@ jobs:
# Build Windows installer
build-windows-installer:
needs: [validate, test, build-frontend]
needs: [validate, test, e2e, build-frontend]
if: |
always() &&
needs.validate.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
needs.build-frontend.result == 'success' &&
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
uses: ./.github/workflows/build-windows-installer.yml
@@ -137,11 +147,12 @@ jobs:
# Build macOS installers (Intel and ARM)
build-macos:
needs: [validate, test, build-frontend]
needs: [validate, test, e2e, build-frontend]
if: |
always() &&
needs.validate.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
needs.build-frontend.result == 'success' &&
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
uses: ./.github/workflows/build-macos-installer.yml
@@ -151,11 +162,12 @@ jobs:
# Build and push Docker image(s)
build-docker:
needs: [validate, test]
needs: [validate, test, e2e]
if: |
always() &&
needs.validate.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildDocker == 'true')
uses: ./.github/workflows/build-docker.yml
with:
@@ -232,7 +244,7 @@ jobs:
# Summary job
summary:
needs: [validate, test, build-frontend, build-executables, build-windows-installer, build-macos, build-docker]
needs: [validate, test, e2e, build-frontend, build-executables, build-windows-installer, build-macos, build-docker]
runs-on: ubuntu-latest
if: always()
@@ -277,6 +289,7 @@ jobs:
}
print_result "Tests" "${{ needs.test.result }}"
print_result "E2E Tests" "${{ needs.e2e.result }}"
print_result "Frontend Build" "${{ needs.build-frontend.result }}"
print_result "Portable Executables" "${{ needs.build-executables.result }}"
print_result "Windows Installer" "${{ needs.build-windows-installer.result }}"
+3 -3
View File
@@ -15,7 +15,7 @@ on:
# Cancel in-progress runs for the same PR
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
group: Tests-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
@@ -29,9 +29,9 @@ jobs:
timeout-minutes: 1
- name: Setup .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
dotnet-version: 10.0.200
- name: Cache NuGet packages
uses: actions/cache@v4
@@ -1,2 +0,0 @@
thepirateheaven.org
RARBG.work
@@ -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>
{
@@ -31,6 +32,16 @@ public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
body.GetProperty("setupCompleted").GetBoolean().ShouldBeFalse();
}
[Fact, TestPriority(0)]
public async Task AuthEndpoints_AlwaysReturnNoCacheHeaders()
{
var response = await _client.GetAsync("/api/auth/status");
response.Headers.CacheControl.ShouldNotBeNull();
response.Headers.CacheControl!.NoCache.ShouldBeTrue();
response.Headers.CacheControl!.NoStore.ShouldBeTrue();
}
[Fact, TestPriority(1)]
public async Task Setup_CreateAccount_ReturnsCreated()
{
@@ -233,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
}
@@ -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 { }
@@ -0,0 +1,5 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"parallelizeAssembly": false,
"parallelizeTestCollections": false
}
@@ -1,5 +1,6 @@
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using Cleanuparr.Api.Filters;
using Cleanuparr.Api.Json;
using Cleanuparr.Infrastructure.Health;
using Cleanuparr.Infrastructure.Hubs;
@@ -64,10 +65,10 @@ public static class ApiDI
// Enable compression
app.UseResponseCompression();
// Serve static files with caching
// Serve static files without caching
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = _ => {}
OnPrepareResponse = ctx => NoCacheAttribute.Apply(ctx.Context.Response.Headers)
});
// Add the global exception handling middleware first
@@ -119,6 +120,7 @@ public static class ApiDI
);
context.Response.ContentType = "text/html";
NoCacheAttribute.Apply(context.Response.Headers);
await context.Response.WriteAsync(indexContent, Encoding.UTF8);
}).AllowAnonymous();
@@ -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,25 +1,30 @@
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;
using Cleanuparr.Infrastructure.Features.Auth;
using Cleanuparr.Persistence;
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;
[ApiController]
[Route("api/account")]
[Authorize]
[NoCache]
public sealed class AccountController : ControllerBase
{
private readonly UsersContext _usersContext;
private readonly IPasswordService _passwordService;
private readonly ITotpService _totpService;
private readonly IPlexAuthService _plexAuthService;
private readonly IOidcAuthService _oidcAuthService;
private readonly ILogger<AccountController> _logger;
public AccountController(
@@ -27,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;
}
@@ -40,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
{
@@ -55,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 });
}
@@ -303,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 });
@@ -338,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)
@@ -347,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
{
@@ -371,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,7 +1,9 @@
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;
using Cleanuparr.Infrastructure.Features.Auth;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Auth;
@@ -14,28 +16,35 @@ namespace Cleanuparr.Api.Features.Auth.Controllers;
[ApiController]
[Route("api/auth")]
[AllowAnonymous]
[NoCache]
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;
}
@@ -45,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(
@@ -58,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
});
}
@@ -239,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)
@@ -292,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)
{
@@ -453,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)
{
@@ -471,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)
{
@@ -507,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);
@@ -608,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 };
}
}
@@ -16,7 +16,7 @@ public sealed record UpdateMalwareBlockerConfigRequest
public bool DeletePrivate { get; init; }
public bool DeleteKnownMalware { get; init; }
public bool ProcessNoContentId { get; init; }
public BlocklistSettings Sonarr { get; init; } = new();
@@ -37,7 +37,7 @@ public sealed record UpdateMalwareBlockerConfigRequest
config.UseAdvancedScheduling = UseAdvancedScheduling;
config.IgnorePrivate = IgnorePrivate;
config.DeletePrivate = DeletePrivate;
config.DeleteKnownMalware = DeleteKnownMalware;
config.ProcessNoContentId = ProcessNoContentId;
config.Sonarr = Sonarr;
config.Radarr = Radarr;
config.Lidarr = Lidarr;
@@ -13,6 +13,8 @@ public sealed record UpdateQueueCleanerConfigRequest
public FailedImportConfig FailedImport { get; init; } = new();
public ushort DownloadingMetadataMaxStrikes { get; init; }
public bool ProcessNoContentId { get; init; }
public List<string> IgnoredDownloads { get; set; } = [];
}
@@ -69,6 +69,7 @@ public sealed class QueueCleanerConfigController : ControllerBase
oldConfig.UseAdvancedScheduling = newConfigDto.UseAdvancedScheduling;
oldConfig.FailedImport = newConfigDto.FailedImport;
oldConfig.DownloadingMetadataMaxStrikes = newConfigDto.DownloadingMetadataMaxStrikes;
oldConfig.ProcessNoContentId = newConfigDto.ProcessNoContentId;
oldConfig.IgnoredDownloads = newConfigDto.IgnoredDownloads;
oldConfig.Validate();
@@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Mvc.Filters;
namespace Cleanuparr.Api.Filters;
/// <summary>
/// Prevents caching of sensitive responses by setting appropriate HTTP headers.
/// Applies Cache-Control: no-cache, no-store, Pragma: no-cache, and a past Expires date
/// for maximum compatibility with HTTP/1.0 and HTTP/1.1 clients and intermediaries.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public sealed class NoCacheAttribute : ActionFilterAttribute
{
public static void Apply(IHeaderDictionary headers)
{
headers.CacheControl = "no-cache, no-store";
headers.Pragma = "no-cache";
headers.Expires = "Thu, 01 Jan 1970 00:00:00 GMT";
}
public override void OnResultExecuting(ResultExecutingContext context)
{
Apply(context.HttpContext.Response.Headers);
base.OnResultExecuting(context);
}
}
@@ -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;
@@ -11,5 +11,4 @@ public enum DeleteReason
AllFilesSkipped,
AllFilesSkippedByQBit,
AllFilesBlocked,
MalwareFileFound,
}
@@ -1,10 +1,8 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Interceptors;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Arr;
@@ -35,144 +33,4 @@ public class WhisparrV2ClientTests
_dryRunInterceptorMock.Object
);
}
#region IsRecordValid Tests
[Fact]
public void IsRecordValid_WhenEpisodeIdIsZero_ReturnsFalse()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Episode",
DownloadId = "abc123",
Protocol = "torrent",
EpisodeId = 0,
SeriesId = 1
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.False(result);
_loggerMock.Verify(
x => x.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("episode id and/or series id missing")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public void IsRecordValid_WhenSeriesIdIsZero_ReturnsFalse()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Episode",
DownloadId = "abc123",
Protocol = "torrent",
EpisodeId = 1,
SeriesId = 0
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.False(result);
}
[Fact]
public void IsRecordValid_WhenBothIdsAreZero_ReturnsFalse()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Episode",
DownloadId = "abc123",
Protocol = "torrent",
EpisodeId = 0,
SeriesId = 0
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.False(result);
}
[Fact]
public void IsRecordValid_WhenBothIdsAreSet_ReturnsTrue()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Episode",
DownloadId = "abc123",
Protocol = "torrent",
EpisodeId = 42,
SeriesId = 10
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.True(result);
}
[Fact]
public void IsRecordValid_WhenDownloadIdIsNull_ReturnsFalse()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Episode",
DownloadId = null!,
Protocol = "torrent",
EpisodeId = 42,
SeriesId = 10
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.False(result);
}
[Fact]
public void IsRecordValid_WhenDownloadIdIsEmpty_ReturnsFalse()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Episode",
DownloadId = "",
Protocol = "torrent",
EpisodeId = 42,
SeriesId = 10
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.False(result);
}
#endregion
}
@@ -1,10 +1,8 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Interceptors;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Arr;
@@ -35,98 +33,4 @@ public class WhisparrV3ClientTests
_dryRunInterceptorMock.Object
);
}
#region IsRecordValid Tests
[Fact]
public void IsRecordValid_WhenMovieIdIsZero_ReturnsFalse()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Movie",
DownloadId = "abc123",
Protocol = "torrent",
MovieId = 0
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.False(result);
_loggerMock.Verify(
x => x.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("movie id missing")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public void IsRecordValid_WhenMovieIdIsSet_ReturnsTrue()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Movie",
DownloadId = "abc123",
Protocol = "torrent",
MovieId = 42
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.True(result);
}
[Fact]
public void IsRecordValid_WhenDownloadIdIsNull_ReturnsFalse()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Movie",
DownloadId = null!,
Protocol = "torrent",
MovieId = 42
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.False(result);
}
[Fact]
public void IsRecordValid_WhenDownloadIdIsEmpty_ReturnsFalse()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Movie",
DownloadId = "",
Protocol = "torrent",
MovieId = 42
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.False(result);
}
#endregion
}
File diff suppressed because it is too large. Load diff
@@ -275,6 +275,55 @@ public class QueueItemRemoverTests : IDisposable
#endregion
#region RemoveQueueItemAsync - SkipSearch Tests
[Fact]
public async Task RemoveQueueItemAsync_WhenSkipSearch_DoesNotPublishHuntRequest()
{
// Arrange
var request = CreateRemoveRequest(skipSearch: true);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_busMock.Verify(b => b.Publish(
It.IsAny<DownloadHuntRequest<SearchItem>>(),
It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task RemoveQueueItemAsync_WhenSkipSearch_AndHashIsNotRecurring_DoesNotModifyRecurringHashes()
{
// Arrange
var request = CreateRemoveRequest(skipSearch: true);
var hash = request.Record.DownloadId.ToLowerInvariant();
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert - hash was never in recurring, should still not be there
Assert.False(Striker.RecurringHashes.ContainsKey(hash));
}
#endregion
#region RemoveQueueItemAsync - HTTP Error Tests
[Fact]
@@ -377,7 +426,6 @@ public class QueueItemRemoverTests : IDisposable
[InlineData(DeleteReason.SlowSpeed)]
[InlineData(DeleteReason.SlowTime)]
[InlineData(DeleteReason.DownloadingMetadata)]
[InlineData(DeleteReason.MalwareFileFound)]
public async Task RemoveQueueItemAsync_PassesCorrectDeleteReason(DeleteReason deleteReason)
{
// Arrange
@@ -436,7 +484,8 @@ public class QueueItemRemoverTests : IDisposable
private static QueueItemRemoveRequest<SearchItem> CreateRemoveRequest(
InstanceType instanceType = InstanceType.Sonarr,
bool removeFromClient = true,
DeleteReason deleteReason = DeleteReason.Stalled)
DeleteReason deleteReason = DeleteReason.Stalled,
bool skipSearch = false)
{
return new QueueItemRemoveRequest<SearchItem>
{
@@ -446,6 +495,7 @@ public class QueueItemRemoverTests : IDisposable
Record = CreateQueueRecord(),
RemoveFromClient = removeFromClient,
DeleteReason = deleteReason,
SkipSearch = skipSearch,
JobRunId = Guid.NewGuid()
};
}
@@ -159,24 +159,21 @@ public class MalwareBlockerTests : IDisposable
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()), Times.Once);
}
[Fact]
public async Task ExecuteInternalAsync_WhenDeleteKnownMalwareEnabled_ProcessesAllArrs()
[Theory]
[InlineData(InstanceType.Radarr)]
[InlineData(InstanceType.Lidarr)]
[InlineData(InstanceType.Readarr)]
[InlineData(InstanceType.Whisparr)]
public async Task ExecuteInternalAsync_WhenArrTypeEnabled_ProcessesCorrectInstances(InstanceType instanceType)
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
var contentBlockerConfig = _fixture.DataContext.ContentBlockerConfigs.First();
contentBlockerConfig.DeleteKnownMalware = true;
// Need at least one blocklist enabled for processing to occur
contentBlockerConfig.Sonarr = new BlocklistSettings { Enabled = true };
_fixture.DataContext.SaveChanges();
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
EnableBlocklist(instanceType);
AddArrInstance(instanceType);
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrClientFactory
.Setup(x => x.GetClient(It.IsAny<InstanceType>(), It.IsAny<float>()))
.Setup(x => x.GetClient(instanceType, It.IsAny<float>()))
.Returns(mockArrClient.Object);
_fixture.ArrQueueIterator
@@ -192,9 +189,8 @@ public class MalwareBlockerTests : IDisposable
// Act
await sut.ExecuteAsync();
// Assert - Sonarr and Radarr processed because DeleteKnownMalware is true
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()), Times.Once);
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()), Times.Once);
// Assert
_fixture.ArrClientFactory.Verify(x => x.GetClient(instanceType, It.IsAny<float>()), Times.Once);
}
#endregion
@@ -215,6 +211,7 @@ public class MalwareBlockerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
@@ -225,7 +222,9 @@ public class MalwareBlockerTests : IDisposable
Id = 1,
DownloadId = "ignored-download-id",
Title = "Ignored Download",
Protocol = "torrent"
Protocol = "torrent",
SeriesId = 1,
EpisodeId = 1
};
_fixture.ArrQueueIterator
@@ -267,6 +266,7 @@ public class MalwareBlockerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
@@ -277,7 +277,9 @@ public class MalwareBlockerTests : IDisposable
Id = 1,
DownloadId = "torrent-download-id",
Title = "Torrent Download",
Protocol = "torrent"
Protocol = "torrent",
SeriesId = 1,
EpisodeId = 1
};
_fixture.ArrQueueIterator
@@ -325,6 +327,7 @@ public class MalwareBlockerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
@@ -401,6 +404,7 @@ public class MalwareBlockerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
@@ -472,6 +476,7 @@ public class MalwareBlockerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
@@ -482,7 +487,9 @@ public class MalwareBlockerTests : IDisposable
Id = 1,
DownloadId = "missing-download-id",
Title = "Missing Download",
Protocol = "torrent"
Protocol = "torrent",
SeriesId = 1,
EpisodeId = 1
};
_fixture.ArrQueueIterator
@@ -526,6 +533,142 @@ public class MalwareBlockerTests : IDisposable
);
}
[Fact]
public async Task ProcessInstanceAsync_SkipsItem_WhenMissingContentId_AndProcessNoContentIdIsFalse()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(false);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
{
Id = 1,
DownloadId = "no-content-id-download",
Title = "No Content ID Download",
Protocol = "torrent"
};
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
It.IsAny<IArrClient>(),
It.IsAny<ArrInstance>(),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
{
await callback([queueRecord]);
});
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_logger.Verify(
x => x.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("skip | item is missing the content id")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
_fixture.MessageBus.Verify(
x => x.Publish(
It.IsAny<QueueItemRemoveRequest<SeriesSearchItem>>(),
It.IsAny<CancellationToken>()
),
Times.Never
);
}
[Fact]
public async Task ProcessInstanceAsync_WhenMissingContentId_AndProcessNoContentIdIsTrue_PublishesRemoveRequestWithSkipSearch()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var contentBlockerConfig = _fixture.DataContext.ContentBlockerConfigs.First();
contentBlockerConfig.ProcessNoContentId = true;
_fixture.DataContext.SaveChanges();
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(false);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
{
Id = 1,
DownloadId = "no-content-id-download",
Title = "No Content ID Download",
Protocol = "torrent"
};
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
It.IsAny<IArrClient>(),
It.IsAny<ArrInstance>(),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
{
await callback([queueRecord]);
});
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.BlockUnwantedFilesAsync(
It.IsAny<string>(),
It.IsAny<List<string>>()
))
.ReturnsAsync(new BlockFilesResult
{
Found = true,
ShouldRemove = true,
IsPrivate = false,
DeleteReason = DeleteReason.AllFilesBlocked
});
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert - SkipSearch must be true because the item has no content ID
_fixture.MessageBus.Verify(
x => x.Publish(
It.Is<QueueItemRemoveRequest<SeriesSearchItem>>(r =>
r.SkipSearch == true &&
r.DeleteReason == DeleteReason.AllFilesBlocked
),
It.IsAny<CancellationToken>()
),
Times.Once
);
}
#endregion
#region Error Handling Tests
@@ -540,6 +683,7 @@ public class MalwareBlockerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
@@ -550,7 +694,9 @@ public class MalwareBlockerTests : IDisposable
Id = 1,
DownloadId = "error-download-id",
Title = "Error Download",
Protocol = "torrent"
Protocol = "torrent",
SeriesId = 1,
EpisodeId = 1
};
_fixture.ArrQueueIterator
@@ -605,5 +751,32 @@ public class MalwareBlockerTests : IDisposable
_fixture.DataContext.SaveChanges();
}
private void EnableBlocklist(InstanceType instanceType)
{
var config = _fixture.DataContext.ContentBlockerConfigs.First();
var settings = new BlocklistSettings { Enabled = true };
switch (instanceType)
{
case InstanceType.Radarr: config.Radarr = settings; break;
case InstanceType.Lidarr: config.Lidarr = settings; break;
case InstanceType.Readarr: config.Readarr = settings; break;
case InstanceType.Whisparr: config.Whisparr = settings; break;
default: throw new ArgumentOutOfRangeException(nameof(instanceType));
}
_fixture.DataContext.SaveChanges();
}
private void AddArrInstance(InstanceType instanceType)
{
switch (instanceType)
{
case InstanceType.Radarr: TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); break;
case InstanceType.Lidarr: TestDataContextFactory.AddLidarrInstance(_fixture.DataContext); break;
case InstanceType.Readarr: TestDataContextFactory.AddReadarrInstance(_fixture.DataContext); break;
case InstanceType.Whisparr: TestDataContextFactory.AddWhisparrInstance(_fixture.DataContext); break;
default: throw new ArgumentOutOfRangeException(nameof(instanceType));
}
}
#endregion
}
@@ -220,6 +220,7 @@ public class QueueCleanerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
@@ -230,7 +231,9 @@ public class QueueCleanerTests : IDisposable
Id = 1,
DownloadId = "ignored-download-id",
Title = "Ignored Download",
Protocol = "torrent"
Protocol = "torrent",
SeriesId = 1,
EpisodeId = 1
};
_fixture.ArrQueueIterator
@@ -275,6 +278,7 @@ public class QueueCleanerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
@@ -285,7 +289,9 @@ public class QueueCleanerTests : IDisposable
Id = 1,
DownloadId = "cached-download-id",
Title = "Cached Download",
Protocol = "torrent"
Protocol = "torrent",
SeriesId = 1,
EpisodeId = 1
};
_fixture.ArrQueueIterator
@@ -326,6 +332,7 @@ public class QueueCleanerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
mockArrClient.Setup(x => x.ShouldRemoveFromQueue(
It.IsAny<InstanceType>(),
It.IsAny<QueueRecord>(),
@@ -342,7 +349,9 @@ public class QueueCleanerTests : IDisposable
Id = 1,
DownloadId = "torrent-download-id",
Title = "Torrent Download",
Protocol = "torrent"
Protocol = "torrent",
SeriesId = 1,
EpisodeId = 1
};
_fixture.ArrQueueIterator
@@ -389,6 +398,7 @@ public class QueueCleanerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
@@ -458,6 +468,7 @@ public class QueueCleanerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
mockArrClient.Setup(x => x.ShouldRemoveFromQueue(
It.IsAny<InstanceType>(),
It.IsAny<QueueRecord>(),
@@ -474,7 +485,9 @@ public class QueueCleanerTests : IDisposable
Id = 1,
DownloadId = "missing-download-id",
Title = "Missing Download",
Protocol = "torrent"
Protocol = "torrent",
SeriesId = 1,
EpisodeId = 1
};
_fixture.ArrQueueIterator
@@ -527,6 +540,7 @@ public class QueueCleanerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
mockArrClient.Setup(x => x.ShouldRemoveFromQueue(
It.IsAny<InstanceType>(),
It.IsAny<QueueRecord>(),
@@ -543,7 +557,9 @@ public class QueueCleanerTests : IDisposable
Id = 1,
DownloadId = "download-id",
Title = "Test Download",
Protocol = "torrent"
Protocol = "torrent",
SeriesId = 1,
EpisodeId = 1
};
_fixture.ArrQueueIterator
@@ -595,6 +611,7 @@ public class QueueCleanerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
mockArrClient.Setup(x => x.ShouldRemoveFromQueue(
It.IsAny<InstanceType>(),
It.IsAny<QueueRecord>(),
@@ -656,6 +673,147 @@ public class QueueCleanerTests : IDisposable
);
}
[Fact]
public async Task ProcessInstanceAsync_SkipsItem_WhenMissingContentId_AndProcessNoContentIdIsFalse()
{
// Arrange
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(false);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
{
Id = 1,
DownloadId = "no-content-id-download",
Title = "No Content ID Download",
Protocol = "torrent"
};
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
It.IsAny<IArrClient>(),
It.IsAny<ArrInstance>(),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
{
await callback([queueRecord]);
});
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_logger.Verify(
x => x.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("skip | item is missing the content id")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
_fixture.MessageBus.Verify(
x => x.Publish(
It.IsAny<QueueItemRemoveRequest<SeriesSearchItem>>(),
It.IsAny<CancellationToken>()
),
Times.Never
);
}
[Fact]
public async Task ProcessInstanceAsync_WhenMissingContentId_AndProcessNoContentIdIsTrue_PublishesRemoveRequestWithSkipSearch()
{
// Arrange
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
var queueCleanerConfig = _fixture.DataContext.QueueCleanerConfigs.First();
queueCleanerConfig.ProcessNoContentId = true;
_fixture.DataContext.SaveChanges();
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(false);
mockArrClient.Setup(x => x.ShouldRemoveFromQueue(
It.IsAny<InstanceType>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<short>()
)).ReturnsAsync(false);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
{
Id = 1,
DownloadId = "no-content-id-download",
Title = "No Content ID Download",
Protocol = "torrent"
};
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
It.IsAny<IArrClient>(),
It.IsAny<ArrInstance>(),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
{
await callback([queueRecord]);
});
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.ShouldRemoveFromArrQueueAsync(
It.IsAny<string>(),
It.IsAny<List<string>>()
))
.ReturnsAsync(new DownloadCheckResult
{
Found = true,
ShouldRemove = true,
IsPrivate = false,
DeleteFromClient = true,
DeleteReason = DeleteReason.Stalled
});
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert - SkipSearch must be true because the item has no content ID
_fixture.MessageBus.Verify(
x => x.Publish(
It.Is<QueueItemRemoveRequest<SeriesSearchItem>>(r =>
r.SkipSearch == true &&
r.DeleteReason == DeleteReason.Stalled
),
It.IsAny<CancellationToken>()
),
Times.Once
);
}
#endregion
#region Error Handling Tests
@@ -669,6 +827,7 @@ public class QueueCleanerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
mockArrClient.Setup(x => x.ShouldRemoveFromQueue(
It.IsAny<InstanceType>(),
It.IsAny<QueueRecord>(),
@@ -685,7 +844,9 @@ public class QueueCleanerTests : IDisposable
Id = 1,
DownloadId = "error-download-id",
Title = "Error Download",
Protocol = "torrent"
Protocol = "torrent",
SeriesId = 1,
EpisodeId = 1
};
_fixture.ArrQueueIterator
@@ -744,6 +905,7 @@ public class QueueCleanerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
@@ -833,6 +995,7 @@ public class QueueCleanerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
@@ -905,6 +1068,7 @@ public class QueueCleanerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Lidarr, It.IsAny<float>()))
@@ -977,6 +1141,7 @@ public class QueueCleanerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Readarr, It.IsAny<float>()))
@@ -1049,6 +1214,7 @@ public class QueueCleanerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Whisparr, 2f))
@@ -1124,6 +1290,7 @@ public class QueueCleanerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Whisparr, 3f))
@@ -1196,6 +1363,7 @@ public class QueueCleanerTests : IDisposable
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Whisparr, 2f))
@@ -75,7 +75,6 @@ public static class TestDataContextFactory
{
Id = Guid.NewGuid(),
IgnoredDownloads = [],
DeleteKnownMalware = false,
DeletePrivate = false,
Sonarr = new BlocklistSettings { Enabled = false },
Radarr = new BlocklistSettings { Enabled = false },
@@ -118,34 +118,6 @@ public class BlocklistProviderTests : IDisposable
result.Count.ShouldBe(2);
}
[Fact]
public void GetMalwarePatterns_NotInCache_ReturnsEmptyBag()
{
// Act
var result = _provider.GetMalwarePatterns();
// Assert
result.ShouldNotBeNull();
result.ShouldBeEmpty();
}
[Fact]
public void GetMalwarePatterns_InCache_ReturnsCachedPatterns()
{
// Arrange
var patterns = new ConcurrentBag<string> { "known_malware.exe", "trojan*", "virus.dll" };
_cache.Set(CacheKeys.KnownMalwarePatterns(), patterns);
// Act
var result = _provider.GetMalwarePatterns();
// Assert
result.Count.ShouldBe(3);
result.ShouldContain("known_malware.exe");
result.ShouldContain("trojan*");
result.ShouldContain("virus.dll");
}
[Theory]
[InlineData(InstanceType.Sonarr)]
[InlineData(InstanceType.Radarr)]
@@ -271,12 +271,12 @@ public class NotificationPublisherTests
.Returns(providerMock.Object);
// Act
await _publisher.NotifyQueueItemDeleted(false, DeleteReason.MalwareFileFound);
await _publisher.NotifyQueueItemDeleted(false, DeleteReason.AllFilesBlocked);
// Assert
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
c => c.Data["Removed from client?"] == "False" &&
c.Data["Reason"] == "MalwareFileFound")), Times.Once);
c.Data["Reason"] == "AllFilesBlocked")), Times.Once);
}
#endregion
@@ -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" />
@@ -232,7 +232,7 @@ public class EventPublisher : IEventPublisher
public async Task PublishSearchNotTriggered(string hash, string itemName)
{
await PublishManualAsync(
"Replacement search was not triggered after removal because the item keeps coming back\nPlease trigger a manual search if needed",
"Replacement search was not triggered after removal\nPlease trigger a manual search if needed",
EventSeverity.Warning,
data: new { itemName, hash }
);
@@ -158,7 +158,7 @@ public abstract class ArrClient : IArrClient
public abstract Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items);
public virtual bool IsRecordValid(QueueRecord record)
public bool IsRecordValid(QueueRecord record)
{
if (string.IsNullOrEmpty(record.DownloadId))
{
@@ -169,6 +169,8 @@ public abstract class ArrClient : IArrClient
return true;
}
public abstract bool HasContentId(QueueRecord record);
/// <inheritdoc/>
public async Task HealthCheckAsync(ArrInstance arrInstance)
{
@@ -17,6 +17,13 @@ public interface IArrClient
bool IsRecordValid(QueueRecord record);
/// <summary>
/// Checks whether the record has an id (movie id, tv show id etc.)
/// </summary>
/// <param name="record">The record to check</param>
/// <returns>True if the record has an id, false otherwise</returns>
bool HasContentId(QueueRecord record);
/// <summary>
/// Tests the connection to an Arr instance
/// </summary>
@@ -87,16 +87,7 @@ public class LidarrClient : ArrClient, ILidarrClient
}
}
public override bool IsRecordValid(QueueRecord record)
{
if (record.ArtistId is 0 || record.AlbumId is 0)
{
_logger.LogDebug("skip | artist id and/or album id missing | {title}", record.Title);
return false;
}
return base.IsRecordValid(record);
}
public override bool HasContentId(QueueRecord record) => record.ArtistId is not 0 && record.AlbumId is not 0;
private static string GetSearchLog(
Uri instanceUrl,
@@ -92,16 +92,7 @@ public class RadarrClient : ArrClient, IRadarrClient
}
}
public override bool IsRecordValid(QueueRecord record)
{
if (record.MovieId is 0)
{
_logger.LogDebug("skip | movie id missing | {title}", record.Title);
return false;
}
return base.IsRecordValid(record);
}
public override bool HasContentId(QueueRecord record) => record.MovieId is not 0;
private static string GetSearchLog(Uri instanceUrl, RadarrCommand command, bool success, string? logContext)
{
@@ -92,16 +92,7 @@ public class ReadarrClient : ArrClient, IReadarrClient
}
}
public override bool IsRecordValid(QueueRecord record)
{
if (record.AuthorId is 0 || record.BookId is 0)
{
_logger.LogDebug("skip | author id and/or book id missing | {title}", record.Title);
return false;
}
return base.IsRecordValid(record);
}
public override bool HasContentId(QueueRecord record) => record.AuthorId is not 0 && record.BookId is not 0;
private static string GetSearchLog(Uri instanceUrl, ReadarrCommand command, bool success, string? logContext)
{
@@ -90,16 +90,7 @@ public class SonarrClient : ArrClient, ISonarrClient
}
}
public override bool IsRecordValid(QueueRecord record)
{
if (record.EpisodeId is 0 || record.SeriesId is 0)
{
_logger.LogDebug("skip | episode id and/or series id missing | {title}", record.Title);
return false;
}
return base.IsRecordValid(record);
}
public override bool HasContentId(QueueRecord record) => record.EpisodeId is not 0 && record.SeriesId is not 0;
private static string GetSearchLog(
SeriesSearchType searchType,
@@ -90,16 +90,7 @@ public class WhisparrV2Client : ArrClient, IWhisparrV2Client
}
}
public override bool IsRecordValid(QueueRecord record)
{
if (record.EpisodeId is 0 || record.SeriesId is 0)
{
_logger.LogDebug("skip | episode id and/or series id missing | {title}", record.Title);
return false;
}
return base.IsRecordValid(record);
}
public override bool HasContentId(QueueRecord record) => record.EpisodeId is not 0 && record.SeriesId is not 0;
private static string GetSearchLog(
SeriesSearchType searchType,
@@ -93,16 +93,7 @@ public class WhisparrV3Client : ArrClient, IWhisparrV3Client
}
}
public override bool IsRecordValid(QueueRecord record)
{
if (record.MovieId is 0)
{
_logger.LogDebug("skip | movie id missing | {title}", record.Title);
return false;
}
return base.IsRecordValid(record);
}
public override bool HasContentId(QueueRecord record) => record.MovieId is not 0;
private static string GetSearchLog(Uri instanceUrl, WhisparrV3Command command, bool success, string? logContext)
{
@@ -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;
}
}
@@ -68,7 +68,6 @@ public partial class DelugeService
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
ProcessFiles(contents.Contents, (name, file) =>
{
@@ -79,13 +78,6 @@ public partial class DelugeService
{
return;
}
if (malwareBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(name, malwarePatterns))
{
_logger.LogInformation("malware file found | {file} | {title}", file.Path, download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.MalwareFileFound;
}
if (file.Priority is 0)
{
@@ -73,8 +73,7 @@ public partial class QBitService
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
foreach (TorrentContent file in files)
{
if (!file.Index.HasValue)
@@ -84,14 +83,6 @@ public partial class QBitService
}
totalFiles++;
if (malwareBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(file.Name, malwarePatterns))
{
_logger.LogInformation("malware file found | {file} | {title}", file.Name, download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.MalwareFileFound;
return result;
}
if (file.Priority is TorrentContentPriority.Skip)
{
@@ -71,7 +71,6 @@ public partial class RTorrentService
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
List<(int Index, int Priority)> priorityUpdates = [];
@@ -85,13 +84,6 @@ public partial class RTorrentService
continue;
}
if (malwareBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(fileName, malwarePatterns))
{
_logger.LogInformation("malware file found | {file} | {title}", file.Path, download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.MalwareFileFound;
}
if (file.Priority == 0)
{
_logger.LogTrace("File is already skipped | {file}", file.Path);
@@ -56,8 +56,7 @@ public partial class TransmissionService
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
for (int i = 0; i < download.Files.Length; i++)
{
if (download.FileStats?[i].Wanted == null)
@@ -67,15 +66,7 @@ public partial class TransmissionService
}
totalFiles++;
if (malwareBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(download.Files[i].Name, malwarePatterns))
{
_logger.LogInformation("malware file found | {file} | {title}", download.Files[i].Name, download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.MalwareFileFound;
return result;
}
if (!download.FileStats[i].Wanted.Value)
{
_logger.LogTrace("File is already skipped | {file}", download.Files[i].Name);
@@ -61,18 +61,9 @@ public partial class UTorrentService
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
for (int i = 0; i < files.Count; i++)
{
if (malwareBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(files[i].Name, malwarePatterns))
{
_logger.LogInformation("malware file found | {file} | {title}", files[i].Name, download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.MalwareFileFound;
return result;
}
var file = files[i];
if (file.Priority == 0) // Already skipped
@@ -21,4 +21,6 @@ public sealed record QueueItemRemoveRequest<T>
public required DeleteReason DeleteReason { get; init; }
public required Guid JobRunId { get; init; }
public bool SkipSearch { get; init; }
}
@@ -73,15 +73,20 @@ public sealed class QueueItemRemover : IQueueItemRemover
ContextProvider.Set(nameof(InstanceType), request.InstanceType);
ContextProvider.Set(ContextProvider.Keys.Version, request.Instance.Version);
// Use the new centralized EventPublisher method
await _eventPublisher.PublishQueueItemDeleted(request.RemoveFromClient, request.DeleteReason);
// If recurring, do not search for replacement
string hash = request.Record.DownloadId.ToLowerInvariant();
if (Striker.RecurringHashes.ContainsKey(hash))
var isRecurring = Striker.RecurringHashes.ContainsKey(hash);
if (isRecurring || request.SkipSearch)
{
await _eventPublisher.PublishSearchNotTriggered(request.Record.DownloadId, request.Record.Title);
Striker.RecurringHashes.Remove(hash, out _);
if (isRecurring)
{
Striker.RecurringHashes.Remove(hash, out _);
}
return;
}
@@ -131,7 +131,8 @@ public abstract class GenericHandler : IHandler
QueueRecord record,
bool isPack,
bool removeFromClient,
DeleteReason deleteReason
DeleteReason deleteReason,
bool skipSearch = false
)
{
if (_cache.TryGetValue(downloadRemovalKey, out bool _))
@@ -139,7 +140,7 @@ public abstract class GenericHandler : IHandler
_logger.LogDebug("skip removal request | already marked for removal | {title}", record.Title);
return;
}
if (instanceType is InstanceType.Sonarr || (instanceType is InstanceType.Whisparr && instance.Version is 2))
{
QueueItemRemoveRequest<SeriesSearchItem> removeRequest = new()
@@ -150,7 +151,8 @@ public abstract class GenericHandler : IHandler
SearchItem = (SeriesSearchItem)GetRecordSearchItem(instanceType, instance.Version, record, isPack),
RemoveFromClient = removeFromClient,
DeleteReason = deleteReason,
JobRunId = ContextProvider.GetJobRunId()
JobRunId = ContextProvider.GetJobRunId(),
SkipSearch = skipSearch
};
await _messageBus.Publish(removeRequest);
@@ -165,7 +167,8 @@ public abstract class GenericHandler : IHandler
SearchItem = GetRecordSearchItem(instanceType, instance.Version, record, isPack),
RemoveFromClient = removeFromClient,
DeleteReason = deleteReason,
JobRunId = ContextProvider.GetJobRunId()
JobRunId = ContextProvider.GetJobRunId(),
SkipSearch = skipSearch
};
await _messageBus.Publish(removeRequest);
@@ -1,4 +1,4 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
@@ -48,9 +48,13 @@ public sealed class MalwareBlocker : GenericHandler
return;
}
var config = ContextProvider.Get<ContentBlockerConfig>();
ContentBlockerConfig malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
if (!config.Sonarr.Enabled && !config.Radarr.Enabled && !config.Lidarr.Enabled && !config.Readarr.Enabled && !config.Whisparr.Enabled)
if (!malwareBlockerConfig.Sonarr.Enabled &&
!malwareBlockerConfig.Radarr.Enabled &&
!malwareBlockerConfig.Lidarr.Enabled &&
!malwareBlockerConfig.Readarr.Enabled &&
!malwareBlockerConfig.Whisparr.Enabled)
{
_logger.LogWarning("No blocklists are enabled");
return;
@@ -64,27 +68,27 @@ public sealed class MalwareBlocker : GenericHandler
var readarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr));
var whisparrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Whisparr));
if (config.Sonarr.Enabled || config.DeleteKnownMalware)
if (malwareBlockerConfig.Sonarr.Enabled)
{
await ProcessArrConfigAsync(sonarrConfig);
}
if (config.Radarr.Enabled || config.DeleteKnownMalware)
if (malwareBlockerConfig.Radarr.Enabled)
{
await ProcessArrConfigAsync(radarrConfig);
}
if (config.Lidarr.Enabled || config.DeleteKnownMalware)
if (malwareBlockerConfig.Lidarr.Enabled)
{
await ProcessArrConfigAsync(lidarrConfig);
}
if (config.Readarr.Enabled || config.DeleteKnownMalware)
if (malwareBlockerConfig.Readarr.Enabled)
{
await ProcessArrConfigAsync(readarrConfig);
}
if (config.Whisparr.Enabled || config.DeleteKnownMalware)
if (malwareBlockerConfig.Whisparr.Enabled)
{
await ProcessArrConfigAsync(whisparrConfig);
}
@@ -107,33 +111,43 @@ public sealed class MalwareBlocker : GenericHandler
IReadOnlyList<IDownloadService> downloadServices = await GetInitializedDownloadServicesAsync();
var config = ContextProvider.Get<ContentBlockerConfig>();
await _arrArrQueueIterator.Iterate(arrClient, instance, async items =>
{
var groups = items
.GroupBy(x => x.DownloadId)
.ToList();
foreach (var group in groups)
{
if (group.Any(x => !arrClient.IsRecordValid(x)))
{
continue;
}
QueueRecord record = group.First();
_logger.LogTrace("processing | {title} | {id}", record.Title, record.DownloadId);
if (!arrClient.IsRecordValid(record))
{
continue;
}
if (ignoredDownloads.Contains(record.DownloadId, StringComparer.InvariantCultureIgnoreCase))
{
_logger.LogInformation("skip | {title} | ignored", record.Title);
continue;
}
_logger.LogTrace("processing | {title} | {id}", record.Title, record.DownloadId);
bool hasContentId = arrClient.HasContentId(record);
if (!hasContentId)
{
if (!config.ProcessNoContentId)
{
_logger.LogInformation("skip | item is missing the content id | {title}", record.Title);
continue;
}
_logger.LogDebug("item is missing the content id | {title}", record.Title);
}
string downloadRemovalKey = CacheKeys.DownloadMarkedForRemoval(record.DownloadId, instance.Url);
@@ -196,8 +210,6 @@ public sealed class MalwareBlocker : GenericHandler
continue;
}
var config = ContextProvider.Get<ContentBlockerConfig>();
bool removeFromClient = true;
if (result.IsPrivate && !config.DeletePrivate)
@@ -212,7 +224,8 @@ public sealed class MalwareBlocker : GenericHandler
record,
group.Count() > 1,
removeFromClient,
result.DeleteReason
result.DeleteReason,
skipSearch: !hasContentId
);
}
});
@@ -106,18 +106,11 @@ public sealed class QueueCleaner : GenericHandler
var groups = items
.GroupBy(x => x.DownloadId)
.ToList();
foreach (var group in groups)
{
if (group.Any(x => !arrClient.IsRecordValid(x)))
{
continue;
}
QueueRecord record = group.First();
_logger.LogTrace("processing | {title} | {id}", record.Title, record.DownloadId);
if (!arrClient.IsRecordValid(record))
{
continue;
@@ -129,6 +122,21 @@ public sealed class QueueCleaner : GenericHandler
continue;
}
_logger.LogDebug("processing | {title} | {id}", record.Title, record.DownloadId);
bool hasContentId = arrClient.HasContentId(record);
if (!hasContentId)
{
if (!queueCleanerConfig.ProcessNoContentId)
{
_logger.LogInformation("skip | item is missing the content id | {title}", record.Title);
continue;
}
_logger.LogDebug("item is missing the content id | {title}", record.Title);
}
string downloadRemovalKey = CacheKeys.DownloadMarkedForRemoval(record.DownloadId, instance.Url);
if (_cache.TryGetValue(downloadRemovalKey, out bool _))
@@ -190,7 +198,8 @@ public sealed class QueueCleaner : GenericHandler
record,
group.Count() > 1,
removeFromClient,
downloadCheckResult.DeleteReason
downloadCheckResult.DeleteReason,
skipSearch: !hasContentId
);
continue;
@@ -218,7 +227,8 @@ public sealed class QueueCleaner : GenericHandler
record,
group.Count() > 1,
removeFromClient,
DeleteReason.FailedImport
DeleteReason.FailedImport,
skipSearch: !hasContentId
);
continue;
@@ -23,8 +23,6 @@ public sealed class BlocklistProvider : IBlocklistProvider
private readonly Dictionary<string, DateTime> _lastLoadTimes = new();
private const int DefaultLoadIntervalHours = 4;
private const int FastLoadIntervalMinutes = 5;
private const string MalwareListUrl = "https://cleanuparr.pages.dev/static/known_malware_file_name_patterns";
private const string MalwareListKey = "MALWARE_PATTERNS";
public BlocklistProvider(
ILogger<BlocklistProvider> logger,
@@ -72,10 +70,7 @@ public sealed class BlocklistProvider : IBlocklistProvider
changedCount++;
}
}
// Always check and update malware patterns
await LoadMalwarePatternsAsync(fileReader);
if (changedCount > 0)
{
_logger.LogInformation("Successfully loaded {count} blocklists", changedCount);
@@ -109,17 +104,10 @@ public sealed class BlocklistProvider : IBlocklistProvider
public ConcurrentBag<Regex> GetRegexes(InstanceType instanceType)
{
_cache.TryGetValue(CacheKeys.BlocklistRegexes(instanceType), out ConcurrentBag<Regex>? regexes);
return regexes ?? [];
}
public ConcurrentBag<string> GetMalwarePatterns()
{
_cache.TryGetValue(CacheKeys.KnownMalwarePatterns(), out ConcurrentBag<string>? patterns);
return patterns ?? [];
}
private async Task<bool> EnsureInstanceLoadedAsync(BlocklistSettings settings, InstanceType instanceType, FileReader fileReader)
{
if (!settings.Enabled || string.IsNullOrEmpty(settings.BlocklistPath))
@@ -165,47 +153,9 @@ public sealed class BlocklistProvider : IBlocklistProvider
{
return true;
}
return DateTime.UtcNow - lastLoad >= interval;
}
private async Task LoadMalwarePatternsAsync(FileReader fileReader)
{
var malwareInterval = TimeSpan.FromMinutes(FastLoadIntervalMinutes);
if (!ShouldReloadBlocklist(MalwareListKey, malwareInterval))
{
return;
}
try
{
_logger.LogDebug("Loading malware patterns");
string[] filePatterns = await fileReader.ReadContentAsync(MalwareListUrl);
long startTime = Stopwatch.GetTimestamp();
ParallelOptions options = new() { MaxDegreeOfParallelism = 5 };
ConcurrentBag<string> patterns = [];
Parallel.ForEach(filePatterns, options, pattern =>
{
patterns.Add(pattern);
});
TimeSpan elapsed = Stopwatch.GetElapsedTime(startTime);
_cache.Set(CacheKeys.KnownMalwarePatterns(), patterns);
_lastLoadTimes[MalwareListKey] = DateTime.UtcNow;
_logger.LogDebug("loaded {count} known malware patterns", patterns.Count);
_logger.LogDebug("malware patterns loaded in {elapsed} ms", elapsed.TotalMilliseconds);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load malware patterns from {url}", MalwareListUrl);
}
}
private async Task LoadPatternsAndRegexesAsync(BlocklistSettings blocklistSettings, InstanceType instanceType, FileReader fileReader)
{
@@ -20,16 +20,6 @@ public class FilenameEvaluator : IFilenameEvaluator
return IsValidAgainstPatterns(filename, type, patterns) && IsValidAgainstRegexes(filename, type, regexes);
}
public bool IsKnownMalware(string filename, ConcurrentBag<string> malwarePatterns)
{
if (malwarePatterns.Count is 0)
{
return false;
}
return malwarePatterns.Any(pattern => filename.Contains(pattern, StringComparison.InvariantCultureIgnoreCase));
}
private static bool IsValidAgainstPatterns(string filename, BlocklistType type, ConcurrentBag<string> patterns)
{
if (patterns.Count is 0)
@@ -13,6 +13,4 @@ public interface IBlocklistProvider
ConcurrentBag<string> GetPatterns(InstanceType instanceType);
ConcurrentBag<Regex> GetRegexes(InstanceType instanceType);
ConcurrentBag<string> GetMalwarePatterns();
}
@@ -7,6 +7,4 @@ namespace Cleanuparr.Infrastructure.Features.MalwareBlocker;
public interface IFilenameEvaluator
{
bool IsValid(string filename, BlocklistType type, ConcurrentBag<string> patterns, ConcurrentBag<Regex> regexes);
bool IsKnownMalware(string filename, ConcurrentBag<string> malwarePatterns);
}
@@ -8,8 +8,6 @@ public static class CacheKeys
public static string BlocklistPatterns(InstanceType instanceType) => $"{instanceType.ToString()}_patterns";
public static string BlocklistRegexes(InstanceType instanceType) => $"{instanceType.ToString()}_regexes";
public static string KnownMalwarePatterns() => "KNOWN_MALWARE_PATTERNS";
public static string IgnoredDownloads(string name) => $"{name}_ignored";
public static string DownloadMarkedForRemoval(string hash, Uri url) => $"remove_{hash.ToLowerInvariant()}_{url}";
@@ -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
}
@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddProcessMissingContentId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "process_no_content_id",
table: "queue_cleaner_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "process_no_content_id",
table: "content_blocker_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "process_no_content_id",
table: "queue_cleaner_configs");
migrationBuilder.DropColumn(
name: "process_no_content_id",
table: "content_blocker_configs");
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class RemoveKnownMalwareOption : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "delete_known_malware",
table: "content_blocker_configs");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "delete_known_malware",
table: "content_blocker_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
}
}
@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class RemoveProcessMissingId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "process_no_content_id",
table: "queue_cleaner_configs");
migrationBuilder.DropColumn(
name: "process_no_content_id",
table: "content_blocker_configs");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "process_no_content_id",
table: "queue_cleaner_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "process_no_content_id",
table: "content_blocker_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
}
}
@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddProcessMissingId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "process_no_content_id",
table: "queue_cleaner_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "process_no_content_id",
table: "content_blocker_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "process_no_content_id",
table: "queue_cleaner_configs");
migrationBuilder.DropColumn(
name: "process_no_content_id",
table: "content_blocker_configs");
}
}
}
@@ -389,10 +389,6 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("DeleteKnownMalware")
.HasColumnType("INTEGER")
.HasColumnName("delete_known_malware");
b.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("delete_private");
@@ -410,6 +406,10 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("ignored_downloads");
b.Property<bool>("ProcessNoContentId")
.HasColumnType("INTEGER")
.HasColumnName("process_no_content_id");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
@@ -920,6 +920,10 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("ignored_downloads");
b.Property<bool>("ProcessNoContentId")
.HasColumnType("INTEGER")
.HasColumnName("process_no_content_id");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
@@ -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");
@@ -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; }
@@ -19,8 +19,8 @@ public sealed record ContentBlockerConfig : IJobConfig
public bool IgnorePrivate { get; set; }
public bool DeletePrivate { get; set; }
public bool DeleteKnownMalware { get; set; }
public bool ProcessNoContentId { get; set; }
public BlocklistSettings Sonarr { get; set; } = new();
@@ -22,7 +22,9 @@ public sealed record QueueCleanerConfig : IJobConfig
public FailedImportConfig FailedImport { get; set; } = new();
public List<string> IgnoredDownloads { get; set; } = [];
public bool ProcessNoContentId { get; set; }
public ushort DownloadingMetadataMaxStrikes { get; set; }
public List<StallRule> StallRules { 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)
+245 -226
View File
@@ -279,13 +279,13 @@
}
},
"node_modules/@angular-devkit/architect": {
"version": "0.2102.0",
"resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.0.tgz",
"integrity": "sha512-kYFwTNzToG2SJMxj2f41w3QRtdqlrFuF+bpZrtIaHOP078Ktld8EPIp9KqB0Y46Vvs69ifby5Q1/wPD9wA3iaw==",
"version": "0.2102.1",
"resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.1.tgz",
"integrity": "sha512-x2Qqz6oLYvEh9UBUG0AP1A4zROO/VP+k+zM9+4c2uZw1uqoBQFmutqgzncjVU7cR9R0RApgx9JRZHDFtQru68w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@angular-devkit/core": "21.2.0",
"@angular-devkit/core": "21.2.1",
"rxjs": "7.8.2"
},
"bin": {
@@ -298,9 +298,9 @@
}
},
"node_modules/@angular-devkit/core": {
"version": "21.2.0",
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.0.tgz",
"integrity": "sha512-HZdTn46Ca6qbb9Zef8R/+TWsk6mNKRm4rJyL3PxHP6HnVCwSPNZ0LNN9BjVREBs+UlRdXqBGFBZh5D1nBgu5GQ==",
"version": "21.2.1",
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.1.tgz",
"integrity": "sha512-TpXGjERqVPN8EPt7LdmWAwh0oNQ/6uWFutzGZiXhJy81n1zb1O1XrqhRAmvP1cAo5O+na6IV2JkkCmxL6F8GUg==",
"license": "MIT",
"dependencies": {
"ajv": "8.18.0",
@@ -325,13 +325,13 @@
}
},
"node_modules/@angular-devkit/schematics": {
"version": "21.2.0",
"resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.0.tgz",
"integrity": "sha512-3kn3FI5v7BQ7Zct6raek+WgvyDwOJ8wElbyC903GxMQCDBRGGcevhHvTAIHhknihEsrgplzPhTlWeMbk1JfdFg==",
"version": "21.2.1",
"resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.1.tgz",
"integrity": "sha512-CWoamHaasAHMjHcYqxbj0tMnoXxdGotcAz2SpiuWtH28Lnf5xfbTaJn/lwdMP8Wdh4tgA+uYh2l45A5auCwmkw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@angular-devkit/core": "21.2.0",
"@angular-devkit/core": "21.2.1",
"jsonc-parser": "3.3.1",
"magic-string": "0.30.21",
"ora": "9.3.0",
@@ -344,9 +344,9 @@
}
},
"node_modules/@angular-eslint/builder": {
"version": "21.2.0",
"resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-21.2.0.tgz",
"integrity": "sha512-wcp3J9cbrDwSeI/o1D/DSvMQa8zpKjc5WhRGTx33omhWijCfiVNEAiBLWiEx5Sb/dWcoX8yFNWY5jSgFVy9Sjw==",
"version": "21.3.0",
"resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-21.3.0.tgz",
"integrity": "sha512-26QUUouei52biUFAlJSrWNAU9tuF2miKwd8uHdxWwCF31xz+OxC5+NfudWvt1AFaYow7gWueX1QX3rNNtSPDrg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -355,89 +355,89 @@
},
"peerDependencies": {
"@angular/cli": ">= 21.0.0 < 22.0.0",
"eslint": "^8.57.0 || ^9.0.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": "*"
}
},
"node_modules/@angular-eslint/bundled-angular-compiler": {
"version": "21.2.0",
"resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-21.2.0.tgz",
"integrity": "sha512-J0DWL+j6t9ItFIyIADvzHGqwDA1qfVJ9bx+oTmJ/Hlo7cUpIRoXpcTXpug0CEEABFH0RfDu6PDG2b0FoZ1+7bg==",
"version": "21.3.0",
"resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-21.3.0.tgz",
"integrity": "sha512-l521I24J9gJxyMbRkrM24Tc7W8J8BP+TDAmVs2nT8+lXbS3kg8QpWBRtd+hNUgq6o+vt+lKBkytnEfu8OiqeRg==",
"dev": true,
"license": "MIT"
},
"node_modules/@angular-eslint/eslint-plugin": {
"version": "21.2.0",
"resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-21.2.0.tgz",
"integrity": "sha512-X2Qn2viDsjm91CEMxNrxDH3qkKpp6un0C1F1BW2p/m9J4AUVfOcXwWz9UpHFSHTRQ+YlTJbiH1ZwwAPeKhFaxA==",
"version": "21.3.0",
"resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-21.3.0.tgz",
"integrity": "sha512-Whf/AUUBekOlfSJRS78m76YGrBQAZ3waXE7oOdlW5xEQvn8jBDN9EGuNnjg/syZzvzjK4ZpYC4g1XYXrc+fQIg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@angular-eslint/bundled-angular-compiler": "21.2.0",
"@angular-eslint/utils": "21.2.0",
"@angular-eslint/bundled-angular-compiler": "21.3.0",
"@angular-eslint/utils": "21.3.0",
"ts-api-utils": "^2.1.0"
},
"peerDependencies": {
"@typescript-eslint/utils": "^7.11.0 || ^8.0.0",
"eslint": "^8.57.0 || ^9.0.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": "*"
}
},
"node_modules/@angular-eslint/eslint-plugin-template": {
"version": "21.2.0",
"resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-21.2.0.tgz",
"integrity": "sha512-lJ13Dj0DjR6YiceQR0sRbyWzSzOQ6uZPwK9CJUF3wuZjYAUvL1D61zaU9QrVLtf89NVOxv+dYZHDdu3IDeIqbA==",
"version": "21.3.0",
"resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-21.3.0.tgz",
"integrity": "sha512-lVixd/KypPWgA/5/pUOhJV9MTcaHjYZEqyOi+IiLk+h+maGxn6/s6Ot+20n+XGS85zAgOY+qUw6EEQ11hoojIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@angular-eslint/bundled-angular-compiler": "21.2.0",
"@angular-eslint/utils": "21.2.0",
"@angular-eslint/bundled-angular-compiler": "21.3.0",
"@angular-eslint/utils": "21.3.0",
"aria-query": "5.3.2",
"axobject-query": "4.1.0"
},
"peerDependencies": {
"@angular-eslint/template-parser": "21.2.0",
"@angular-eslint/template-parser": "21.3.0",
"@typescript-eslint/types": "^7.11.0 || ^8.0.0",
"@typescript-eslint/utils": "^7.11.0 || ^8.0.0",
"eslint": "^8.57.0 || ^9.0.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": "*"
}
},
"node_modules/@angular-eslint/template-parser": {
"version": "21.2.0",
"resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-21.2.0.tgz",
"integrity": "sha512-TCb3qYOC/uXKZCo56cJ6N9sHeWdFhyVqrbbYfFjTi09081T6jllgHDZL5Ms7gOMNY8KywWGGbhxwvzeA0RwTgA==",
"version": "21.3.0",
"resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-21.3.0.tgz",
"integrity": "sha512-ysyou1zAY6M6rSZNdIcYKGd4nk6TCapamyFNB3ivmTlVZ0O35TS9o/rJ0aUttuHgDp+Ysgs3ql+LA746PXgCyQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@angular-eslint/bundled-angular-compiler": "21.2.0",
"eslint-scope": "^9.0.0"
"@angular-eslint/bundled-angular-compiler": "21.3.0",
"eslint-scope": "^9.1.1"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": "*"
}
},
"node_modules/@angular-eslint/utils": {
"version": "21.2.0",
"resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-21.2.0.tgz",
"integrity": "sha512-E19/hkuvHoNFvctBkmEiGWpy2bbC6cgbr3GNVrn2nGtbI4jnwnDFCGHv50I4LBfvj0PA9E6TWe73ejJ5qoMJWQ==",
"version": "21.3.0",
"resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-21.3.0.tgz",
"integrity": "sha512-oNigH6w3l+owTMboj/uFG0tHOy43uH8BpQRtBOQL1/s2+5in/BJ2Fjobv3SyizxTgeJ1FhRefbkT8GmVjK7jAA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@angular-eslint/bundled-angular-compiler": "21.2.0"
"@angular-eslint/bundled-angular-compiler": "21.3.0"
},
"peerDependencies": {
"@typescript-eslint/utils": "^7.11.0 || ^8.0.0",
"eslint": "^8.57.0 || ^9.0.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": "*"
}
},
"node_modules/@angular/animations": {
"version": "21.2.0",
"resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.2.0.tgz",
"integrity": "sha512-SzlLoMT/r5wKqPicx5okCAiN5UD5+VE7x/F1G6gSJCcnBfbK5PqHPUmDnMW4jw9Ode06KZDT7ntstn6fG+Ld8w==",
"version": "21.2.2",
"resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.2.2.tgz",
"integrity": "sha512-h+BKgBDI04Euu+SfEuvOOT/oFO31yg/DgFLvOBFTxab9dWoaeR3j4JsjMUTRJ+HmvAeo1jUm1F5vUf2Bn63Q+A==",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -447,18 +447,18 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@angular/core": "21.2.0"
"@angular/core": "21.2.2"
}
},
"node_modules/@angular/build": {
"version": "21.2.0",
"resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.0.tgz",
"integrity": "sha512-K0EqiHz2y7TSyD4adWD0+C/P9khKlrsSWavXWxGRvoSJC/H3I3SK5Z6BWwftBibXR1Fis7njwvl5IGAlQrDchA==",
"version": "21.2.1",
"resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.1.tgz",
"integrity": "sha512-cUpLNHJp9taII/FOcJHHfQYlMcZSRaf6eIxgSNS6Xfx1CeGoJNDN+J8+GFk+H1CPJt1EvbfyZ+dE5DbsgTD/QQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "2.3.0",
"@angular-devkit/architect": "0.2102.0",
"@angular-devkit/architect": "0.2102.1",
"@babel/core": "7.29.0",
"@babel/helper-annotate-as-pure": "7.27.3",
"@babel/helper-split-export-declaration": "7.24.7",
@@ -501,7 +501,7 @@
"@angular/platform-browser": "^21.0.0",
"@angular/platform-server": "^21.0.0",
"@angular/service-worker": "^21.0.0",
"@angular/ssr": "^21.2.0",
"@angular/ssr": "^21.2.1",
"karma": "^6.4.0",
"less": "^4.2.0",
"ng-packagr": "^21.0.0",
@@ -551,9 +551,9 @@
}
},
"node_modules/@angular/cdk": {
"version": "21.2.0",
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.2.0.tgz",
"integrity": "sha512-1P0TNL1F51NC7JAaXabaAHY7Y1zBloLSZXfml1POa4a116V+y/QZfPGsxM0LwD1qSSXhSb2LNl7duTtJAP39bA==",
"version": "21.2.1",
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.2.1.tgz",
"integrity": "sha512-JUFV8qLnO7CU5v4W0HzXSQrFkkJ4RH/qqdwrf9lup7YEnsLxB7cTGhsVisc9pWKAJsoNZ4pXCVOkqKc1mFL7dw==",
"license": "MIT",
"dependencies": {
"parse5": "^8.0.0",
@@ -567,20 +567,20 @@
}
},
"node_modules/@angular/cli": {
"version": "21.2.0",
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.0.tgz",
"integrity": "sha512-yaGEpckqgOemcHkoWeH92i9eNrcbr9iE/dnxL+Du6s9spTAXJ2jjtYfszhmowuQZkCK5rjecMb8ctNtHlaGCjg==",
"version": "21.2.1",
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.1.tgz",
"integrity": "sha512-5SRfMTgwFj1zXOpfeZWHsxZBni0J4Xz7/CbewG47D6DmbstOrSdgt6eNzJ62R650t0G9dpri2YvToZgImtbjOQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@angular-devkit/architect": "0.2102.0",
"@angular-devkit/core": "21.2.0",
"@angular-devkit/schematics": "21.2.0",
"@angular-devkit/architect": "0.2102.1",
"@angular-devkit/core": "21.2.1",
"@angular-devkit/schematics": "21.2.1",
"@inquirer/prompts": "7.10.1",
"@listr2/prompt-adapter-inquirer": "3.0.5",
"@modelcontextprotocol/sdk": "1.26.0",
"@schematics/angular": "21.2.0",
"@schematics/angular": "21.2.1",
"@yarnpkg/lockfile": "1.1.0",
"algoliasearch": "5.48.1",
"ini": "6.0.0",
@@ -603,9 +603,9 @@
}
},
"node_modules/@angular/common": {
"version": "21.2.0",
"resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.0.tgz",
"integrity": "sha512-6zJMPi0i/XDniEgv3/t2BjuDHiOG44lgIR5PYyxqGpgJ0kqB5hku/0TuentNEi1VnBYgthnfhjek7c+lakXmhw==",
"version": "21.2.2",
"resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.2.tgz",
"integrity": "sha512-xpVYV+MgqWzdjTCFxe3uJGpFOc84YrO4H4oX9HkzI5yQ5OLkQlndtq+OAUK8e330iacg4XHArft3SNDj1LaFfg==",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -615,14 +615,14 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@angular/core": "21.2.0",
"@angular/core": "21.2.2",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/compiler": {
"version": "21.2.0",
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.0.tgz",
"integrity": "sha512-0RPkma8UVNpse/VJcXT9w6SKzTMz4J/uMGj0l9enM1frg9xrx1fwi/lLmaVV9Nr9LfqPjQdxNFFlvaBB7g/2zg==",
"version": "21.2.2",
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.2.tgz",
"integrity": "sha512-k7P0EH8I/Iwf2uRalSqhfokFbItTwdH7CmBJ7RKTRIH4FcrQcnqHetNKUMCOYXZtnlHIAnTpG+C+T4+6GTpYFg==",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -633,9 +633,9 @@
}
},
"node_modules/@angular/compiler-cli": {
"version": "21.2.0",
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.0.tgz",
"integrity": "sha512-gZd58p0/JjgdxMX3v+LjCB6e3dBIfNVr/YzXoh55TfffdBCUQY94hl1+DFQkJ72K5EX+1zbaz03dIm30kw1bGw==",
"version": "21.2.2",
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.2.tgz",
"integrity": "sha512-TFg2wXUZ1FdUikNyR27PxuCXuqqlJhL6Mr/cBYuc4HbtBfgKw5FLffbI/iLubBEs55W5ApuYpBVuXKGoZp9SRQ==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -657,7 +657,7 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@angular/compiler": "21.2.0",
"@angular/compiler": "21.2.2",
"typescript": ">=5.9 <6.1"
},
"peerDependenciesMeta": {
@@ -667,9 +667,9 @@
}
},
"node_modules/@angular/core": {
"version": "21.2.0",
"resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.0.tgz",
"integrity": "sha512-VnTbmZq3g3Q+s3nCZ8VUDMLjMezOg/bqUxAJ/DrRWCrEcTP5JO3mrNPs3FHj+qlB0T+BQP7uQv6QTzPVKybwoA==",
"version": "21.2.2",
"resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.2.tgz",
"integrity": "sha512-ljiyiFjR6dgK27CNlOcMrjsDPYKFf2Rl89WLwGEGMOj0cJg/PSLQqpW/fbSkSB3SDgwG/WhXQ4Wrw525OKMupg==",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -679,7 +679,7 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@angular/compiler": "21.2.0",
"@angular/compiler": "21.2.2",
"rxjs": "^6.5.3 || ^7.4.0",
"zone.js": "~0.15.0 || ~0.16.0"
},
@@ -693,9 +693,9 @@
}
},
"node_modules/@angular/forms": {
"version": "21.2.0",
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.0.tgz",
"integrity": "sha512-NduUtPWLauH/FLayEDkLyaKAGqKzXbcfO7468LOWCXN3crhNVQyIWRQPOUcdpoJwDAGLpN85m3DhJhXNnA9c5w==",
"version": "21.2.2",
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.2.tgz",
"integrity": "sha512-uiuL8uy4OpcbB0zRZ8TcvYXxe+GZC+XkCAptfrG+yAyzm4cOrqCilOZsMG6bVdOb2sjc2islk9CVDZZOju28/Q==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
@@ -705,16 +705,16 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@angular/common": "21.2.0",
"@angular/core": "21.2.0",
"@angular/platform-browser": "21.2.0",
"@angular/common": "21.2.2",
"@angular/core": "21.2.2",
"@angular/platform-browser": "21.2.2",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/platform-browser": {
"version": "21.2.0",
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.0.tgz",
"integrity": "sha512-IUGukpvvT2B5Dl76qzk6rY7UIHUT9u4BhT2AwVz+5JqcX9KwQtYD17Gt7wj6bvIgCXKWG+CfN8Zd9DECOCYWjg==",
"version": "21.2.2",
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.2.tgz",
"integrity": "sha512-6cHfHi/lRCUPNGO0eJeYRIpu8vM+CMMS2Wv/psOUwvl/5+RC92hfBEZxzQiF/5X9A170bJabaMFQC5fA7pkF2g==",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -724,9 +724,9 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@angular/animations": "21.2.0",
"@angular/common": "21.2.0",
"@angular/core": "21.2.0"
"@angular/animations": "21.2.2",
"@angular/common": "21.2.2",
"@angular/core": "21.2.2"
},
"peerDependenciesMeta": {
"@angular/animations": {
@@ -735,9 +735,9 @@
}
},
"node_modules/@angular/router": {
"version": "21.2.0",
"resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.0.tgz",
"integrity": "sha512-siliJ+jJRUCRZ0cdkqc7zww9Didz56Z0Z2YPIuR2n5TZLiuJY+jAf6xotXKp/v6v8XoGJwLiRNipGgNDRIAlWA==",
"version": "21.2.2",
"resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.2.tgz",
"integrity": "sha512-mpVPI6AiIlZ4z6sSM5WOzCMwTka9yQ9TEVj9+ZyfAs5U9RnKDa8DZIoQ6BFbh3h/v4DArvT5q1Ji8aIlabmWcQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
@@ -746,9 +746,9 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@angular/common": "21.2.0",
"@angular/core": "21.2.0",
"@angular/platform-browser": "21.2.0",
"@angular/common": "21.2.2",
"@angular/core": "21.2.2",
"@angular/platform-browser": "21.2.2",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
@@ -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",
@@ -1548,15 +1549,15 @@
}
},
"node_modules/@eslint/config-array": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz",
"integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
"version": "0.21.2",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz",
"integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/object-schema": "^2.1.7",
"debug": "^4.3.1",
"minimatch": "^3.1.2"
"minimatch": "^3.1.5"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1620,9 +1621,9 @@
}
},
"node_modules/@eslint/eslintrc": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.4.tgz",
"integrity": "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==",
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz",
"integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1633,7 +1634,7 @@
"ignore": "^5.2.0",
"import-fresh": "^3.2.1",
"js-yaml": "^4.1.1",
"minimatch": "^3.1.3",
"minimatch": "^3.1.5",
"strip-json-comments": "^3.1.1"
},
"engines": {
@@ -1709,9 +1710,9 @@
}
},
"node_modules/@eslint/js": {
"version": "9.39.3",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz",
"integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==",
"version": "9.39.4",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz",
"integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1767,9 +1768,9 @@
"optional": true
},
"node_modules/@hono/node-server": {
"version": "1.19.9",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
"integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
"version": "1.19.11",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4013,14 +4014,14 @@
]
},
"node_modules/@schematics/angular": {
"version": "21.2.0",
"resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.0.tgz",
"integrity": "sha512-GQUIeGzZwCT9/W5MAkKnkwETROPbA1eRmy3JF56jLmvr95tJnypGOG8jGYy0d+tcEVujIouh48r4J3bJQg5mrw==",
"version": "21.2.1",
"resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.1.tgz",
"integrity": "sha512-DjrHRMoILhbZ6tc7aNZWuHA1wCm1iU/JN1TxAwNEyIBgyU3Fx8Z5baK4w0TCpOIPt0RLWVgP2L7kka9aXWCUFA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@angular-devkit/core": "21.2.0",
"@angular-devkit/schematics": "21.2.0",
"@angular-devkit/core": "21.2.1",
"@angular-devkit/schematics": "21.2.1",
"jsonc-parser": "3.3.1"
},
"engines": {
@@ -4427,17 +4428,17 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
"integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==",
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz",
"integrity": "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/type-utils": "8.56.1",
"@typescript-eslint/utils": "8.56.1",
"@typescript-eslint/visitor-keys": "8.56.1",
"@typescript-eslint/scope-manager": "8.57.0",
"@typescript-eslint/type-utils": "8.57.0",
"@typescript-eslint/utils": "8.57.0",
"@typescript-eslint/visitor-keys": "8.57.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.4.0"
@@ -4450,23 +4451,23 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.56.1",
"@typescript-eslint/parser": "^8.57.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz",
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.0.tgz",
"integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
"@typescript-eslint/typescript-estree": "8.56.1",
"@typescript-eslint/visitor-keys": "8.56.1",
"@typescript-eslint/scope-manager": "8.57.0",
"@typescript-eslint/types": "8.57.0",
"@typescript-eslint/typescript-estree": "8.57.0",
"@typescript-eslint/visitor-keys": "8.57.0",
"debug": "^4.4.3"
},
"engines": {
@@ -4482,14 +4483,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz",
"integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==",
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.0.tgz",
"integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.56.1",
"@typescript-eslint/types": "^8.56.1",
"@typescript-eslint/tsconfig-utils": "^8.57.0",
"@typescript-eslint/types": "^8.57.0",
"debug": "^4.4.3"
},
"engines": {
@@ -4504,14 +4505,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz",
"integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==",
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.0.tgz",
"integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.56.1",
"@typescript-eslint/visitor-keys": "8.56.1"
"@typescript-eslint/types": "8.57.0",
"@typescript-eslint/visitor-keys": "8.57.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4522,9 +4523,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz",
"integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==",
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.0.tgz",
"integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4539,15 +4540,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz",
"integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==",
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.0.tgz",
"integrity": "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.56.1",
"@typescript-eslint/typescript-estree": "8.56.1",
"@typescript-eslint/utils": "8.56.1",
"@typescript-eslint/types": "8.57.0",
"@typescript-eslint/typescript-estree": "8.57.0",
"@typescript-eslint/utils": "8.57.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.4.0"
},
@@ -4564,9 +4565,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz",
"integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==",
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.0.tgz",
"integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -4579,16 +4580,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz",
"integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==",
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.0.tgz",
"integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.56.1",
"@typescript-eslint/tsconfig-utils": "8.56.1",
"@typescript-eslint/types": "8.56.1",
"@typescript-eslint/visitor-keys": "8.56.1",
"@typescript-eslint/project-service": "8.57.0",
"@typescript-eslint/tsconfig-utils": "8.57.0",
"@typescript-eslint/types": "8.57.0",
"@typescript-eslint/visitor-keys": "8.57.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -4607,17 +4608,17 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz",
"integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==",
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.0.tgz",
"integrity": "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
"@typescript-eslint/typescript-estree": "8.56.1"
"@typescript-eslint/scope-manager": "8.57.0",
"@typescript-eslint/types": "8.57.0",
"@typescript-eslint/typescript-estree": "8.57.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4632,13 +4633,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz",
"integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==",
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.0.tgz",
"integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.56.1",
"@typescript-eslint/types": "8.57.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -5120,9 +5121,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001774",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz",
"integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==",
"version": "1.0.30001777",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz",
"integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==",
"dev": true,
"funding": [
{
@@ -5219,14 +5220,14 @@
}
},
"node_modules/cli-truncate": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz",
"integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==",
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz",
"integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"slice-ansi": "^7.1.0",
"string-width": "^8.0.0"
"slice-ansi": "^8.0.0",
"string-width": "^8.2.0"
},
"engines": {
"node": ">=20"
@@ -5596,9 +5597,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.302",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
"integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==",
"version": "1.5.307",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz",
"integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==",
"dev": true,
"license": "ISC"
},
@@ -5620,9 +5621,9 @@
}
},
"node_modules/enhanced-resolve": {
"version": "5.19.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
"integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==",
"version": "5.20.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz",
"integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
@@ -5781,26 +5782,26 @@
}
},
"node_modules/eslint": {
"version": "9.39.3",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz",
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"version": "9.39.4",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz",
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.21.1",
"@eslint/config-array": "^0.21.2",
"@eslint/config-helpers": "^0.4.2",
"@eslint/core": "^0.17.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.39.3",
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "9.39.4",
"@eslint/plugin-kit": "^0.4.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
"@types/estree": "^1.0.6",
"ajv": "^6.12.4",
"ajv": "^6.14.0",
"chalk": "^4.0.0",
"cross-spawn": "^7.0.6",
"debug": "^4.3.2",
@@ -5819,7 +5820,7 @@
"is-glob": "^4.0.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
"lodash.merge": "^4.6.2",
"minimatch": "^3.1.2",
"minimatch": "^3.1.5",
"natural-compare": "^1.4.0",
"optionator": "^0.9.3"
},
@@ -5858,9 +5859,9 @@
}
},
"node_modules/eslint-scope": {
"version": "9.1.1",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.1.tgz",
"integrity": "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==",
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
@@ -6159,13 +6160,13 @@
}
},
"node_modules/express-rate-limit": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"version": "8.3.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz",
"integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==",
"dev": true,
"license": "MIT",
"dependencies": {
"ip-address": "10.0.1"
"ip-address": "10.1.0"
},
"engines": {
"node": ">= 16"
@@ -6308,9 +6309,9 @@
}
},
"node_modules/flatted": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
"dev": true,
"license": "ISC"
},
@@ -6549,9 +6550,9 @@
}
},
"node_modules/hono": {
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.3.tgz",
"integrity": "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==",
"version": "4.12.7",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -6712,9 +6713,9 @@
}
},
"node_modules/immutable": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz",
"integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==",
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
"dev": true,
"license": "MIT"
},
@@ -6763,9 +6764,9 @@
}
},
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"dev": true,
"license": "MIT",
"engines": {
@@ -6896,9 +6897,9 @@
}
},
"node_modules/jose": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
"integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.1.tgz",
"integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==",
"dev": true,
"license": "MIT",
"funding": {
@@ -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",
@@ -7445,6 +7447,23 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/log-update/node_modules/slice-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
"integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"is-fullwidth-code-point": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
}
},
"node_modules/log-update/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
@@ -7936,9 +7955,9 @@
}
},
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
"version": "2.0.36",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
"integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
"dev": true,
"license": "MIT"
},
@@ -8476,9 +8495,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"funding": [
{
"type": "opencollective",
@@ -9317,17 +9336,17 @@
}
},
"node_modules/slice-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
"integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==",
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz",
"integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"is-fullwidth-code-point": "^5.0.0"
"ansi-styles": "^6.2.3",
"is-fullwidth-code-point": "^5.1.0"
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"funding": {
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
@@ -9563,9 +9582,9 @@
}
},
"node_modules/tar": {
"version": "7.5.9",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz",
"integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==",
"version": "7.5.11",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz",
"integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
+7
View File
@@ -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,19 @@ export class AuthService {
private readonly _isSetupComplete = signal(false);
private readonly _plexLinked = signal(false);
private readonly _isLoading = signal(true);
private readonly _connectionError = signal(false);
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 connectionError = this._connectionError.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;
@@ -59,8 +70,12 @@ export class AuthService {
checkStatus(): Observable<AuthStatus> {
return this.http.get<AuthStatus>('/api/auth/status').pipe(
tap((status) => {
this._connectionError.set(false);
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) {
@@ -94,6 +109,7 @@ export class AuthService {
this._isLoading.set(false);
}),
catchError(() => {
this._connectionError.set(true);
this._isLoading.set(false);
return of({ setupCompleted: false, plexLinked: false });
}),
@@ -119,6 +135,12 @@ export class AuthService {
);
}
retryConnection(): Observable<AuthStatus> {
this._connectionError.set(false);
this._isLoading.set(true);
return this.checkStatus();
}
// Login flow
login(username: string, password: string): Observable<LoginResponse> {
return this.http.post<LoginResponse>('/api/auth/login', { username, password }).pipe(
@@ -160,6 +182,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
@@ -15,6 +15,7 @@ export class DocumentationService {
'ignoredDownloads': 'ignored-downloads',
'useAdvancedScheduling': 'scheduling-mode',
'cronExpression': 'cron-expression',
'processNoContentId': 'process-downloads-with-no-content-id',
'failedImport.maxStrikes': 'failed-import-max-strikes',
'failedImport.ignorePrivate': 'failed-import-ignore-private',
'failedImport.deletePrivate': 'failed-import-delete-private',
@@ -87,7 +88,7 @@ export class DocumentationService {
'cronExpression': 'cron-expression',
'ignorePrivate': 'ignore-private',
'deletePrivate': 'delete-private',
'deleteKnownMalware': 'delete-known-malware',
'processNoContentId': 'process-downloads-with-no-content-id',
'sonarr.enabled': 'enable-blocklist',
'sonarr.blocklistPath': 'blocklist-path',
'sonarr.blocklistType': 'blocklist-type',
@@ -174,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);
}
}
@@ -1,3 +1,18 @@
@if (connectionError() || retrying()) {
<app-empty-state
heading="Could not connect to server"
description="Failed to connect to the server. Please check that the server is running and try again."
>
<app-button variant="primary" size="sm" [disabled]="retrying()" (clicked)="retryConnection()">
@if (retrying()) {
<app-spinner size="sm" />
} @else {
Retry
}
</app-button>
</app-empty-state>
} @else {
<h2 class="setup-title">Initial Setup</h2>
<div class="steps-indicator">
@@ -236,3 +251,5 @@
</div>
</div>
}
}
@@ -1,17 +1,18 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, viewChild, effect, afterNextRender, OnDestroy } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { ButtonComponent, InputComponent, SpinnerComponent } from '@ui';
import { ButtonComponent, InputComponent, SpinnerComponent, EmptyStateComponent } from '@ui';
import { AuthService } from '@core/auth/auth.service';
import { ToastService } from '@core/services/toast.service';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { tablerCheck, tablerCopy, tablerShieldLock } from '@ng-icons/tabler-icons';
import { QRCodeComponent } from 'angularx-qrcode';
import { forkJoin, timer } from 'rxjs';
@Component({
selector: 'app-setup',
standalone: true,
imports: [FormsModule, ButtonComponent, InputComponent, SpinnerComponent, NgIconComponent, QRCodeComponent],
imports: [FormsModule, ButtonComponent, InputComponent, SpinnerComponent, EmptyStateComponent, NgIconComponent, QRCodeComponent],
templateUrl: './setup.component.html',
styleUrl: './setup.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -22,6 +23,9 @@ export class SetupComponent implements OnDestroy {
private readonly router = inject(Router);
private readonly toast = inject(ToastService);
readonly connectionError = this.auth.connectionError;
readonly retrying = signal(false);
currentStep = signal(1);
loading = signal(false);
error = signal('');
@@ -87,6 +91,16 @@ export class SetupComponent implements OnDestroy {
});
}
retryConnection(): void {
this.retrying.set(true);
forkJoin([this.auth.retryConnection(), timer(500)]).subscribe(() => {
this.retrying.set(false);
if (this.auth.isSetupComplete()) {
this.router.navigate(['/auth/login']);
}
});
}
// Step 1: Create account
createAccount(): void {
if (!this.passwordsMatch || !this.passwordValid) return;
@@ -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);
},
});
}
}
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff. Show more