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