From 70fc955d370e45b8af6db30ef778626cd7fae3ad Mon Sep 17 00:00:00 2001 From: Flaminel Date: Thu, 12 Mar 2026 22:12:20 +0200 Subject: [PATCH] Add OIDC support (#500) --- .github/workflows/e2e.yml | 94 ++ .github/workflows/release.yml | 25 +- .../Cleanuparr.Api.Tests.csproj | 4 + .../CustomWebApplicationFactory.cs | 23 +- .../Auth/AccountControllerOidcTests.cs | 498 ++++++++ .../Features/Auth/AuthControllerTests.cs | 25 + .../Features/Auth/OidcAuthControllerTests.cs | 656 ++++++++++ .../SensitiveData/SensitiveDataInputTests.cs | 64 + .../Cleanuparr.Api.Tests/TestCollections.cs | 9 + .../Cleanuparr.Api.Tests/xunit.runner.json | 5 + .../DependencyInjection/MainDI.cs | 3 + .../DependencyInjection/ServicesDI.cs | 1 + .../Extensions/HttpRequestExtensions.cs | 44 + .../Contracts/Requests/OidcExchangeRequest.cs | 9 + .../Requests/UpdateOidcConfigRequest.cs | 49 + .../Contracts/Responses/AuthStatusResponse.cs | 3 + .../Contracts/Responses/OidcStartResponse.cs | 6 + .../Auth/Controllers/AccountController.cs | 643 ++++++---- .../Auth/Controllers/AuthController.cs | 167 ++- code/backend/Cleanuparr.Api/HostExtensions.cs | 2 - .../Features/Auth/OidcAuthServiceTests.cs | 1076 +++++++++++++++++ .../Cleanuparr.Infrastructure.csproj | 1 + .../Features/Auth/IOidcAuthService.cs | 57 + .../Features/Auth/OidcAuthService.cs | 523 ++++++++ .../Configuration/General/OidcConfigTests.cs | 357 ++++++ .../20260312090408_AddOidcSupport.Designer.cs | 271 +++++ .../Users/20260312090408_AddOidcSupport.cs | 124 ++ .../Users/UsersContextModelSnapshot.cs | 56 + .../Models/Auth/OidcConfig.cs | 121 ++ .../Models/Auth/User.cs | 2 + .../Cleanuparr.Persistence/UsersContext.cs | 2 + code/frontend/package-lock.json | 2 + code/frontend/src/app/app.routes.ts | 7 + code/frontend/src/app/core/api/account.api.ts | 13 + .../src/app/core/auth/auth.service.ts | 27 + .../core/services/documentation.service.ts | 11 + .../features/auth/login/login.component.html | 105 +- .../features/auth/login/login.component.scss | 39 + .../features/auth/login/login.component.ts | 37 +- .../oidc-callback/oidc-callback.component.ts | 98 ++ .../account/account-settings.component.html | 91 +- .../account/account-settings.component.scss | 43 + .../account/account-settings.component.ts | 121 +- .../general/general-settings.component.ts | 6 +- .../app/shared/models/oidc-config.model.ts | 11 + code/frontend/src/app/ui/index.ts | 1 + .../src/app/ui/label/label.component.html | 9 + .../src/app/ui/label/label.component.scss | 5 + .../src/app/ui/label/label.component.ts | 28 + docs/contributing/e2e-testing.md | 49 + docs/docs/configuration/account/index.mdx | 182 +++ e2e/.gitignore | 4 + e2e/Makefile | 14 + e2e/docker-compose.e2e.yml | 37 + e2e/keycloak-realm.json | 47 + e2e/package-lock.json | 76 ++ e2e/package.json | 12 + e2e/playwright.config.ts | 22 + e2e/tests/00-oidc-login-unlinked.spec.ts | 36 + e2e/tests/01-oidc-link.spec.ts | 46 + e2e/tests/02-oidc-login.spec.ts | 40 + e2e/tests/03-oidc-error-display.spec.ts | 73 ++ e2e/tests/04-oidc-subject-mismatch.spec.ts | 47 + e2e/tests/05-oidc-config-changes.spec.ts | 51 + e2e/tests/06-oidc-settings-ui.spec.ts | 82 ++ e2e/tests/07-oidc-login-persists.spec.ts | 33 + e2e/tests/08-oidc-exclusive-mode.spec.ts | 157 +++ .../09-oidc-unlink-allows-any-user.spec.ts | 109 ++ e2e/tests/global-setup.ts | 27 + e2e/tests/helpers/app-api.ts | 125 ++ e2e/tests/helpers/keycloak.ts | 88 ++ e2e/tests/helpers/test-config.ts | 14 + e2e/tsconfig.json | 10 + 73 files changed, 6646 insertions(+), 309 deletions(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 code/backend/Cleanuparr.Api.Tests/Features/Auth/AccountControllerOidcTests.cs create mode 100644 code/backend/Cleanuparr.Api.Tests/Features/Auth/OidcAuthControllerTests.cs create mode 100644 code/backend/Cleanuparr.Api.Tests/TestCollections.cs create mode 100644 code/backend/Cleanuparr.Api.Tests/xunit.runner.json create mode 100644 code/backend/Cleanuparr.Api/Extensions/HttpRequestExtensions.cs create mode 100644 code/backend/Cleanuparr.Api/Features/Auth/Contracts/Requests/OidcExchangeRequest.cs create mode 100644 code/backend/Cleanuparr.Api/Features/Auth/Contracts/Requests/UpdateOidcConfigRequest.cs create mode 100644 code/backend/Cleanuparr.Api/Features/Auth/Contracts/Responses/OidcStartResponse.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/Auth/OidcAuthServiceTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Auth/IOidcAuthService.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Auth/OidcAuthService.cs create mode 100644 code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/General/OidcConfigTests.cs create mode 100644 code/backend/Cleanuparr.Persistence/Migrations/Users/20260312090408_AddOidcSupport.Designer.cs create mode 100644 code/backend/Cleanuparr.Persistence/Migrations/Users/20260312090408_AddOidcSupport.cs create mode 100644 code/backend/Cleanuparr.Persistence/Models/Auth/OidcConfig.cs create mode 100644 code/frontend/src/app/features/auth/oidc-callback/oidc-callback.component.ts create mode 100644 code/frontend/src/app/shared/models/oidc-config.model.ts create mode 100644 code/frontend/src/app/ui/label/label.component.html create mode 100644 code/frontend/src/app/ui/label/label.component.scss create mode 100644 code/frontend/src/app/ui/label/label.component.ts create mode 100644 docs/contributing/e2e-testing.md create mode 100644 docs/docs/configuration/account/index.mdx create mode 100644 e2e/.gitignore create mode 100644 e2e/Makefile create mode 100644 e2e/docker-compose.e2e.yml create mode 100644 e2e/keycloak-realm.json create mode 100644 e2e/package-lock.json create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/tests/00-oidc-login-unlinked.spec.ts create mode 100644 e2e/tests/01-oidc-link.spec.ts create mode 100644 e2e/tests/02-oidc-login.spec.ts create mode 100644 e2e/tests/03-oidc-error-display.spec.ts create mode 100644 e2e/tests/04-oidc-subject-mismatch.spec.ts create mode 100644 e2e/tests/05-oidc-config-changes.spec.ts create mode 100644 e2e/tests/06-oidc-settings-ui.spec.ts create mode 100644 e2e/tests/07-oidc-login-persists.spec.ts create mode 100644 e2e/tests/08-oidc-exclusive-mode.spec.ts create mode 100644 e2e/tests/09-oidc-unlink-allows-any-user.spec.ts create mode 100644 e2e/tests/global-setup.ts create mode 100644 e2e/tests/helpers/app-api.ts create mode 100644 e2e/tests/helpers/keycloak.ts create mode 100644 e2e/tests/helpers/test-config.ts create mode 100644 e2e/tsconfig.json diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..8306a9b1 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 056825f1..b6c6bcc4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }}" diff --git a/code/backend/Cleanuparr.Api.Tests/Cleanuparr.Api.Tests.csproj b/code/backend/Cleanuparr.Api.Tests/Cleanuparr.Api.Tests.csproj index 3105520b..c8e2aa6c 100644 --- a/code/backend/Cleanuparr.Api.Tests/Cleanuparr.Api.Tests.csproj +++ b/code/backend/Cleanuparr.Api.Tests/Cleanuparr.Api.Tests.csproj @@ -24,4 +24,8 @@ + + + + \ No newline at end of file diff --git a/code/backend/Cleanuparr.Api.Tests/CustomWebApplicationFactory.cs b/code/backend/Cleanuparr.Api.Tests/CustomWebApplicationFactory.cs index a8a61d6e..a336e421 100644 --- a/code/backend/Cleanuparr.Api.Tests/CustomWebApplicationFactory.cs +++ b/code/backend/Cleanuparr.Api.Tests/CustomWebApplicationFactory.cs @@ -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 options.UseSqlite($"Data Source={dbPath}"); }); - // Ensure DB is created - var sp = services.BuildServiceProvider(); - using var scope = sp.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); + // Remove all hosted services (Quartz scheduler, BackgroundJobManager) to prevent + // Quartz.Logging.LogProvider.ResolvedLogProvider (a cached Lazy) 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() + .UseSqlite($"Data Source={dbPath}") + .Options); db.Database.EnsureCreated(); }); } diff --git a/code/backend/Cleanuparr.Api.Tests/Features/Auth/AccountControllerOidcTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/Auth/AccountControllerOidcTests.cs new file mode 100644 index 00000000..f5b02a9c --- /dev/null +++ b/code/backend/Cleanuparr.Api.Tests/Features/Auth/AccountControllerOidcTests.cs @@ -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; + +/// +/// 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. +/// +[Collection("Auth Integration Tests")] +[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")] +public class AccountControllerOidcTests : IClassFixture +{ + 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(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(); + 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(); + 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(); + 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(); + 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 + { + 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)); + 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(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(); + + // Remove all hosted services (Quartz scheduler, BackgroundJobManager) to prevent + // Quartz.Logging.LogProvider.ResolvedLogProvider (a cached Lazy) 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() + .UseSqlite($"Data Source={dbPath}") + .UseLowerCaseNamingConvention() + .UseSnakeCaseNamingConvention() + .Options); + db.Database.EnsureCreated(); + }); + } + + public async Task EnableOidcAsync() + { + using var scope = Services.CreateScope(); + var usersContext = scope.ServiceProvider.GetRequiredService(); + + 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 GetAuthorizedSubjectAsync() + { + using var scope = Services.CreateScope(); + var usersContext = scope.ServiceProvider.GetRequiredService(); + + 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(); + + var user = await usersContext.Users.FirstOrDefaultAsync(); + if (user is not null) + { + user.Oidc.ExclusiveMode = enabled; + await usersContext.SaveChangesAsync(); + } + } + + public async Task GetExclusiveModeAsync() + { + using var scope = Services.CreateScope(); + var usersContext = scope.ServiceProvider.GetRequiredService(); + + 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 _oneTimeCodes = new(); + + public Task 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 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 +} diff --git a/code/backend/Cleanuparr.Api.Tests/Features/Auth/AuthControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/Auth/AuthControllerTests.cs index 81199c38..5de6054e 100644 --- a/code/backend/Cleanuparr.Api.Tests/Features/Auth/AuthControllerTests.cs +++ b/code/backend/Cleanuparr.Api.Tests/Features/Auth/AuthControllerTests.cs @@ -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. /// +[Collection("Auth Integration Tests")] [TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")] public class AuthControllerTests : IClassFixture { @@ -243,6 +244,30 @@ public class AuthControllerTests : IClassFixture 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(); + // 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 = ""; diff --git a/code/backend/Cleanuparr.Api.Tests/Features/Auth/OidcAuthControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/Auth/OidcAuthControllerTests.cs new file mode 100644 index 00000000..5a599fae --- /dev/null +++ b/code/backend/Cleanuparr.Api.Tests/Features/Auth/OidcAuthControllerTests.cs @@ -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; + +/// +/// 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. +/// +[Collection("Auth Integration Tests")] +[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")] +public class OidcAuthControllerTests : IClassFixture +{ + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + // 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(); + 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(); + 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(); + 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(); + 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 + + /// + /// Custom factory that replaces IOidcAuthService with a mock for testing. + /// + public class OidcWebApplicationFactory : WebApplicationFactory + { + 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)); + 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(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(); + + // Remove all hosted services (Quartz scheduler, BackgroundJobManager) to prevent + // Quartz.Logging.LogProvider.ResolvedLogProvider (a cached Lazy) 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() + .UseSqlite($"Data Source={dbPath}") + .Options); + db.Database.EnsureCreated(); + }); + } + + /// + /// Enables OIDC on the user in the UsersContext database. + /// + public async Task EnableOidcAsync() + { + using var scope = Services.CreateScope(); + var usersContext = scope.ServiceProvider.GetRequiredService(); + + 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(); + + 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(); + + 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(); + + 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 */ } + } + } + } + + /// + /// Mock OIDC auth service that simulates IdP behavior without network calls. + /// + 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 _oneTimeCodes = new(); + + public Task 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 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 +} diff --git a/code/backend/Cleanuparr.Api.Tests/Features/SensitiveData/SensitiveDataInputTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/SensitiveData/SensitiveDataInputTests.cs index 2f155d9a..5816618c 100644 --- a/code/backend/Cleanuparr.Api.Tests/Features/SensitiveData/SensitiveDataInputTests.cs +++ b/code/backend/Cleanuparr.Api.Tests/Features/SensitiveData/SensitiveDataInputTests.cs @@ -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 } diff --git a/code/backend/Cleanuparr.Api.Tests/TestCollections.cs b/code/backend/Cleanuparr.Api.Tests/TestCollections.cs new file mode 100644 index 00000000..f4fca963 --- /dev/null +++ b/code/backend/Cleanuparr.Api.Tests/TestCollections.cs @@ -0,0 +1,9 @@ +namespace Cleanuparr.Api.Tests; + +/// +/// 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. +/// +[CollectionDefinition("Auth Integration Tests")] +public class AuthIntegrationTestsCollection { } diff --git a/code/backend/Cleanuparr.Api.Tests/xunit.runner.json b/code/backend/Cleanuparr.Api.Tests/xunit.runner.json new file mode 100644 index 00000000..dd80f43a --- /dev/null +++ b/code/backend/Cleanuparr.Api.Tests/xunit.runner.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeAssembly": false, + "parallelizeTestCollections": false +} diff --git a/code/backend/Cleanuparr.Api/DependencyInjection/MainDI.cs b/code/backend/Cleanuparr.Api/DependencyInjection/MainDI.cs index f359e17b..5c5d603f 100644 --- a/code/backend/Cleanuparr.Api/DependencyInjection/MainDI.cs +++ b/code/backend/Cleanuparr.Api/DependencyInjection/MainDI.cs @@ -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; } diff --git a/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs b/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs index 32898c80..71010716 100644 --- a/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs +++ b/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs @@ -33,6 +33,7 @@ public static class ServicesDI .AddSingleton() .AddSingleton() .AddScoped() + .AddScoped() .AddScoped() .AddHostedService() .AddScoped() diff --git a/code/backend/Cleanuparr.Api/Extensions/HttpRequestExtensions.cs b/code/backend/Cleanuparr.Api/Extensions/HttpRequestExtensions.cs new file mode 100644 index 00000000..5541c278 --- /dev/null +++ b/code/backend/Cleanuparr.Api/Extensions/HttpRequestExtensions.cs @@ -0,0 +1,44 @@ +using Cleanuparr.Infrastructure.Extensions; + +namespace Cleanuparr.Api.Extensions; + +public static class HttpRequestExtensions +{ + /// + /// Returns the request PathBase as a safe relative path. + /// Rejects absolute URLs (e.g. "://" or "//") to prevent open redirect attacks. + /// + public static string GetSafeBasePath(this HttpRequest request) + { + var basePath = request.PathBase.Value?.TrimEnd('/') ?? ""; + if (basePath.Contains("://") || basePath.StartsWith("//")) + { + return ""; + } + return basePath; + } + + /// + /// 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. + /// + 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}"; + } +} diff --git a/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Requests/OidcExchangeRequest.cs b/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Requests/OidcExchangeRequest.cs new file mode 100644 index 00000000..794b0ad2 --- /dev/null +++ b/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Requests/OidcExchangeRequest.cs @@ -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; } +} diff --git a/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Requests/UpdateOidcConfigRequest.cs b/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Requests/UpdateOidcConfigRequest.cs new file mode 100644 index 00000000..8585f750 --- /dev/null +++ b/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Requests/UpdateOidcConfigRequest.cs @@ -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(); + } + } +} diff --git a/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Responses/AuthStatusResponse.cs b/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Responses/AuthStatusResponse.cs index d8e75d84..159a6ca2 100644 --- a/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Responses/AuthStatusResponse.cs +++ b/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Responses/AuthStatusResponse.cs @@ -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; } } diff --git a/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Responses/OidcStartResponse.cs b/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Responses/OidcStartResponse.cs new file mode 100644 index 00000000..685c7de1 --- /dev/null +++ b/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Responses/OidcStartResponse.cs @@ -0,0 +1,6 @@ +namespace Cleanuparr.Api.Features.Auth.Contracts.Responses; + +public sealed record OidcStartResponse +{ + public required string AuthorizationUrl { get; init; } +} diff --git a/code/backend/Cleanuparr.Api/Features/Auth/Controllers/AccountController.cs b/code/backend/Cleanuparr.Api/Features/Auth/Controllers/AccountController.cs index 02fd33af..6c94fdc0 100644 --- a/code/backend/Cleanuparr.Api/Features/Auth/Controllers/AccountController.cs +++ b/code/backend/Cleanuparr.Api/Features/Auth/Controllers/AccountController.cs @@ -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 _logger; public AccountController( @@ -29,12 +32,14 @@ public sealed class AccountController : ControllerBase IPasswordService passwordService, ITotpService totpService, IPlexAuthService plexAuthService, + IOidcAuthService oidcAuthService, ILogger 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 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 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 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 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 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 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 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 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 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 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 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 GetOidcConfig() + { + var user = await GetCurrentUser(); + if (user is null) + { + return Unauthorized(); + } + + return Ok(user.Oidc); + } + + [HttpPut("oidc")] + public async Task 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 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 }); + } + } + + /// + /// 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). + /// + [AllowAnonymous] + [HttpGet("oidc/link/callback")] + public async Task 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 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 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 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 GetCurrentUser(bool includeRecoveryCodes = false) diff --git a/code/backend/Cleanuparr.Api/Features/Auth/Controllers/AuthController.cs b/code/backend/Cleanuparr.Api/Features/Auth/Controllers/AuthController.cs index 7aa45480..fd50395d 100644 --- a/code/backend/Cleanuparr.Api/Features/Auth/Controllers/AuthController.cs +++ b/code/backend/Cleanuparr.Api/Features/Auth/Controllers/AuthController.cs @@ -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 _logger; public AuthController( UsersContext usersContext, + DataContext dataContext, IJwtService jwtService, IPasswordService passwordService, ITotpService totpService, IPlexAuthService plexAuthService, + IOidcAuthService oidcAuthService, ILogger 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 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 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 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 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 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 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 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 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 }; + } } diff --git a/code/backend/Cleanuparr.Api/HostExtensions.cs b/code/backend/Cleanuparr.Api/HostExtensions.cs index 1c5de372..168006c1 100644 --- a/code/backend/Cleanuparr.Api/HostExtensions.cs +++ b/code/backend/Cleanuparr.Api/HostExtensions.cs @@ -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; diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Auth/OidcAuthServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Auth/OidcAuthServiceTests.cs new file mode 100644 index 00000000..e8154491 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Auth/OidcAuthServiceTests.cs @@ -0,0 +1,1076 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Net; +using System.Reflection; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Cleanuparr.Infrastructure.Features.Auth; +using Microsoft.IdentityModel.Tokens; +using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Auth; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Features.Auth; + +public sealed class OidcAuthServiceTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly UsersContext _usersContext; + private readonly Mock _httpClientFactory; + private readonly Mock> _logger; + + public OidcAuthServiceTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + _usersContext = new UsersContext(options); + _usersContext.Database.EnsureCreated(); + + // Seed a user + _usersContext.Users.Add(new User + { + Id = Guid.NewGuid(), + Username = "admin", + PasswordHash = "hash", + TotpSecret = "secret", + ApiKey = "test-api-key", + SetupCompleted = true, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }); + _usersContext.SaveChanges(); + + _httpClientFactory = new Mock(); + _logger = new Mock>(); + + // Set up a default HttpClient for the factory + _httpClientFactory + .Setup(f => f.CreateClient("OidcAuth")) + .Returns(new HttpClient()); + } + + private OidcAuthService CreateService() + { + return new OidcAuthService(_httpClientFactory.Object, _usersContext, _logger.Object); + } + + #region StoreOneTimeCode Tests + + [Fact] + public void StoreOneTimeCode_ReturnsNonEmptyCode() + { + var service = CreateService(); + + var code = service.StoreOneTimeCode("access-token", "refresh-token", 3600); + + code.ShouldNotBeNullOrEmpty(); + } + + [Fact] + public void StoreOneTimeCode_ReturnsDifferentCodesEachTime() + { + var service = CreateService(); + + var code1 = service.StoreOneTimeCode("access-1", "refresh-1", 3600); + var code2 = service.StoreOneTimeCode("access-2", "refresh-2", 3600); + + code1.ShouldNotBe(code2); + } + + #endregion + + #region ExchangeOneTimeCode Tests + + [Fact] + public void ExchangeOneTimeCode_ValidCode_ReturnsTokens() + { + var service = CreateService(); + var code = service.StoreOneTimeCode("test-access", "test-refresh", 1800); + + var result = service.ExchangeOneTimeCode(code); + + result.ShouldNotBeNull(); + result.AccessToken.ShouldBe("test-access"); + result.RefreshToken.ShouldBe("test-refresh"); + result.ExpiresIn.ShouldBe(1800); + } + + [Fact] + public void ExchangeOneTimeCode_InvalidCode_ReturnsNull() + { + var service = CreateService(); + + var result = service.ExchangeOneTimeCode("nonexistent-code"); + + result.ShouldBeNull(); + } + + [Fact] + public void ExchangeOneTimeCode_SameCodeTwice_SecondReturnsNull() + { + var service = CreateService(); + var code = service.StoreOneTimeCode("test-access", "test-refresh", 3600); + + var result1 = service.ExchangeOneTimeCode(code); + var result2 = service.ExchangeOneTimeCode(code); + + result1.ShouldNotBeNull(); + result2.ShouldBeNull(); + } + + [Fact] + public void ExchangeOneTimeCode_EmptyCode_ReturnsNull() + { + var service = CreateService(); + + var result = service.ExchangeOneTimeCode(string.Empty); + + result.ShouldBeNull(); + } + + #endregion + + #region StartAuthorization Tests + + [Fact] + public async Task StartAuthorization_WhenOidcDisabled_ThrowsInvalidOperationException() + { + // Ensure OIDC is disabled in config (default state from seed data) + var service = CreateService(); + + await Should.ThrowAsync( + () => service.StartAuthorization("https://app.test/api/auth/oidc/callback")); + } + + [Fact] + public async Task StartAuthorization_WhenEnabled_ReturnsAuthorizationUrlWithRequiredParams() + { + await EnableOidcInConfig(); + var service = CreateService(); + + // This will fail at the discovery document fetch since we don't have a real IdP, + // but we can at least verify the config check passes. + // The actual StartAuthorization requires a reachable discovery endpoint. + // Full flow testing is done in integration tests. + await Should.ThrowAsync( + () => service.StartAuthorization("https://app.test/api/auth/oidc/callback")); + } + + #endregion + + #region HandleCallback Tests + + [Fact] + public async Task HandleCallback_InvalidState_ReturnsFailure() + { + var service = CreateService(); + + var result = await service.HandleCallback("some-code", "invalid-state", "https://app.test/callback"); + + result.Success.ShouldBeFalse(); + result.Error.ShouldContain("Invalid or expired"); + } + + #endregion + + #region ClearDiscoveryCache Tests + + [Fact] + public void ClearDiscoveryCache_DoesNotThrow() + { + Should.NotThrow(() => OidcAuthService.ClearDiscoveryCache()); + } + + #endregion + + #region HandleCallback Edge Cases + + [Fact] + public async Task HandleCallback_EmptyCode_ReturnsFailure() + { + var service = CreateService(); + + // Even with a valid-looking state, empty code still fails because the state won't match + var result = await service.HandleCallback("", "nonexistent-state", "https://app.test/callback"); + + result.Success.ShouldBeFalse(); + } + + [Fact] + public async Task HandleCallback_EmptyState_ReturnsFailure() + { + var service = CreateService(); + + var result = await service.HandleCallback("some-code", "", "https://app.test/callback"); + + result.Success.ShouldBeFalse(); + result.Error.ShouldContain("Invalid or expired"); + } + + #endregion + + #region StoreOneTimeCode Capacity Tests + + [Fact] + public void StoreOneTimeCode_MultipleStores_AllReturnUniqueCodes() + { + var service = CreateService(); + var codes = new HashSet(); + + for (int i = 0; i < 10; i++) + { + var code = service.StoreOneTimeCode($"access-{i}", $"refresh-{i}", 3600); + codes.Add(code).ShouldBeTrue($"Code {i} was not unique"); + } + + codes.Count.ShouldBe(10); + } + + [Fact] + public void StoreOneTimeCode_Concurrent_AllCodesAreUnique() + { + var service = CreateService(); + var codes = new System.Collections.Concurrent.ConcurrentBag(); + + Parallel.For(0, 50, i => + { + var code = service.StoreOneTimeCode($"access-{i}", $"refresh-{i}", 3600); + codes.Add(code); + }); + + codes.Count.ShouldBe(50); + codes.Distinct().Count().ShouldBe(50); + } + + #endregion + + #region Helpers + + private async Task EnableOidcInConfig() + { + var user = await _usersContext.Users.FirstAsync(); + user.Oidc = new OidcConfig + { + Enabled = true, + IssuerUrl = "https://mock-oidc-provider.test", + ClientId = "test-client", + Scopes = "openid profile email", + AuthorizedSubject = "test-subject", + ProviderName = "TestProvider" + }; + await _usersContext.SaveChangesAsync(); + } + + /// + /// Creates an OidcAuthService using the given HttpMessageHandler instead of the default mock. + /// + private OidcAuthService CreateServiceWithHandler(HttpMessageHandler handler) + { + var factory = new Mock(); + factory.Setup(f => f.CreateClient("OidcAuth")).Returns(new HttpClient(handler)); + return new OidcAuthService(factory.Object, _usersContext, _logger.Object); + } + + /// + /// Uses reflection to retrieve the nonce stored in a pending OIDC flow state. + /// Required for constructing a valid JWT in tests before HandleCallback is called. + /// + private static string GetFlowNonce(string state) + { + var pendingFlowsField = typeof(OidcAuthService) + .GetField("PendingFlows", BindingFlags.NonPublic | BindingFlags.Static)!; + var pendingFlows = pendingFlowsField.GetValue(null)!; + + // Use ConcurrentDictionary indexer: pendingFlows[state] + var indexer = pendingFlows.GetType().GetProperty("Item")!; + var flowState = indexer.GetValue(pendingFlows, new object[] { state }) + ?? throw new InvalidOperationException($"No pending flow found for state: {state}"); + + var nonceProp = flowState.GetType() + .GetProperty("Nonce", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)!; + return (string)nonceProp.GetValue(flowState)!; + } + + /// + /// Returns a handler that serves a minimal OIDC discovery document for the mock issuer. + /// Optionally also handles a token endpoint and JWKS endpoint. + /// + private static MockHttpMessageHandler CreateDiscoveryHandler( + string? tokenResponse = null, + HttpStatusCode tokenStatusCode = HttpStatusCode.OK, + bool throwNetworkErrorOnToken = false, + string? jwksJson = null, + Func? tokenResponseFactory = null) + { + const string issuer = "https://mock-oidc-provider.test"; + + var discoveryJson = JsonSerializer.Serialize(new + { + issuer, + authorization_endpoint = $"{issuer}/authorize", + token_endpoint = $"{issuer}/token", + jwks_uri = $"{issuer}/.well-known/jwks", + response_types_supported = new[] { "code" }, + subject_types_supported = new[] { "public" }, + id_token_signing_alg_values_supported = new[] { "RS256" } + }); + + return new MockHttpMessageHandler(request => + { + var url = request.RequestUri?.ToString() ?? ""; + + if (url.Contains("/.well-known/openid-configuration")) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(discoveryJson, Encoding.UTF8, "application/json") + }; + } + + if (url.Contains("/.well-known/jwks")) + { + // Default to an empty JWKS (sufficient for PKCE/URL tests; JWT tests pass a real key) + var keysJson = jwksJson ?? """{"keys": []}"""; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(keysJson, Encoding.UTF8, "application/json") + }; + } + + if (url.Contains("/token")) + { + if (throwNetworkErrorOnToken) + throw new HttpRequestException("Simulated network failure"); + + // tokenResponseFactory allows dynamic response generation (needed for JWT nonce) + var body = tokenResponseFactory?.Invoke() ?? tokenResponse ?? "{}"; + return new HttpResponseMessage(tokenStatusCode) + { + Content = new StringContent(body, Encoding.UTF8, "application/json") + }; + } + + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + } + + #endregion + + #region JWT ID Token Validation Tests + + private const string MockIssuer = "https://mock-oidc-provider.test"; + private const string MockClientId = "test-client"; + private const string MockSubject = "test-subject-123"; + private const string MockRedirectUri = "https://app.test/api/auth/oidc/callback"; + + [Fact] + public async Task HandleCallback_ValidIdToken_ReturnsSuccessWithSubject() + { + await EnableOidcInConfig(); + OidcAuthService.ClearDiscoveryCache(); + + var jwt = new JwtTestHelper(); + string? capturedJwt = null; + + var handler = CreateDiscoveryHandler( + jwksJson: jwt.GetJwksJson(), + tokenResponseFactory: () => + $$"""{"id_token":"{{capturedJwt}}","access_token":"access-123","token_type":"Bearer"}"""); + + var service = CreateServiceWithHandler(handler); + try + { + var startResult = await service.StartAuthorization(MockRedirectUri); + var nonce = GetFlowNonce(startResult.State); + capturedJwt = jwt.CreateIdToken(MockIssuer, MockClientId, MockSubject, nonce); + + var callbackResult = await service.HandleCallback("code", startResult.State, MockRedirectUri); + + callbackResult.Success.ShouldBeTrue(); + callbackResult.Subject.ShouldBe(MockSubject); + } + finally + { + OidcAuthService.ClearDiscoveryCache(); + } + } + + [Fact] + public async Task HandleCallback_ExpiredIdToken_ReturnsFailure() + { + await EnableOidcInConfig(); + OidcAuthService.ClearDiscoveryCache(); + + var jwt = new JwtTestHelper(); + string? capturedJwt = null; + + var handler = CreateDiscoveryHandler( + jwksJson: jwt.GetJwksJson(), + tokenResponseFactory: () => + $$"""{"id_token":"{{capturedJwt}}","access_token":"access-123","token_type":"Bearer"}"""); + + var service = CreateServiceWithHandler(handler); + try + { + var startResult = await service.StartAuthorization(MockRedirectUri); + var nonce = GetFlowNonce(startResult.State); + // Token expired 1 hour ago (well outside the 2-minute clock skew) + capturedJwt = jwt.CreateIdToken(MockIssuer, MockClientId, MockSubject, nonce, + expiry: DateTime.UtcNow.AddHours(-1), + notBefore: DateTime.UtcNow.AddHours(-2)); + + var callbackResult = await service.HandleCallback("code", startResult.State, MockRedirectUri); + + callbackResult.Success.ShouldBeFalse(); + callbackResult.Error.ShouldContain("ID token validation failed"); + } + finally + { + OidcAuthService.ClearDiscoveryCache(); + } + } + + [Fact] + public async Task HandleCallback_WrongNonce_ReturnsFailure() + { + await EnableOidcInConfig(); + OidcAuthService.ClearDiscoveryCache(); + + var jwt = new JwtTestHelper(); + var handler = CreateDiscoveryHandler( + jwksJson: jwt.GetJwksJson(), + tokenResponseFactory: () => + $$"""{"id_token":"{{jwt.CreateIdToken(MockIssuer, MockClientId, MockSubject, "wrong-nonce")}}","access_token":"access-123","token_type":"Bearer"}"""); + + var service = CreateServiceWithHandler(handler); + try + { + var startResult = await service.StartAuthorization(MockRedirectUri); + var callbackResult = await service.HandleCallback("code", startResult.State, MockRedirectUri); + + callbackResult.Success.ShouldBeFalse(); + callbackResult.Error.ShouldContain("ID token validation failed"); + } + finally + { + OidcAuthService.ClearDiscoveryCache(); + } + } + + [Fact] + public async Task HandleCallback_WrongIssuer_ReturnsFailure() + { + await EnableOidcInConfig(); + OidcAuthService.ClearDiscoveryCache(); + + var jwt = new JwtTestHelper(); + string? capturedJwt = null; + + var handler = CreateDiscoveryHandler( + jwksJson: jwt.GetJwksJson(), + tokenResponseFactory: () => + $$"""{"id_token":"{{capturedJwt}}","access_token":"access-123","token_type":"Bearer"}"""); + + var service = CreateServiceWithHandler(handler); + try + { + var startResult = await service.StartAuthorization(MockRedirectUri); + var nonce = GetFlowNonce(startResult.State); + // Use a different issuer than what's in config + capturedJwt = jwt.CreateIdToken("https://evil-issuer.test", MockClientId, MockSubject, nonce); + + var callbackResult = await service.HandleCallback("code", startResult.State, MockRedirectUri); + + callbackResult.Success.ShouldBeFalse(); + callbackResult.Error.ShouldContain("ID token validation failed"); + } + finally + { + OidcAuthService.ClearDiscoveryCache(); + } + } + + [Fact] + public async Task HandleCallback_IssuerWithTrailingSlash_ReturnsSuccess() + { + await EnableOidcInConfig(); + OidcAuthService.ClearDiscoveryCache(); + + var jwt = new JwtTestHelper(); + string? capturedJwt = null; + + var handler = CreateDiscoveryHandler( + jwksJson: jwt.GetJwksJson(), + tokenResponseFactory: () => + $$"""{"id_token":"{{capturedJwt}}","access_token":"access-123","token_type":"Bearer"}"""); + + var service = CreateServiceWithHandler(handler); + try + { + var startResult = await service.StartAuthorization(MockRedirectUri); + var nonce = GetFlowNonce(startResult.State); + // Use issuer WITH trailing slash (Authentik-style) while config has no trailing slash + capturedJwt = jwt.CreateIdToken(MockIssuer + "/", MockClientId, MockSubject, nonce); + + var callbackResult = await service.HandleCallback("code", startResult.State, MockRedirectUri); + + callbackResult.Success.ShouldBeTrue(); + callbackResult.Subject.ShouldBe(MockSubject); + } + finally + { + OidcAuthService.ClearDiscoveryCache(); + } + } + + [Fact] + public async Task HandleCallback_MissingSubClaim_ReturnsFailure() + { + await EnableOidcInConfig(); + OidcAuthService.ClearDiscoveryCache(); + + var jwt = new JwtTestHelper(); + string? capturedJwt = null; + + var handler = CreateDiscoveryHandler( + jwksJson: jwt.GetJwksJson(), + tokenResponseFactory: () => + $$"""{"id_token":"{{capturedJwt}}","access_token":"access-123","token_type":"Bearer"}"""); + + var service = CreateServiceWithHandler(handler); + try + { + var startResult = await service.StartAuthorization(MockRedirectUri); + var nonce = GetFlowNonce(startResult.State); + capturedJwt = jwt.CreateIdToken(MockIssuer, MockClientId, subject: null, nonce); + + var callbackResult = await service.HandleCallback("code", startResult.State, MockRedirectUri); + + callbackResult.Success.ShouldBeFalse(); + callbackResult.Error.ShouldContain("missing 'sub' claim"); + } + finally + { + OidcAuthService.ClearDiscoveryCache(); + } + } + + #endregion + + #region Token Exchange Error Handling Tests + + [Fact] + public async Task HandleCallback_TokenEndpointReturnsHttpError_ReturnsFailure() + { + await EnableOidcInConfig(); + OidcAuthService.ClearDiscoveryCache(); + + const string redirectUri = "https://app.test/api/auth/oidc/callback"; + var handler = CreateDiscoveryHandler(tokenResponse: """{"error":"invalid_grant"}""", tokenStatusCode: HttpStatusCode.BadRequest); + var service = CreateServiceWithHandler(handler); + try + { + var startResult = await service.StartAuthorization(redirectUri); + var callbackResult = await service.HandleCallback("some-code", startResult.State, redirectUri); + + callbackResult.Success.ShouldBeFalse(); + callbackResult.Error.ShouldContain("Failed to exchange authorization code"); + } + finally + { + OidcAuthService.ClearDiscoveryCache(); + } + } + + [Fact] + public async Task HandleCallback_TokenEndpointThrowsNetworkError_ReturnsFailure() + { + await EnableOidcInConfig(); + OidcAuthService.ClearDiscoveryCache(); + + const string redirectUri = "https://app.test/api/auth/oidc/callback"; + var handler = CreateDiscoveryHandler(throwNetworkErrorOnToken: true); + var service = CreateServiceWithHandler(handler); + try + { + var startResult = await service.StartAuthorization(redirectUri); + var callbackResult = await service.HandleCallback("some-code", startResult.State, redirectUri); + + callbackResult.Success.ShouldBeFalse(); + callbackResult.Error.ShouldContain("Failed to exchange authorization code"); + } + finally + { + OidcAuthService.ClearDiscoveryCache(); + } + } + + [Fact] + public async Task HandleCallback_TokenResponseMissingIdToken_ReturnsFailure() + { + await EnableOidcInConfig(); + OidcAuthService.ClearDiscoveryCache(); + + const string redirectUri = "https://app.test/api/auth/oidc/callback"; + // Token response with access_token but no id_token — ValidateIdToken will fail on empty string + var handler = CreateDiscoveryHandler(tokenResponse: """{"access_token":"abc","token_type":"Bearer"}"""); + var service = CreateServiceWithHandler(handler); + try + { + var startResult = await service.StartAuthorization(redirectUri); + var callbackResult = await service.HandleCallback("some-code", startResult.State, redirectUri); + + callbackResult.Success.ShouldBeFalse(); + callbackResult.Error.ShouldContain("ID token validation failed"); + } + finally + { + OidcAuthService.ClearDiscoveryCache(); + } + } + + #endregion + + #region Expiry and Capacity Tests (via reflection) + + [Fact] + public void ExchangeOneTimeCode_ExpiredCode_ReturnsNull() + { + var service = CreateService(); + + // Insert a pre-expired entry directly into the static dictionary + var code = InsertExpiredOneTimeCode(); + + var result = service.ExchangeOneTimeCode(code); + + result.ShouldBeNull(); + } + + [Fact] + public async Task HandleCallback_ExpiredFlowState_ReturnsExpiredError() + { + await EnableOidcInConfig(); + OidcAuthService.ClearDiscoveryCache(); + + const string redirectUri = "https://app.test/api/auth/oidc/callback"; + var handler = CreateDiscoveryHandler(); + var service = CreateServiceWithHandler(handler); + try + { + // Get a valid state from StartAuthorization, then backdate its CreatedAt + var startResult = await service.StartAuthorization(redirectUri); + BackdateFlowState(startResult.State, TimeSpan.FromMinutes(11)); + + var callbackResult = await service.HandleCallback("some-code", startResult.State, redirectUri); + + callbackResult.Success.ShouldBeFalse(); + callbackResult.Error.ShouldContain("OIDC flow has expired"); + } + finally + { + OidcAuthService.ClearDiscoveryCache(); + } + } + + [Fact] + public async Task StartAuthorization_WhenAtCapacity_ThrowsInvalidOperationException() + { + await EnableOidcInConfig(); + OidcAuthService.ClearDiscoveryCache(); + + const string redirectUri = "https://app.test/api/auth/oidc/callback"; + var handler = CreateDiscoveryHandler(); + var service = CreateServiceWithHandler(handler); + + var insertedKeys = new List(); + try + { + // Fill PendingFlows up to the maximum (100 entries) + for (var i = 0; i < 100; i++) + { + var key = InsertPendingFlowState(redirectUri); + insertedKeys.Add(key); + } + + // The 101st attempt should throw + await Should.ThrowAsync( + () => service.StartAuthorization(redirectUri), + "Too many pending OIDC flows"); + } + finally + { + RemovePendingFlowStates(insertedKeys); + OidcAuthService.ClearDiscoveryCache(); + } + } + + // --- Reflection helpers --- + + private static string InsertExpiredOneTimeCode() + { + var oneTimeCodesField = typeof(OidcAuthService) + .GetField("OneTimeCodes", BindingFlags.NonPublic | BindingFlags.Static)!; + var oneTimeCodes = oneTimeCodesField.GetValue(null)!; + + var entryType = typeof(OidcAuthService) + .GetNestedType("OidcOneTimeCodeEntry", BindingFlags.NonPublic)!; + var entry = Activator.CreateInstance(entryType)!; + + SetReflectionProperty(entry, "AccessToken", "test-access"); + SetReflectionProperty(entry, "RefreshToken", "test-refresh"); + SetReflectionProperty(entry, "ExpiresIn", 3600); + SetReflectionProperty(entry, "CreatedAt", DateTime.UtcNow - TimeSpan.FromSeconds(31)); + + var code = "expired-test-code-" + Guid.NewGuid().ToString("N"); + oneTimeCodes.GetType().GetMethod("TryAdd")!.Invoke(oneTimeCodes, new[] { code, entry }); + return code; + } + + /// Replaces the stored OidcFlowState with one whose CreatedAt is backdated by the given age. + private static void BackdateFlowState(string state, TimeSpan age) + { + var pendingFlowsField = typeof(OidcAuthService) + .GetField("PendingFlows", BindingFlags.NonPublic | BindingFlags.Static)!; + var pendingFlows = pendingFlowsField.GetValue(null)!; + var dictType = pendingFlows.GetType(); + + // Get the existing entry + var indexer = dictType.GetProperty("Item")!; + var existing = indexer.GetValue(pendingFlows, new object[] { state })!; + + // Build a new entry with CreatedAt backdated + var flowType = existing.GetType(); + var newEntry = Activator.CreateInstance(flowType)!; + + foreach (var prop in flowType.GetProperties()) + { + var value = prop.Name == "CreatedAt" + ? DateTime.UtcNow - age + : prop.GetValue(existing); + SetReflectionProperty(newEntry, prop.Name, value!); + } + + // Replace the entry: TryUpdate(state, newEntry, existing) + var tryUpdate = dictType.GetMethod("TryUpdate")!; + tryUpdate.Invoke(pendingFlows, new[] { state, newEntry, existing }); + } + + private static string InsertPendingFlowState(string redirectUri) + { + var pendingFlowsField = typeof(OidcAuthService) + .GetField("PendingFlows", BindingFlags.NonPublic | BindingFlags.Static)!; + var pendingFlows = pendingFlowsField.GetValue(null)!; + + var flowType = typeof(OidcAuthService) + .GetNestedType("OidcFlowState", BindingFlags.NonPublic)!; + var entry = Activator.CreateInstance(flowType)!; + var key = "capacity-test-" + Guid.NewGuid().ToString("N"); + + SetReflectionProperty(entry, "State", key); + SetReflectionProperty(entry, "Nonce", "test-nonce"); + SetReflectionProperty(entry, "CodeVerifier", "test-verifier"); + SetReflectionProperty(entry, "RedirectUri", redirectUri); + SetReflectionProperty(entry, "CreatedAt", DateTime.UtcNow); + + pendingFlows.GetType().GetMethod("TryAdd")!.Invoke(pendingFlows, new[] { key, entry }); + return key; + } + + private static void RemovePendingFlowStates(IEnumerable keys) + { + var pendingFlowsField = typeof(OidcAuthService) + .GetField("PendingFlows", BindingFlags.NonPublic | BindingFlags.Static)!; + var pendingFlows = pendingFlowsField.GetValue(null)!; + var tryRemove = pendingFlows.GetType().GetMethod("TryRemove", + new[] { typeof(string), pendingFlows.GetType().GetGenericArguments()[1].MakeByRefType() })!; + + foreach (var key in keys) + { + var args = new object?[] { key, null }; + tryRemove.Invoke(pendingFlows, args); + } + } + + private static void SetReflectionProperty(object obj, string propertyName, object value) + { + var prop = obj.GetType() + .GetProperty(propertyName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)!; + prop.SetValue(obj, value); + } + + #endregion + + #region PKCE and Authorization URL Tests + + [Fact] + public async Task StartAuthorization_ReturnUrl_ContainsPkceParameters() + { + await EnableOidcInConfig(); + OidcAuthService.ClearDiscoveryCache(); + + var service = CreateServiceWithHandler(CreateDiscoveryHandler()); + try + { + var result = await service.StartAuthorization("https://app.test/api/auth/oidc/callback"); + + result.AuthorizationUrl.ShouldContain("code_challenge="); + result.AuthorizationUrl.ShouldContain("code_challenge_method=S256"); + } + finally + { + OidcAuthService.ClearDiscoveryCache(); + } + } + + [Fact] + public async Task StartAuthorization_ReturnUrl_ContainsAllRequiredOAuthParams() + { + await EnableOidcInConfig(); + OidcAuthService.ClearDiscoveryCache(); + + var service = CreateServiceWithHandler(CreateDiscoveryHandler()); + const string redirectUri = "https://app.test/api/auth/oidc/callback"; + try + { + var result = await service.StartAuthorization(redirectUri); + var url = result.AuthorizationUrl; + + url.ShouldContain("response_type=code"); + url.ShouldContain("client_id="); + url.ShouldContain("redirect_uri="); + url.ShouldContain("scope="); + url.ShouldContain("state="); + url.ShouldContain("nonce="); + } + finally + { + OidcAuthService.ClearDiscoveryCache(); + } + } + + [Fact] + public async Task StartAuthorization_PkceChallenge_IsValidBase64Url() + { + await EnableOidcInConfig(); + OidcAuthService.ClearDiscoveryCache(); + + var service = CreateServiceWithHandler(CreateDiscoveryHandler()); + try + { + var result = await service.StartAuthorization("https://app.test/api/auth/oidc/callback"); + + // Extract code_challenge from URL + var uri = new Uri(result.AuthorizationUrl); + var queryParts = uri.Query.TrimStart('?').Split('&'); + var challengePart = queryParts.FirstOrDefault(p => p.StartsWith("code_challenge=")); + challengePart.ShouldNotBeNull(); + + var challengeValue = Uri.UnescapeDataString(challengePart.Substring("code_challenge=".Length)); + + // Base64url characters: A-Z a-z 0-9 - _ (no +, /, or =) + challengeValue.ShouldNotContain("+"); + challengeValue.ShouldNotContain("/"); + challengeValue.ShouldNotContain("="); + challengeValue.Length.ShouldBeGreaterThan(0); + } + finally + { + OidcAuthService.ClearDiscoveryCache(); + } + } + + [Fact] + public async Task StartAuthorization_SpecialCharsInConfig_UrlEncodesParameters() + { + // Configure OIDC with special characters in ClientId and Scopes + var user = await _usersContext.Users.FirstAsync(); + user.Oidc = new OidcConfig + { + Enabled = true, + IssuerUrl = "https://mock-oidc-provider.test", + ClientId = "test client+id", // space and plus sign require encoding + Scopes = "openid profile email", // spaces between scopes require encoding + AuthorizedSubject = "test-subject", + ProviderName = "TestProvider" + }; + await _usersContext.SaveChangesAsync(); + OidcAuthService.ClearDiscoveryCache(); + + var service = CreateServiceWithHandler(CreateDiscoveryHandler()); + try + { + var result = await service.StartAuthorization("https://app.test/api/auth/oidc/callback"); + var url = result.AuthorizationUrl; + + // Uri.EscapeDataString: space → %20, + → %2B + url.ShouldContain("client_id=test%20client%2Bid"); + url.ShouldContain("scope=openid%20profile%20email"); + } + finally + { + OidcAuthService.ClearDiscoveryCache(); + } + } + + #endregion + + #region Cleanup Timer Tests + + [Fact] + public void CleanupExpiredEntries_RemovesExpiredFlowsAndCodes() + { + const string redirectUri = "https://app.test/api/auth/oidc/callback"; + var service = CreateService(); + + // Insert an expired flow state and backdate it beyond the expiry window + var expiredFlowKey = InsertPendingFlowState(redirectUri); + BackdateFlowState(expiredFlowKey, TimeSpan.FromMinutes(11)); + + // Insert a valid (non-expired) flow state that cleanup must leave in place + var validFlowKey = InsertPendingFlowState(redirectUri); + + // Insert an expired one-time code and a valid one-time code + var expiredCodeKey = InsertExpiredOneTimeCode(); + var validCodeKey = service.StoreOneTimeCode("access", "refresh", 3600); + + try + { + // Invoke the private static CleanupExpiredEntries directly (bypassing the timer) + var method = typeof(OidcAuthService) + .GetMethod("CleanupExpiredEntries", BindingFlags.NonPublic | BindingFlags.Static)!; + method.Invoke(null, new object?[] { null }); + + // Expired flow state must have been removed + var pendingFlowsField = typeof(OidcAuthService) + .GetField("PendingFlows", BindingFlags.NonPublic | BindingFlags.Static)!; + var pendingFlows = pendingFlowsField.GetValue(null)!; + var containsKeyFlow = pendingFlows.GetType().GetMethod("ContainsKey")!; + ((bool)containsKeyFlow.Invoke(pendingFlows, new object[] { expiredFlowKey })!).ShouldBeFalse(); + + // Valid flow state must still be present + ((bool)containsKeyFlow.Invoke(pendingFlows, new object[] { validFlowKey })!).ShouldBeTrue(); + + // Expired one-time code must have been removed + var oneTimeCodesField = typeof(OidcAuthService) + .GetField("OneTimeCodes", BindingFlags.NonPublic | BindingFlags.Static)!; + var oneTimeCodes = oneTimeCodesField.GetValue(null)!; + var containsKeyCode = oneTimeCodes.GetType().GetMethod("ContainsKey")!; + ((bool)containsKeyCode.Invoke(oneTimeCodes, new object[] { expiredCodeKey })!).ShouldBeFalse(); + + // Valid one-time code must still be present + ((bool)containsKeyCode.Invoke(oneTimeCodes, new object[] { validCodeKey })!).ShouldBeTrue(); + } + finally + { + RemovePendingFlowStates(new[] { validFlowKey }); + service.ExchangeOneTimeCode(validCodeKey); // consume to clean up + } + } + + #endregion + + public void Dispose() + { + _usersContext.Dispose(); + _connection.Dispose(); + } + + /// + /// Creates RSA-signed JWTs for use in ID token validation tests. + /// + private sealed class JwtTestHelper + { + private readonly RSA _rsa = RSA.Create(2048); + private readonly RsaSecurityKey _key; + + public JwtTestHelper() + { + _key = new RsaSecurityKey(_rsa) { KeyId = "test-key-1" }; + } + + /// Creates a signed JWT. Pass subject=null to produce a token with no 'sub' claim. + public string CreateIdToken(string issuer, string audience, string? subject, string nonce, + DateTime? expiry = null, DateTime? notBefore = null) + { + var claims = new List { new("nonce", nonce) }; + if (subject is not null) + claims.Add(new Claim("sub", subject)); + + var expiresAt = expiry ?? DateTime.UtcNow.AddHours(1); + var notBeforeAt = notBefore ?? DateTime.UtcNow.AddMinutes(-1); + + var descriptor = new SecurityTokenDescriptor + { + Issuer = issuer, + Audience = audience, + Subject = new ClaimsIdentity(claims), + NotBefore = notBeforeAt, + Expires = expiresAt, + IssuedAt = notBeforeAt, + SigningCredentials = new SigningCredentials(_key, SecurityAlgorithms.RsaSha256) + }; + + var handler = new JwtSecurityTokenHandler(); + return handler.WriteToken(handler.CreateToken(descriptor)); + } + + public string GetJwksJson() + { + var rsaParams = _rsa.ExportParameters(includePrivateParameters: false); + return JsonSerializer.Serialize(new + { + keys = new[] + { + new + { + kty = "RSA", + use = "sig", + kid = _key.KeyId, + alg = "RS256", + n = Base64UrlEncode(rsaParams.Modulus!), + e = Base64UrlEncode(rsaParams.Exponent!) + } + } + }); + } + + private static string Base64UrlEncode(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } + + private sealed class MockHttpMessageHandler : HttpMessageHandler + { + private readonly Func _handler; + + public MockHttpMessageHandler(Func handler) + { + _handler = handler; + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + try + { + return Task.FromResult(_handler(request)); + } + catch (Exception ex) + { + // Convert synchronous exceptions to faulted Tasks so HttpClient propagates them correctly + return Task.FromException(ex); + } + } + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Cleanuparr.Infrastructure.csproj b/code/backend/Cleanuparr.Infrastructure/Cleanuparr.Infrastructure.csproj index 0d9e7589..3f79b3b8 100644 --- a/code/backend/Cleanuparr.Infrastructure/Cleanuparr.Infrastructure.csproj +++ b/code/backend/Cleanuparr.Infrastructure/Cleanuparr.Infrastructure.csproj @@ -15,6 +15,7 @@ + diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Auth/IOidcAuthService.cs b/code/backend/Cleanuparr.Infrastructure/Features/Auth/IOidcAuthService.cs new file mode 100644 index 00000000..62f73aa3 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Auth/IOidcAuthService.cs @@ -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; } + + /// + /// 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. + /// + public string? InitiatorUserId { get; init; } +} + +public interface IOidcAuthService +{ + /// + /// Generates the OIDC authorization URL and stores state/verifier for the callback. + /// + /// The callback URI for the OIDC provider. + /// Optional user ID of the authenticated user initiating the flow (for account linking). + Task StartAuthorization(string redirectUri, string? initiatorUserId = null); + + /// + /// Handles the OIDC callback: validates state, exchanges code for tokens, validates the ID token. + /// + Task HandleCallback(string code, string state, string redirectUri); + + /// + /// Stores tokens associated with a one-time exchange code. + /// Returns the one-time code. + /// + string StoreOneTimeCode(string accessToken, string refreshToken, int expiresIn); + + /// + /// Exchanges a one-time code for the stored tokens. + /// The code is consumed (can only be used once). + /// + 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; } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Auth/OidcAuthService.cs b/code/backend/Cleanuparr.Infrastructure/Features/Auth/OidcAuthService.cs new file mode 100644 index 00000000..1282635b --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Auth/OidcAuthService.cs @@ -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 PendingFlows = new(); + private static readonly ConcurrentDictionary OneTimeCodes = new(); + private static readonly ConcurrentDictionary> 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 _logger; + + public OidcAuthService( + IHttpClientFactory httpClientFactory, + UsersContext usersContext, + ILogger logger) + { + _httpClient = httpClientFactory.CreateClient("OidcAuth"); + _usersContext = usersContext; + _logger = logger; + } + + public async Task 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 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 GetOidcConfig() + { + var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync(); + return user?.Oidc ?? new OidcConfig(); + } + + private async Task 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( + metadataAddress, + new OpenIdConnectConfigurationRetriever(), + new HttpDocumentRetriever(_httpClient) { RequireHttps = !isLocalhost }); + }); + + return await configManager.GetConfigurationAsync(); + } + + private async Task ExchangeCodeForTokens( + string tokenEndpoint, + string code, + string codeVerifier, + string redirectUri, + string clientId, + string clientSecret) + { + var parameters = new Dictionary + { + ["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(); + } + catch (Exception ex) + { + _logger.LogError(ex, "OIDC token exchange failed"); + return null; + } + } + + private async Task 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 + { + ["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 _); + } + } + } + + /// + /// Clears the cached OIDC discovery configuration. Used when issuer URL changes. + /// + 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; + } +} diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/General/OidcConfigTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/General/OidcConfigTests.cs new file mode 100644 index 00000000..7d0da661 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/General/OidcConfigTests.cs @@ -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(() => 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(() => 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(() => 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(() => 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(() => 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(() => 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(() => config.Validate()); + exception.Message.ShouldBe("OIDC Issuer URL must use HTTPS"); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Users/20260312090408_AddOidcSupport.Designer.cs b/code/backend/Cleanuparr.Persistence/Migrations/Users/20260312090408_AddOidcSupport.Designer.cs new file mode 100644 index 00000000..49db87c6 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Users/20260312090408_AddOidcSupport.Designer.cs @@ -0,0 +1,271 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CodeHash") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("code_hash"); + + b.Property("IsUsed") + .HasColumnType("INTEGER") + .HasColumnName("is_used"); + + b.Property("UsedAt") + .HasColumnType("TEXT") + .HasColumnName("used_at"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT") + .HasColumnName("expires_at"); + + b.Property("RevokedAt") + .HasColumnType("TEXT") + .HasColumnName("revoked_at"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("token_hash"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("FailedLoginAttempts") + .HasColumnType("INTEGER") + .HasColumnName("failed_login_attempts"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT") + .HasColumnName("lockout_end"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("password_hash"); + + b.Property("PlexAccountId") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("plex_account_id"); + + b.Property("PlexAuthToken") + .HasColumnType("TEXT") + .HasColumnName("plex_auth_token"); + + b.Property("PlexEmail") + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("plex_email"); + + b.Property("PlexUsername") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("plex_username"); + + b.Property("SetupCompleted") + .HasColumnType("INTEGER") + .HasColumnName("setup_completed"); + + b.Property("TotpEnabled") + .HasColumnType("INTEGER") + .HasColumnName("totp_enabled"); + + b.Property("TotpSecret") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("totp_secret"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.ComplexProperty(typeof(Dictionary), "Oidc", "Cleanuparr.Persistence.Models.Auth.User.Oidc#OidcConfig", b1 => + { + b1.IsRequired(); + + b1.Property("AuthorizedSubject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("oidc_authorized_subject"); + + b1.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("oidc_client_id"); + + b1.Property("ClientSecret") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("oidc_client_secret"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("oidc_enabled"); + + b1.Property("ExclusiveMode") + .HasColumnType("INTEGER") + .HasColumnName("oidc_exclusive_mode"); + + b1.Property("IssuerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("oidc_issuer_url"); + + b1.Property("ProviderName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("oidc_provider_name"); + + b1.Property("RedirectUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("oidc_redirect_url"); + + b1.Property("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 + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Users/20260312090408_AddOidcSupport.cs b/code/backend/Cleanuparr.Persistence/Migrations/Users/20260312090408_AddOidcSupport.cs new file mode 100644 index 00000000..ac541c34 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Users/20260312090408_AddOidcSupport.cs @@ -0,0 +1,124 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Users +{ + /// + public partial class AddOidcSupport : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "oidc_authorized_subject", + table: "users", + type: "TEXT", + maxLength: 500, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "oidc_client_id", + table: "users", + type: "TEXT", + maxLength: 200, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "oidc_client_secret", + table: "users", + type: "TEXT", + maxLength: 500, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "oidc_enabled", + table: "users", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "oidc_exclusive_mode", + table: "users", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "oidc_issuer_url", + table: "users", + type: "TEXT", + maxLength: 500, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "oidc_provider_name", + table: "users", + type: "TEXT", + maxLength: 100, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "oidc_redirect_url", + table: "users", + type: "TEXT", + maxLength: 500, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "oidc_scopes", + table: "users", + type: "TEXT", + maxLength: 500, + nullable: false, + defaultValue: ""); + } + + /// + 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"); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Users/UsersContextModelSnapshot.cs b/code/backend/Cleanuparr.Persistence/Migrations/Users/UsersContextModelSnapshot.cs index c60b61a1..a5f0b82a 100644 --- a/code/backend/Cleanuparr.Persistence/Migrations/Users/UsersContextModelSnapshot.cs +++ b/code/backend/Cleanuparr.Persistence/Migrations/Users/UsersContextModelSnapshot.cs @@ -1,5 +1,6 @@ // 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), "Oidc", "Cleanuparr.Persistence.Models.Auth.User.Oidc#OidcConfig", b1 => + { + b1.IsRequired(); + + b1.Property("AuthorizedSubject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("oidc_authorized_subject"); + + b1.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("oidc_client_id"); + + b1.Property("ClientSecret") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("oidc_client_secret"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("oidc_enabled"); + + b1.Property("ExclusiveMode") + .HasColumnType("INTEGER") + .HasColumnName("oidc_exclusive_mode"); + + b1.Property("IssuerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("oidc_issuer_url"); + + b1.Property("ProviderName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("oidc_provider_name"); + + b1.Property("RedirectUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("oidc_redirect_url"); + + b1.Property("Scopes") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("oidc_scopes"); + }); + b.HasKey("Id") .HasName("pk_users"); diff --git a/code/backend/Cleanuparr.Persistence/Models/Auth/OidcConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Auth/OidcConfig.cs new file mode 100644 index 00000000..6c370ecb --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Models/Auth/OidcConfig.cs @@ -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; } + + /// + /// The OIDC provider's issuer URL (e.g., https://authentik.example.com/application/o/cleanuparr/). + /// Used to discover the .well-known/openid-configuration endpoints. + /// + [MaxLength(500)] + public string IssuerUrl { get; set; } = string.Empty; + + /// + /// The Client ID registered at the identity provider. + /// + [MaxLength(200)] + public string ClientId { get; set; } = string.Empty; + + /// + /// The Client Secret (optional; for confidential clients). + /// + [SensitiveData] + [MaxLength(500)] + public string ClientSecret { get; set; } = string.Empty; + + /// + /// Space-separated OIDC scopes to request. + /// + [MaxLength(500)] + public string Scopes { get; set; } = "openid profile email"; + + /// + /// The OIDC subject ("sub" claim) that identifies the authorized user. + /// Set during OIDC account linking. Only this subject can log in via OIDC. + /// + [MaxLength(500)] + public string AuthorizedSubject { get; set; } = string.Empty; + + /// + /// Display name for the OIDC provider (shown on the login button, e.g., "Authentik"). + /// + [MaxLength(100)] + public string ProviderName { get; set; } = "OIDC"; + + /// + /// 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. + /// + [MaxLength(500)] + public string RedirectUrl { get; set; } = string.Empty; + + /// + /// When enabled, all non-OIDC login methods (username/password, Plex) are disabled. + /// Requires OIDC to be fully configured with an authorized subject. + /// + 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]"; + } +} diff --git a/code/backend/Cleanuparr.Persistence/Models/Auth/User.cs b/code/backend/Cleanuparr.Persistence/Models/Auth/User.cs index 85d37edf..4c37cbda 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Auth/User.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Auth/User.cs @@ -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; } diff --git a/code/backend/Cleanuparr.Persistence/UsersContext.cs b/code/backend/Cleanuparr.Persistence/UsersContext.cs index 4ee8e802..19c54193 100644 --- a/code/backend/Cleanuparr.Persistence/UsersContext.cs +++ b/code/backend/Cleanuparr.Persistence/UsersContext.cs @@ -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) diff --git a/code/frontend/package-lock.json b/code/frontend/package-lock.json index 72842f65..7a2c911d 100644 --- a/code/frontend/package-lock.json +++ b/code/frontend/package-lock.json @@ -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", diff --git a/code/frontend/src/app/app.routes.ts b/code/frontend/src/app/app.routes.ts index 662cf7cf..d67a6404 100644 --- a/code/frontend/src/app/app.routes.ts +++ b/code/frontend/src/app/app.routes.ts @@ -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' }, diff --git a/code/frontend/src/app/core/api/account.api.ts b/code/frontend/src/app/core/api/account.api.ts index 44714a9e..5880b58d 100644 --- a/code/frontend/src/app/core/api/account.api.ts +++ b/code/frontend/src/app/core/api/account.api.ts @@ -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 { return this.http.delete('/api/account/plex/link'); } + + getOidcConfig(): Observable { + return this.http.get('/api/account/oidc'); + } + + updateOidcConfig(config: Partial): Observable { + return this.http.put('/api/account/oidc', config); + } + + unlinkOidc(): Observable { + return this.http.delete('/api/account/oidc/link'); + } } diff --git a/code/frontend/src/app/core/auth/auth.service.ts b/code/frontend/src/app/core/auth/auth.service.ts index 76a4292a..048006a1 100644 --- a/code/frontend/src/app/core/auth/auth.service.ts +++ b/code/frontend/src/app/core/auth/auth.service.ts @@ -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 | null = null; private refreshInFlight$: Observable | 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 { + return this.http + .post('/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 { // Deduplicate: if a refresh is already in-flight, share the same observable diff --git a/code/frontend/src/app/core/services/documentation.service.ts b/code/frontend/src/app/core/services/documentation.service.ts index 0ba95c84..a59ccd51 100644 --- a/code/frontend/src/app/core/services/documentation.service.ts +++ b/code/frontend/src/app/core/services/documentation.service.ts @@ -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', diff --git a/code/frontend/src/app/features/auth/login/login.component.html b/code/frontend/src/app/features/auth/login/login.component.html index dd7a91d2..dd035915 100644 --- a/code/frontend/src/app/features/auth/login/login.component.html +++ b/code/frontend/src/app/features/auth/login/login.component.html @@ -11,53 +11,76 @@ @if (view() === 'credentials') {