+ Plex login is disabled while OIDC exclusive mode is active.
+
+ }
@if (account()!.plexLinked) {
Linked Account
@@ -291,7 +301,7 @@
@if (plexUnlinking()) {
@@ -308,7 +318,7 @@
@if (plexLinking()) {
@@ -321,5 +331,78 @@
}
+
+
+
+
+
+ @if (oidcEnabled()) {
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @if (oidcAuthorizedSubject()) {
+
+ Linked to subject: {{ oidcAuthorizedSubject() }}
+
+ } @else {
+
+ No account linked — any user who can authenticate with your provider and is allowed to access this app can sign in. Link an account to restrict access.
+
+ }
+
}
diff --git a/code/frontend/src/app/features/settings/account/account-settings.component.scss b/code/frontend/src/app/features/settings/account/account-settings.component.scss
index ee4d7985..f2c0577a 100644
--- a/code/frontend/src/app/features/settings/account/account-settings.component.scss
+++ b/code/frontend/src/app/features/settings/account/account-settings.component.scss
@@ -8,6 +8,15 @@
.form-divider { @include form-divider; }
.form-actions { @include form-actions; }
+.section-notice {
+ padding: var(--space-3);
+ border-radius: var(--radius-md);
+ background: rgba(234, 179, 8, 0.1);
+ color: var(--color-warning);
+ font-size: var(--font-size-sm);
+ line-height: 1.5;
+}
+
.section-hint {
font-size: var(--font-size-sm);
color: var(--text-secondary);
@@ -168,6 +177,40 @@
flex-shrink: 0;
}
+// OIDC link section
+.oidc-link-section {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-4);
+
+ &__info {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ }
+
+ &__hint {
+ font-size: var(--font-size-xs);
+ color: var(--text-tertiary);
+ }
+
+ &__actions {
+ display: flex;
+ gap: var(--space-2);
+ flex-shrink: 0;
+ }
+
+ &__subject {
+ font-family: var(--font-family-mono, monospace);
+ font-size: var(--font-size-xs);
+ color: var(--text-secondary);
+ background: var(--bg-tertiary);
+ padding: var(--space-0-5) var(--space-1);
+ border-radius: var(--radius-sm);
+ }
+}
+
// Password strength indicator
.password-strength {
display: flex;
diff --git a/code/frontend/src/app/features/settings/account/account-settings.component.ts b/code/frontend/src/app/features/settings/account/account-settings.component.ts
index c4b9c674..b0d354cd 100644
--- a/code/frontend/src/app/features/settings/account/account-settings.component.ts
+++ b/code/frontend/src/app/features/settings/account/account-settings.component.ts
@@ -1,10 +1,14 @@
-import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit, OnDestroy } from '@angular/core';
+import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, OnInit, OnDestroy } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
import { PageHeaderComponent } from '@layout/page-header/page-header.component';
import {
CardComponent, ButtonComponent, InputComponent, SpinnerComponent,
+ AccordionComponent, ToggleComponent, LabelComponent,
EmptyStateComponent, LoadingStateComponent,
} from '@ui';
+import { forkJoin } from 'rxjs';
import { AccountApi, AccountInfo } from '@core/api/account.api';
+import { AuthService } from '@core/auth/auth.service';
import { ToastService } from '@core/services/toast.service';
import { ConfirmService } from '@core/services/confirm.service';
import { DeferredLoader } from '@shared/utils/loading.util';
@@ -15,7 +19,8 @@ import { QRCodeComponent } from 'angularx-qrcode';
standalone: true,
imports: [
PageHeaderComponent, CardComponent, ButtonComponent, InputComponent,
- SpinnerComponent, EmptyStateComponent, LoadingStateComponent, QRCodeComponent,
+ SpinnerComponent, AccordionComponent, ToggleComponent,
+ EmptyStateComponent, LoadingStateComponent, QRCodeComponent, LabelComponent,
],
templateUrl: './account-settings.component.html',
styleUrl: './account-settings.component.scss',
@@ -23,8 +28,10 @@ import { QRCodeComponent } from 'angularx-qrcode';
})
export class AccountSettingsComponent implements OnInit, OnDestroy {
private readonly api = inject(AccountApi);
+ private readonly auth = inject(AuthService);
private readonly toast = inject(ToastService);
private readonly confirmService = inject(ConfirmService);
+ private readonly route = inject(ActivatedRoute);
readonly loader = new DeferredLoader();
readonly loadError = signal(false);
@@ -78,7 +85,40 @@ export class AccountSettingsComponent implements OnInit, OnDestroy {
readonly plexUnlinking = signal(false);
private plexPollTimer: ReturnType | null = null;
+ // OIDC
+ readonly oidcEnabled = signal(false);
+ readonly oidcIssuerUrl = signal('');
+ readonly oidcClientId = signal('');
+ readonly oidcClientSecret = signal('');
+ readonly oidcScopes = signal('openid profile email');
+ readonly oidcProviderName = signal('OIDC');
+ readonly oidcRedirectUrl = signal('');
+ readonly oidcAuthorizedSubject = signal('');
+ readonly oidcExpanded = signal(false);
+ readonly oidcExclusiveMode = signal(false);
+ readonly oidcLinking = signal(false);
+ readonly oidcUnlinking = signal(false);
+ readonly oidcSaving = signal(false);
+ readonly oidcSaved = signal(false);
+
+ constructor() {
+ // Reset exclusive mode when OIDC is toggled off
+ effect(() => {
+ if (!this.oidcEnabled()) {
+ this.oidcExclusiveMode.set(false);
+ }
+ });
+ }
+
ngOnInit(): void {
+ const params = this.route.snapshot.queryParams;
+ if (params['oidc_link'] === 'success') {
+ this.toast.success('OIDC account linked successfully');
+ this.oidcExpanded.set(true);
+ } else if (params['oidc_link_error']) {
+ this.toast.error('Failed to link OIDC account');
+ this.oidcExpanded.set(true);
+ }
this.loadAccount();
}
@@ -90,9 +130,18 @@ export class AccountSettingsComponent implements OnInit, OnDestroy {
private loadAccount(): void {
this.loader.start();
- this.api.getInfo().subscribe({
- next: (info) => {
+ forkJoin([this.api.getInfo(), this.api.getOidcConfig()]).subscribe({
+ next: ([info, oidc]) => {
this.account.set(info);
+ this.oidcEnabled.set(oidc.enabled);
+ this.oidcIssuerUrl.set(oidc.issuerUrl);
+ this.oidcClientId.set(oidc.clientId);
+ this.oidcClientSecret.set(oidc.clientSecret);
+ this.oidcScopes.set(oidc.scopes || 'openid profile email');
+ this.oidcProviderName.set(oidc.providerName || 'OIDC');
+ this.oidcRedirectUrl.set(oidc.redirectUrl || '');
+ this.oidcAuthorizedSubject.set(oidc.authorizedSubject);
+ this.oidcExclusiveMode.set(oidc.exclusiveMode);
this.loader.stop();
},
error: () => {
@@ -362,4 +411,68 @@ export class AccountSettingsComponent implements OnInit, OnDestroy {
},
});
}
+
+ // OIDC
+ saveOidcConfig(): void {
+ this.oidcSaving.set(true);
+ this.api.updateOidcConfig({
+ enabled: this.oidcEnabled(),
+ issuerUrl: this.oidcIssuerUrl(),
+ clientId: this.oidcClientId(),
+ clientSecret: this.oidcClientSecret(),
+ scopes: this.oidcScopes(),
+ authorizedSubject: this.oidcAuthorizedSubject(),
+ providerName: this.oidcProviderName(),
+ redirectUrl: this.oidcRedirectUrl(),
+ exclusiveMode: this.oidcExclusiveMode(),
+ }).subscribe({
+ next: () => {
+ this.toast.success('OIDC settings saved');
+ this.oidcSaving.set(false);
+ this.oidcSaved.set(true);
+ setTimeout(() => this.oidcSaved.set(false), 1500);
+ },
+ error: () => {
+ this.toast.error('Failed to save OIDC settings');
+ this.oidcSaving.set(false);
+ },
+ });
+ }
+
+ startOidcLink(): void {
+ this.oidcLinking.set(true);
+ this.auth.startOidcLink().subscribe({
+ next: (result) => {
+ window.location.href = result.authorizationUrl;
+ },
+ error: () => {
+ this.toast.error('Failed to start OIDC account linking');
+ this.oidcLinking.set(false);
+ },
+ });
+ }
+
+ async confirmUnlinkOidc(): Promise {
+ const confirmed = await this.confirmService.confirm({
+ title: 'Unlink OIDC Account',
+ message: 'This will remove the linked identity. Anyone who can authenticate with your identity provider and is allowed to access this application will be able to sign in.',
+ confirmLabel: 'Unlink',
+ destructive: true,
+ });
+ if (!confirmed) return;
+
+ this.oidcUnlinking.set(true);
+ this.api.unlinkOidc().subscribe({
+ next: () => {
+ this.oidcAuthorizedSubject.set('');
+ this.oidcExclusiveMode.set(false);
+ this.toast.success('OIDC account unlinked');
+ this.oidcUnlinking.set(false);
+ },
+ error: () => {
+ this.toast.error('Failed to unlink OIDC account');
+ this.oidcUnlinking.set(false);
+ },
+ });
+ }
}
diff --git a/code/frontend/src/app/features/settings/general/general-settings.component.ts b/code/frontend/src/app/features/settings/general/general-settings.component.ts
index d5a5c179..f4f60012 100644
--- a/code/frontend/src/app/features/settings/general/general-settings.component.ts
+++ b/code/frontend/src/app/features/settings/general/general-settings.component.ts
@@ -1,7 +1,7 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit, viewChildren } from '@angular/core';
import { PageHeaderComponent } from '@layout/page-header/page-header.component';
import {
- CardComponent, ButtonComponent, ToggleComponent,
+ CardComponent, ButtonComponent, ToggleComponent, InputComponent,
NumberInputComponent, SelectComponent, ChipInputComponent, AccordionComponent,
EmptyStateComponent, LoadingStateComponent,
type SelectOption,
@@ -9,7 +9,7 @@ import {
import { GeneralConfigApi } from '@core/api/general-config.api';
import { ToastService } from '@core/services/toast.service';
import { ConfirmService } from '@core/services/confirm.service';
-import { GeneralConfig, LoggingConfig } from '@shared/models/general-config.model';
+import { GeneralConfig } from '@shared/models/general-config.model';
import { CertificateValidationType, LogEventLevel } from '@shared/models/enums';
import { HasPendingChanges } from '@core/guards/pending-changes.guard';
import { DeferredLoader } from '@shared/utils/loading.util';
@@ -34,7 +34,7 @@ const LOG_LEVEL_OPTIONS: SelectOption[] = [
standalone: true,
imports: [
PageHeaderComponent, CardComponent, ButtonComponent,
- ToggleComponent, NumberInputComponent, SelectComponent, ChipInputComponent,
+ ToggleComponent, InputComponent, NumberInputComponent, SelectComponent, ChipInputComponent,
AccordionComponent, EmptyStateComponent, LoadingStateComponent,
],
templateUrl: './general-settings.component.html',
diff --git a/code/frontend/src/app/shared/models/oidc-config.model.ts b/code/frontend/src/app/shared/models/oidc-config.model.ts
new file mode 100644
index 00000000..a5dc4132
--- /dev/null
+++ b/code/frontend/src/app/shared/models/oidc-config.model.ts
@@ -0,0 +1,11 @@
+export interface OidcConfig {
+ enabled: boolean;
+ issuerUrl: string;
+ clientId: string;
+ clientSecret: string;
+ scopes: string;
+ authorizedSubject: string;
+ providerName: string;
+ redirectUrl: string;
+ exclusiveMode: boolean;
+}
diff --git a/code/frontend/src/app/ui/index.ts b/code/frontend/src/app/ui/index.ts
index 600d0b0f..3dccfb6b 100644
--- a/code/frontend/src/app/ui/index.ts
+++ b/code/frontend/src/app/ui/index.ts
@@ -3,6 +3,7 @@ export { ButtonComponent } from './button/button.component';
export type { ButtonVariant, ButtonSize } from './button/button.component';
export { CardComponent } from './card/card.component';
export { InputComponent } from './input/input.component';
+export { LabelComponent } from './label/label.component';
export { SpinnerComponent } from './spinner/spinner.component';
export { ToggleComponent } from './toggle/toggle.component';
export { IconComponent } from './icon/icon.component';
diff --git a/code/frontend/src/app/ui/label/label.component.html b/code/frontend/src/app/ui/label/label.component.html
new file mode 100644
index 00000000..6735baa9
--- /dev/null
+++ b/code/frontend/src/app/ui/label/label.component.html
@@ -0,0 +1,9 @@
+
+ {{ label() }}
+ @if (helpKey()) {
+
+ }
+
diff --git a/code/frontend/src/app/ui/label/label.component.scss b/code/frontend/src/app/ui/label/label.component.scss
new file mode 100644
index 00000000..ffa8d3d5
--- /dev/null
+++ b/code/frontend/src/app/ui/label/label.component.scss
@@ -0,0 +1,5 @@
+.label {
+ font-size: var(--font-size-sm);
+ font-weight: 500;
+ color: var(--text-primary);
+}
diff --git a/code/frontend/src/app/ui/label/label.component.ts b/code/frontend/src/app/ui/label/label.component.ts
new file mode 100644
index 00000000..9bc33c08
--- /dev/null
+++ b/code/frontend/src/app/ui/label/label.component.ts
@@ -0,0 +1,28 @@
+import { Component, ChangeDetectionStrategy, input, inject } from '@angular/core';
+import { NgIcon } from '@ng-icons/core';
+import { DocumentationService } from '@core/services/documentation.service';
+
+@Component({
+ selector: 'app-label',
+ standalone: true,
+ imports: [NgIcon],
+ templateUrl: './label.component.html',
+ styleUrl: './label.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class LabelComponent {
+ private readonly docs = inject(DocumentationService);
+
+ label = input.required();
+ helpKey = input();
+
+ onHelpClick(event: Event): void {
+ event.preventDefault();
+ event.stopPropagation();
+ const key = this.helpKey();
+ if (key) {
+ const [section, field] = key.split(':');
+ this.docs.openFieldDocumentation(section, field);
+ }
+ }
+}
diff --git a/docs/contributing/e2e-testing.md b/docs/contributing/e2e-testing.md
new file mode 100644
index 00000000..312d7acc
--- /dev/null
+++ b/docs/contributing/e2e-testing.md
@@ -0,0 +1,49 @@
+# End-to-End Testing with Keycloak and Playwright
+
+E2E tests use a real Keycloak instance and Playwright browser automation to validate full OIDC round-trips that mocked tests cannot catch.
+
+## Test Coverage Layers
+
+| Layer | What it catches |
+|-------|----------------|
+| Unit tests (`OidcAuthServiceTests`) | PKCE, URL encoding, token validation logic, expiry handling |
+| Integration tests (`OidcAuthControllerTests`, `AccountControllerOidcTests`) | HTTP routing, middleware, cookie/token handling (mocked IdP) |
+| **E2E tests** | Real browser redirects, actual Keycloak protocol, full OIDC round-trip |
+
+## Prerequisites
+
+- Docker + Docker Compose
+- Node.js 18+
+- GitHub Packages credentials (for building the app image)
+
+## Running Locally
+
+```bash
+cd e2e
+
+# Start Keycloak + Cleanuparr
+docker compose -f docker-compose.e2e.yml up -d --build
+
+# Install dependencies and browser
+npm install
+npx playwright install chromium
+
+# Run tests (global setup waits for services and provisions the app automatically)
+npx playwright test
+
+# Tear down
+docker compose -f docker-compose.e2e.yml down
+```
+
+## How It Works
+
+1. **Docker Compose** starts Keycloak (with a pre-configured realm) and the Cleanuparr app
+2. **Playwright `globalSetup`** (`tests/global-setup.ts`) automatically waits for both services, creates an admin account, and configures OIDC settings via the API
+3. **`01-oidc-link.spec.ts`** logs in with local credentials, navigates to settings, and links the account to the Keycloak user (this sets `AuthorizedSubject`)
+4. **`02-oidc-login.spec.ts`** verifies the full OIDC login flow — clicking "Sign in with Keycloak" on the login page, authenticating at Keycloak, and landing on the dashboard
+
+The link test must run before the login test because the OIDC login button only appears after `AuthorizedSubject` is set.
+
+## CI
+
+E2E tests run automatically on PRs that touch `code/**` or `e2e/**` via `.github/workflows/e2e.yml`.
diff --git a/docs/docs/configuration/account/index.mdx b/docs/docs/configuration/account/index.mdx
new file mode 100644
index 00000000..e902ee78
--- /dev/null
+++ b/docs/docs/configuration/account/index.mdx
@@ -0,0 +1,182 @@
+---
+sidebar_position: 8
+---
+
+import {
+ ConfigSection,
+ Note,
+ Important,
+ Warning,
+ ElementNavigator,
+ SectionTitle,
+ styles
+} from '@site/src/components/documentation';
+
+# Account Settings
+
+Manage your account security and external identity provider integrations.
+
+
+
+
+
+
+
+OIDC Settings
+
+
+
+Master switch to enable or disable OIDC authentication. When disabled, the OIDC login button is hidden and all OIDC-related settings are ignored.
+
+
+
+
+
+The display name shown on the login button. Set this to the name of your identity provider so users know where they're signing in.
+
+**Examples:**
+```
+Authentik
+Authelia
+Keycloak
+My SSO
+```
+
+
+
+
+
+The OpenID Connect issuer URL from your identity provider. Cleanuparr uses this to automatically discover your provider's endpoints (authorization, token, user info, etc.).
+
+This URL must use **HTTPS** (except `localhost` for development purposes). You can find it in your provider's application or client settings — it is sometimes called the "Discovery URL" or "OpenID Configuration URL".
+
+**Where to find it:**
+
+| Provider | Issuer URL format |
+|----------|-------------------|
+| **Authentik** | `https://auth.example.com/application/o/cleanuparr/` |
+| **Authelia** | `https://auth.example.com` |
+| **Keycloak** | `https://keycloak.example.com/realms/your-realm` |
+
+
+If you are unsure, visit `{your-provider-url}/.well-known/openid-configuration` in a browser. The `issuer` field in the JSON response is the value you need.
+
+
+
+
+
+
+The client identifier assigned to Cleanuparr by your identity provider. You get this when you create a new application/client in your provider.
+
+**Where to find it:**
+
+| Provider | Location |
+|----------|----------|
+| **Authentik** | Applications → your app → Provider → Client ID |
+| **Authelia** | Configuration file → identity_providers → oidc → clients → client_id |
+| **Keycloak** | Clients → your client → Client ID |
+
+
+
+
+
+The client secret assigned by your identity provider. This is **optional** — whether you need it depends on your provider's configuration:
+
+- **Confidential client** (most common): A secret is required. Your provider generates one when you create the application.
+- **Public client**: No secret is needed. Some providers support this for applications that cannot securely store a secret.
+
+If you are unsure, your provider most likely requires a secret.
+
+
+
+
+
+Space-separated list of OIDC scopes to request from your identity provider. Scopes control what information Cleanuparr receives about the authenticated user.
+
+**Default:** `openid profile email`
+
+You typically do not need to change this. The default scopes request the user's identity (`openid`), profile information (`profile`), and email address (`email`).
+
+
+Only change this if your provider requires different scopes or you have a specific need. The `openid` scope is always required.
+
+
+
+
+
+
+The base URL where Cleanuparr is accessible from the outside. Cleanuparr appends callback paths automatically — you only need to provide the base URL.
+
+**Leave this empty** to let Cleanuparr auto-detect the URL from incoming requests. Set it explicitly if:
+- Cleanuparr is behind a reverse proxy
+- The auto-detected URL is incorrect
+- You access Cleanuparr via a custom domain
+
+**Examples:**
+```
+https://cleanuparr.example.com
+https://media.example.com/cleanuparr
+```
+
+
+This URL must match the **redirect URI** configured in your identity provider. In your provider, set the redirect/callback URI to:
+
+- **Login callback:** `https://cleanuparr.example.com/api/auth/oidc/callback`
+- **Link callback:** `https://cleanuparr.example.com/api/account/oidc/link/callback`
+
+Replace `https://cleanuparr.example.com` with your actual base URL.
+
+
+
+
+
+
+Linking an account is **optional**. By default, when no account is linked, **any user who can authenticate with your identity provider and has access to this app** is allowed to sign in. Your provider controls who has access — if a user can log in to the configured OIDC client, they are permitted into Cleanuparr.
+
+If you want to **restrict access to a single identity**, click the **Link Account** button to connect your Cleanuparr account to a specific user from your provider. This opens your provider's login page, where you authenticate and authorize Cleanuparr. Once linked, **only that specific identity** can sign in via OIDC — all other users from your provider will be rejected.
+
+**Steps to link:**
+1. Fill in all OIDC settings above and click **Save OIDC Settings**.
+2. Click **Link Account**.
+3. Sign in with your identity provider when prompted.
+4. You are redirected back to Cleanuparr with a success message.
+
+
+You can re-link at any time by clicking **Re-link**. This replaces the currently linked identity with the new one.
+
+
+
+
+
+
+When enabled, **only OIDC login is allowed**. Username/password login and Plex login are completely disabled. This is useful if you want to enforce that all authentication goes through your identity provider.
+
+
+**Lockout risk:** If your identity provider goes down or becomes unreachable while exclusive mode is active, you will not be able to sign in to Cleanuparr. To recover, you would need to directly modify the database to disable exclusive mode.
+
+Only enable this if your identity provider is reliable and you have a recovery plan.
+
+
+
+
+