Add excluded paths support for forward auth (fixes #108)
Allow users to exclude specific paths from Authentik/CPM forward auth protection. When excluded_paths is set, all paths require authentication EXCEPT the excluded ones — useful for apps like Navidrome that need /share/* and /rest/* to bypass auth. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
139
tests/e2e/functional/forward-auth-excluded-paths.spec.ts
Normal file
139
tests/e2e/functional/forward-auth-excluded-paths.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Functional tests: CPM Forward Auth with excluded paths.
|
||||
*
|
||||
* Creates a proxy host with CPM forward auth enabled and excluded_paths set,
|
||||
* then verifies:
|
||||
* - Excluded paths bypass auth and reach the upstream directly
|
||||
* - Non-excluded paths still require authentication (redirect to portal)
|
||||
* - The callback route still works for completing auth on non-excluded paths
|
||||
*
|
||||
* This validates the fix for GitHub issue #108: the ability to exclude
|
||||
* specific paths from forward auth (e.g., /share/*, /rest/* for Navidrome).
|
||||
*
|
||||
* Domain: func-fwd-auth-excl.test
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { httpGet, waitForStatus } from '../../helpers/http';
|
||||
|
||||
const DOMAIN = 'func-fwd-auth-excl.test';
|
||||
const ECHO_BODY = 'echo-ok';
|
||||
const BASE_URL = 'http://localhost:3000';
|
||||
const API = `${BASE_URL}/api/v1`;
|
||||
|
||||
let proxyHostId: number;
|
||||
|
||||
test.describe.serial('Forward Auth Excluded Paths', () => {
|
||||
test('setup: create proxy host with forward auth and excluded paths via API', async ({ page }) => {
|
||||
const res = await page.request.post(`${API}/proxy-hosts`, {
|
||||
data: {
|
||||
name: 'Excluded Paths Test',
|
||||
domains: [DOMAIN],
|
||||
upstreams: ['echo-server:8080'],
|
||||
sslForced: false,
|
||||
cpmForwardAuth: {
|
||||
enabled: true,
|
||||
excluded_paths: ['/share/*', '/rest/*'],
|
||||
},
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json', 'Origin': BASE_URL },
|
||||
});
|
||||
expect(res.status()).toBe(201);
|
||||
const host = await res.json();
|
||||
proxyHostId = host.id;
|
||||
|
||||
// Grant testadmin (user ID 1) forward auth access
|
||||
const accessRes = await page.request.put(`${API}/proxy-hosts/${proxyHostId}/forward-auth-access`, {
|
||||
data: { userIds: [1], groupIds: [] },
|
||||
headers: { 'Content-Type': 'application/json', 'Origin': BASE_URL },
|
||||
});
|
||||
expect(accessRes.status()).toBe(200);
|
||||
|
||||
// Wait for Caddy to pick up the config — non-excluded paths should redirect (302)
|
||||
await waitForStatus(DOMAIN, 302, 20_000);
|
||||
});
|
||||
|
||||
test('excluded path /share/* bypasses auth and reaches upstream', async () => {
|
||||
const res = await httpGet(DOMAIN, '/share/some-track');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toContain(ECHO_BODY);
|
||||
});
|
||||
|
||||
test('excluded path /rest/* bypasses auth and reaches upstream', async () => {
|
||||
const res = await httpGet(DOMAIN, '/rest/ping');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toContain(ECHO_BODY);
|
||||
});
|
||||
|
||||
test('non-excluded root path requires auth (redirects to portal)', async () => {
|
||||
const res = await httpGet(DOMAIN, '/');
|
||||
expect(res.status).toBe(302);
|
||||
const location = String(res.headers['location']);
|
||||
expect(location).toContain('/portal?rd=');
|
||||
expect(location).toContain(DOMAIN);
|
||||
});
|
||||
|
||||
test('non-excluded arbitrary path requires auth', async () => {
|
||||
const res = await httpGet(DOMAIN, '/admin/dashboard');
|
||||
expect(res.status).toBe(302);
|
||||
expect(String(res.headers['location'])).toContain('/portal');
|
||||
});
|
||||
|
||||
test('credential login works for non-excluded paths', async ({ page }) => {
|
||||
const context = await page.context().browser()!.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const freshPage = await context.newPage();
|
||||
|
||||
try {
|
||||
await freshPage.goto(`${BASE_URL}/portal?rd=http://${DOMAIN}/protected-page`);
|
||||
await expect(freshPage.getByLabel('Username')).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Intercept the login API response
|
||||
let capturedRedirect: string | null = null;
|
||||
await freshPage.route('**/api/forward-auth/login', async (route) => {
|
||||
const response = await route.fetch();
|
||||
const json = await response.json();
|
||||
capturedRedirect = json.redirectTo ?? null;
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
|
||||
await freshPage.getByLabel('Username').fill('testadmin');
|
||||
await freshPage.getByLabel('Password').fill('TestPassword2026!');
|
||||
await freshPage.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||
|
||||
const deadline = Date.now() + 15_000;
|
||||
while (!capturedRedirect && Date.now() < deadline) {
|
||||
await freshPage.waitForTimeout(200);
|
||||
}
|
||||
|
||||
expect(capturedRedirect).toBeTruthy();
|
||||
expect(capturedRedirect).toContain('/.cpm-auth/callback');
|
||||
|
||||
// Complete the callback
|
||||
const callbackUrl = new URL(capturedRedirect!);
|
||||
const callbackRes = await httpGet(DOMAIN, callbackUrl.pathname + callbackUrl.search);
|
||||
expect(callbackRes.status).toBe(302);
|
||||
const setCookie = String(callbackRes.headers['set-cookie'] ?? '');
|
||||
expect(setCookie).toContain('_cpm_fa=');
|
||||
|
||||
// Verify authenticated access to non-excluded path
|
||||
const match = setCookie.match(/_cpm_fa=([^;]+)/);
|
||||
expect(match).toBeTruthy();
|
||||
const sessionCookie = match![1];
|
||||
const upstreamRes = await httpGet(DOMAIN, '/protected-page', {
|
||||
Cookie: `_cpm_fa=${sessionCookie}`,
|
||||
});
|
||||
expect(upstreamRes.status).toBe(200);
|
||||
expect(upstreamRes.body).toContain(ECHO_BODY);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('cleanup: delete proxy host', async ({ page }) => {
|
||||
if (proxyHostId) {
|
||||
const res = await page.request.delete(`${API}/proxy-hosts/${proxyHostId}`, {
|
||||
headers: { 'Origin': BASE_URL },
|
||||
});
|
||||
expect(res.status()).toBe(200);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user