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 3cc3815c..8c2727c5 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 @@ -413,7 +413,25 @@ export class AccountSettingsComponent implements OnInit, OnDestroy { } // OIDC - saveOidcConfig(): void { + async saveOidcConfig(): Promise { + if (this.oidcEnabled() && !this.oidcAuthorizedSubject()) { + const confirmed = await this.confirmService.confirm({ + title: 'Enable OIDC without a linked account', + message: + 'No OIDC account is linked. Anyone who can authenticate with your identity provider ' + + 'and has access to this application will be able to sign in as the administrator. ' + + 'This is intended for self-hosted providers (Authentik, Keycloak, Authelia) where ' + + 'you control every account. It is UNSAFE with public providers such as Google, ' + + 'Microsoft personal accounts, or Auth0 tenants with open registration. ' + + 'Click "Link Account" after saving to restrict access to a single identity.', + confirmLabel: 'Enable anyway', + destructive: true, + }); + if (!confirmed) { + return; + } + } + this.oidcSaving.set(true); this.api.updateOidcConfig({ enabled: this.oidcEnabled(), diff --git a/docs/docs/configuration/account/index.mdx b/docs/docs/configuration/account/index.mdx index e902ee78..76e4ca08 100644 --- a/docs/docs/configuration/account/index.mdx +++ b/docs/docs/configuration/account/index.mdx @@ -149,6 +149,10 @@ 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. + +The unlinked mode delegates **all** access decisions to your identity provider. This is appropriate for self-hosted providers where you control every account. It is **dangerous** when using public providers with open registration. Anyone who can sign up at that provider would be able to sign in to Cleanuparr as the administrator. If you use a public provider, link a specific account. + + 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:** diff --git a/e2e/tests/15-oidc-save-without-link-warning.spec.ts b/e2e/tests/15-oidc-save-without-link-warning.spec.ts new file mode 100644 index 00000000..4a86b731 --- /dev/null +++ b/e2e/tests/15-oidc-save-without-link-warning.spec.ts @@ -0,0 +1,207 @@ +import { test, expect, Page } from '@playwright/test'; +import { TEST_CONFIG } from './helpers/test-config'; +import { loginAndGetToken } from './helpers/app-api'; + +const API = TEST_CONFIG.appUrl; + +// UX hardening for the OIDC "no linked subject" trust mode +// The unlinked mode is intentional + +test.describe.serial('OIDC Save without Link Warning', () => { + let adminToken: string; + + test.beforeAll(async () => { + adminToken = await loginAndGetToken(); + // Ensure OIDC is enabled (idempotent — no-op if already enabled). + const oidcConfigResponse = await fetch(`${API}/api/account/oidc`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${adminToken}`, + }, + body: JSON.stringify({ + enabled: true, + providerName: TEST_CONFIG.oidcProviderName, + issuerUrl: `${TEST_CONFIG.keycloakUrl}/realms/${TEST_CONFIG.realm}`, + clientId: TEST_CONFIG.clientId, + clientSecret: TEST_CONFIG.clientSecret, + scopes: 'openid profile email', + redirectUrl: '', + exclusiveMode: false, + }), + }); + if (!oidcConfigResponse.ok) { + const body = await oidcConfigResponse.text().catch(() => ''); + throw new Error( + `Failed to configure OIDC in beforeAll (PUT /api/account/oidc): status=${oidcConfigResponse.status} ${oidcConfigResponse.statusText}, body=${body}`, + ); + } + + // Clear any linked subject so the dangerous-state save warning is reachable. + const clearLinkResponse = await fetch(`${API}/api/account/oidc/link`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${adminToken}` }, + }); + if (!clearLinkResponse.ok) { + const body = await clearLinkResponse.text().catch(() => ''); + throw new Error( + `Failed to clear linked OIDC subject in beforeAll (DELETE /api/account/oidc/link): status=${clearLinkResponse.status} ${clearLinkResponse.statusText}, body=${body}`, + ); + } + }); + + async function loginUI(page: Page) { + await page.goto(`${API}/auth/login`); + await page + .getByRole('textbox', { name: 'Username' }) + .fill(TEST_CONFIG.adminUsername); + await page + .getByRole('textbox', { name: 'Password' }) + .fill(TEST_CONFIG.adminPassword); + await page + .getByRole('button', { name: 'Sign In', exact: true }) + .click(); + await expect(page).toHaveURL(/\/dashboard/, { timeout: 10_000 }); + } + + async function openOidcSettings(page: Page) { + await page.goto(`${API}/settings/account`); + await page.getByText('OIDC / SSO').click(); + // Expansion happens client-side; wait for an interior element. + await expect(page.getByRole('button', { name: 'Save OIDC Settings' })).toBeVisible({ + timeout: 5_000, + }); + } + + test('Saving with Enabled=true and no linked subject shows the warning dialog', async ({ + page, + }) => { + await loginUI(page); + await openOidcSettings(page); + + // Sanity: subject is empty + await expect(page.locator('.oidc-link-section__subject')).not.toBeVisible(); + + await page.getByRole('button', { name: 'Save OIDC Settings' }).click(); + + const dialog = page.getByRole('alertdialog', { name: 'Enable OIDC without a linked account' }); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + await expect(dialog).toContainText('Enable OIDC without a linked account'); + await expect(dialog).toContainText('UNSAFE'); + await expect( + dialog.getByRole('button', { name: 'Enable anyway' }), + ).toBeVisible(); + + // Cancel — leave the next test in a clean state. + await dialog.getByRole('button', { name: 'Cancel' }).click(); + await expect(dialog).not.toBeVisible({ timeout: 5_000 }); + }); + + test('Cancelling the warning does not call the save API', async ({ page }) => { + let putRequested = false; + page.on('request', (req) => { + if (req.method() === 'PUT' && req.url().endsWith('/api/account/oidc')) { + putRequested = true; + } + }); + + await loginUI(page); + await openOidcSettings(page); + + await page.getByRole('button', { name: 'Save OIDC Settings' }).click(); + const dialog = page.getByRole('alertdialog', { name: 'Enable OIDC without a linked account' }); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + await dialog.getByRole('button', { name: 'Cancel' }).click(); + await expect(dialog).not.toBeVisible({ timeout: 5_000 }); + + expect(putRequested).toBe(false); + + // No success toast either. + await expect(page.getByText('OIDC settings saved')).not.toBeVisible(); + }); + + test('Confirming the warning saves successfully', async ({ page }) => { + await loginUI(page); + await openOidcSettings(page); + + await page.getByRole('button', { name: 'Save OIDC Settings' }).click(); + const dialog = page.getByRole('alertdialog', { name: 'Enable OIDC without a linked account' }); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + await dialog.getByRole('button', { name: 'Enable anyway' }).click(); + await expect(dialog).not.toBeVisible({ timeout: 5_000 }); + + await expect(page.getByText('OIDC settings saved')).toBeVisible({ + timeout: 5_000, + }); + }); + + test('Saving with Enabled=false does not show the warning', async ({ page }) => { + await loginUI(page); + await openOidcSettings(page); + + // Toggle Enable OIDC off + await page.getByRole('switch', { name: 'Enable OIDC' }).click(); + + await page.getByRole('button', { name: 'Save OIDC Settings' }).click(); + + // No dialog should appear. + await expect(page.getByRole('alertdialog', { name: 'Enable OIDC without a linked account' })).not.toBeVisible({ timeout: 1_000 }); + + // Save toast should appear (no confirmation needed). + await expect(page.getByText('OIDC settings saved')).toBeVisible({ + timeout: 5_000, + }); + + // Restore enabled=true via API for any subsequent tests. + await fetch(`${API}/api/account/oidc`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${adminToken}`, + }, + body: JSON.stringify({ + enabled: true, + providerName: TEST_CONFIG.oidcProviderName, + issuerUrl: `${TEST_CONFIG.keycloakUrl}/realms/${TEST_CONFIG.realm}`, + clientId: TEST_CONFIG.clientId, + clientSecret: TEST_CONFIG.clientSecret, + scopes: 'openid profile email', + redirectUrl: '', + exclusiveMode: false, + }), + }); + }); + + test('Saving with a linked subject does not show the warning', async ({ + page, + }) => { + // Run the OIDC link flow once so the next save is in the linked state. + await loginUI(page); + await page.goto(`${API}/settings/account`); + await page.getByText('OIDC / SSO').click(); + + const linkButton = page.getByRole('button', { name: 'Link Account' }); + await expect(linkButton).toBeVisible({ timeout: 5_000 }); + await linkButton.click(); + + // Authenticate at Keycloak. + await expect(page).toHaveURL(/localhost:8080/, { timeout: 10_000 }); + await page.locator('#username').waitFor({ state: 'visible', timeout: 5_000 }); + await page.locator('#username').fill(TEST_CONFIG.oidcUsername); + await page.locator('#password').fill(TEST_CONFIG.oidcPassword); + await page.locator('#kc-login').click(); + + // Land back on settings with the linked subject visible. + await expect(page).toHaveURL(/\/settings\/account/, { timeout: 15_000 }); + await expect(page.locator('.oidc-link-section__subject')).toBeVisible({ + timeout: 5_000, + }); + + // Now save — no dialog should appear. + await page.getByRole('button', { name: 'Save OIDC Settings' }).click(); + await expect(page.getByRole('alertdialog', { name: 'Enable OIDC without a linked account' })).not.toBeVisible({ timeout: 1_000 }); + await expect(page.getByText('OIDC settings saved')).toBeVisible({ + timeout: 5_000, + }); + }); +});