mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-05-16 10:43:55 -04:00
Add UI warning when not linking an OIDC account (#587)
This commit is contained in:
@@ -413,7 +413,25 @@ export class AccountSettingsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
// OIDC
|
||||
saveOidcConfig(): void {
|
||||
async saveOidcConfig(): Promise<void> {
|
||||
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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Warning>
|
||||
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.
|
||||
</Warning>
|
||||
|
||||
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:**
|
||||
|
||||
207
e2e/tests/15-oidc-save-without-link-warning.spec.ts
Normal file
207
e2e/tests/15-oidc-save-without-link-warning.spec.ts
Normal file
@@ -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(() => '<failed to read response body>');
|
||||
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(() => '<failed to read response body>');
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user