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:
fuomag9
2026-04-17 10:11:24 +02:00
parent 390840dbd9
commit 8f4c24119e
8 changed files with 376 additions and 6 deletions

View File

@@ -77,6 +77,7 @@ function parseAuthentikConfig(formData: FormData): ProxyHostAuthentikInput | und
const copyHeaders = parseCsv(formData.get("authentik_copy_headers"));
const trustedProxies = parseCsv(formData.get("authentik_trusted_proxies"));
const protectedPaths = parseCsv(formData.get("authentik_protected_paths"));
const excludedPaths = parseCsv(formData.get("authentik_excluded_paths"));
const setHostHeader = formData.has("authentik_set_host_header_present")
? parseCheckbox(formData.get("authentik_set_host_header"))
: undefined;
@@ -103,6 +104,9 @@ function parseAuthentikConfig(formData: FormData): ProxyHostAuthentikInput | und
if (protectedPaths.length > 0 || formData.has("authentik_protected_paths")) {
result.protectedPaths = protectedPaths;
}
if (excludedPaths.length > 0 || formData.has("authentik_excluded_paths")) {
result.excludedPaths = excludedPaths;
}
if (setHostHeader !== undefined) {
result.setOutpostHostHeader = setHostHeader;
}
@@ -122,6 +126,7 @@ function parseCpmForwardAuthConfig(formData: FormData): CpmForwardAuthInput | un
: false
: undefined;
const protectedPaths = parseCsv(formData.get("cpm_forward_auth_protected_paths"));
const excludedPaths = parseCsv(formData.get("cpm_forward_auth_excluded_paths"));
const result: CpmForwardAuthInput = {};
if (enabledValue !== undefined) {
@@ -130,6 +135,9 @@ function parseCpmForwardAuthConfig(formData: FormData): CpmForwardAuthInput | un
if (protectedPaths.length > 0 || formData.has("cpm_forward_auth_protected_paths")) {
result.protected_paths = protectedPaths.length > 0 ? protectedPaths : null;
}
if (excludedPaths.length > 0 || formData.has("cpm_forward_auth_excluded_paths")) {
result.excluded_paths = excludedPaths.length > 0 ? excludedPaths : null;
}
return Object.keys(result).length > 0 ? result : undefined;
}

View File

@@ -1448,6 +1448,7 @@ const spec = {
trustedProxies: { type: "array", items: { type: "string" }, example: ["private_ranges"] },
setOutpostHostHeader: { type: "boolean" },
protectedPaths: { type: ["array", "null"], items: { type: "string" }, description: "Paths to protect (null = all)" },
excludedPaths: { type: ["array", "null"], items: { type: "string" }, description: "Paths to exclude from auth (bypassed while rest is protected)" },
},
},
LoadBalancerConfig: {

View File

@@ -162,6 +162,19 @@ export function AuthentikFields({
Leave empty to protect entire domain. Specify paths to protect specific routes only.
</p>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Excluded Paths (Optional)</label>
<Textarea
name="authentik_excluded_paths"
placeholder="/share/*, /rest/*"
defaultValue={initial?.excludedPaths?.join(", ") ?? ""}
disabled={!enabled}
rows={2}
/>
<p className="text-xs text-muted-foreground mt-1">
Paths to exclude from authentication. These paths will bypass forward auth while all other paths remain protected. Ignored if Protected Paths is set.
</p>
</div>
<HiddenCheckboxField
name="authentik_set_host_header"
defaultChecked={setHostHeaderDefault}

View File

@@ -99,6 +99,19 @@ export function CpmForwardAuthFields({
Leave empty to protect entire domain. Comma-separated paths to protect specific routes only.
</p>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Excluded Paths (Optional)</label>
<Textarea
name="cpm_forward_auth_excluded_paths"
placeholder="/share/*, /rest/*"
defaultValue={initial?.excluded_paths?.join(", ") ?? ""}
disabled={!enabled}
rows={2}
/>
<p className="text-xs text-muted-foreground mt-1">
Paths to exclude from authentication. These paths will bypass forward auth while all other paths remain protected. Ignored if Protected Paths is set.
</p>
</div>
{/* Allowed Groups */}
{groups.length > 0 && (

View File

@@ -109,6 +109,7 @@ type UpstreamDnsResolutionMeta = {
type CpmForwardAuthMeta = {
enabled?: boolean;
protected_paths?: string[];
excluded_paths?: string[];
};
type ProxyHostMeta = {
@@ -144,6 +145,7 @@ type ProxyHostAuthentikMeta = {
trusted_proxies?: string[];
set_outpost_host_header?: boolean;
protected_paths?: string[];
excluded_paths?: string[];
};
type AuthentikRouteConfig = {
@@ -155,6 +157,7 @@ type AuthentikRouteConfig = {
trustedProxies: string[];
setOutpostHostHeader: boolean;
protectedPaths: string[] | null;
excludedPaths: string[] | null;
};
type LoadBalancerActiveHealthCheckMeta = {
@@ -1030,6 +1033,7 @@ async function buildProxyRoutes(
// Path-based authentication support
if (authentik.protectedPaths && authentik.protectedPaths.length > 0) {
// Whitelist mode: only specified paths get auth
for (const domainGroup of domainGroups) {
// Create separate routes for each protected path
for (const protectedPath of authentik.protectedPaths) {
@@ -1087,7 +1091,54 @@ async function buildProxyRoutes(
terminal: true
});
}
} else if (authentik.excludedPaths && authentik.excludedPaths.length > 0) {
// Exclusion mode: protect everything EXCEPT specified paths
const locationRules = meta.location_rules ?? [];
for (const domainGroup of domainGroups) {
if (outpostRoute) {
const outpostMatches = (outpostRoute.match as Array<Record<string, unknown>> | undefined) ?? [];
hostRoutes.push({
...outpostRoute,
match: outpostMatches.map((match) => ({
...match,
host: domainGroup
}))
});
}
// Create unprotected routes for each excluded path (before the catch-all)
for (const excludedPath of authentik.excludedPaths) {
hostRoutes.push({
match: [{ host: domainGroup, path: [excludedPath] }],
handle: [...handlers, JSON.parse(JSON.stringify(reverseProxyHandler))],
terminal: true
});
}
// Location rules get auth (same as full-site mode)
for (const rule of locationRules) {
const { safePath, reverseProxyHandler: locationProxy } = buildLocationReverseProxy(
rule,
Boolean(row.skipHttpsHostnameValidation),
Boolean(row.preserveHostHeader)
);
if (!safePath) continue;
hostRoutes.push({
match: [{ host: domainGroup, path: [safePath] }],
handle: [...handlers, forwardAuthHandler, locationProxy],
terminal: true,
});
}
// Catch-all with auth (everything not excluded)
hostRoutes.push({
match: [{ host: domainGroup }],
handle: [...handlers, forwardAuthHandler, reverseProxyHandler],
terminal: true
});
}
} else {
// Full-site mode: protect everything
const locationRules = meta.location_rules ?? [];
for (const domainGroup of domainGroups) {
if (outpostRoute) {
@@ -1229,7 +1280,7 @@ async function buildProxyRoutes(
const locationRules = meta.location_rules ?? [];
if (cpmForwardAuth.protected_paths && cpmForwardAuth.protected_paths.length > 0) {
// Path-specific authentication
// Whitelist mode: only specified paths get auth
for (const domainGroup of domainGroups) {
// Add callback route (unprotected)
hostRoutes.push({
@@ -1273,8 +1324,48 @@ async function buildProxyRoutes(
terminal: true
});
}
} else if (cpmForwardAuth.excluded_paths && cpmForwardAuth.excluded_paths.length > 0) {
// Exclusion mode: protect everything EXCEPT specified paths
for (const domainGroup of domainGroups) {
// Callback route first (unprotected)
hostRoutes.push({
...cpmCallbackRoute,
match: [{ host: domainGroup, path: ["/.cpm-auth/callback"] }]
});
// Excluded paths — unprotected, before the catch-all
for (const excludedPath of cpmForwardAuth.excluded_paths) {
hostRoutes.push({
match: [{ host: domainGroup, path: [excludedPath] }],
handle: [...handlers, JSON.parse(JSON.stringify(reverseProxyHandler))],
terminal: true
});
}
// Location rules with forward auth
for (const rule of locationRules) {
const { safePath, reverseProxyHandler: locationProxy } = buildLocationReverseProxy(
rule,
Boolean(row.skipHttpsHostnameValidation),
Boolean(row.preserveHostHeader)
);
if (!safePath) continue;
hostRoutes.push({
match: [{ host: domainGroup, path: [safePath] }],
handle: [...handlers, cpmForwardAuthHandler, locationProxy],
terminal: true
});
}
// Catch-all with auth (everything not excluded)
hostRoutes.push({
match: [{ host: domainGroup }],
handle: [...handlers, cpmForwardAuthHandler, reverseProxyHandler],
terminal: true
});
}
} else {
// Protect entire site
// Full-site mode: protect everything
for (const domainGroup of domainGroups) {
// Callback route first (unprotected)
hostRoutes.push({
@@ -2282,6 +2373,11 @@ function parseAuthentikConfig(meta: ProxyHostAuthentikMeta | undefined | null):
? meta.protected_paths.map((path) => path?.trim()).filter((path): path is string => Boolean(path))
: null;
const excludedPaths =
Array.isArray(meta.excluded_paths) && meta.excluded_paths.length > 0
? meta.excluded_paths.map((path) => path?.trim()).filter((path): path is string => Boolean(path))
: null;
return {
enabled: true,
outpostDomain,
@@ -2290,7 +2386,8 @@ function parseAuthentikConfig(meta: ProxyHostAuthentikMeta | undefined | null):
copyHeaders,
trustedProxies,
setOutpostHostHeader,
protectedPaths
protectedPaths,
excludedPaths
};
}

View File

@@ -208,6 +208,7 @@ export type ProxyHostAuthentikConfig = {
trustedProxies: string[];
setOutpostHostHeader: boolean;
protectedPaths: string[] | null;
excludedPaths: string[] | null;
};
export type ProxyHostAuthentikInput = {
@@ -219,6 +220,7 @@ export type ProxyHostAuthentikInput = {
trustedProxies?: string[] | null;
setOutpostHostHeader?: boolean | null;
protectedPaths?: string[] | null;
excludedPaths?: string[] | null;
};
type ProxyHostAuthentikMeta = {
@@ -230,6 +232,7 @@ type ProxyHostAuthentikMeta = {
trusted_proxies?: string[];
set_outpost_host_header?: boolean;
protected_paths?: string[];
excluded_paths?: string[];
};
export type MtlsConfig = {
@@ -245,16 +248,19 @@ export type MtlsConfig = {
export type CpmForwardAuthConfig = {
enabled: boolean;
protected_paths: string[] | null;
excluded_paths: string[] | null;
};
export type CpmForwardAuthInput = {
enabled?: boolean;
protected_paths?: string[] | null;
excluded_paths?: string[] | null;
};
type CpmForwardAuthMeta = {
enabled?: boolean;
protected_paths?: string[];
excluded_paths?: string[];
};
type ProxyHostMeta = {
@@ -806,6 +812,17 @@ function normalizeAuthentikInput(
}
}
if (input.excludedPaths !== undefined) {
const paths = (input.excludedPaths ?? [])
.map((path) => path?.trim())
.filter((path): path is string => Boolean(path));
if (paths.length > 0) {
next.excluded_paths = paths;
} else {
delete next.excluded_paths;
}
}
if ((next.enabled ?? false) && next.outpost_domain && !next.auth_endpoint) {
next.auth_endpoint = `/${next.outpost_domain}/auth/caddy`;
}
@@ -1198,6 +1215,9 @@ function buildMeta(existing: ProxyHostMeta, input: Partial<ProxyHostInput>): str
if (input.cpmForwardAuth.protected_paths && input.cpmForwardAuth.protected_paths.length > 0) {
cfa.protected_paths = input.cpmForwardAuth.protected_paths;
}
if (input.cpmForwardAuth.excluded_paths && input.cpmForwardAuth.excluded_paths.length > 0) {
cfa.excluded_paths = input.cpmForwardAuth.excluded_paths;
}
next.cpm_forward_auth = cfa;
} else {
delete next.cpm_forward_auth;
@@ -1254,6 +1274,8 @@ function hydrateAuthentik(meta: ProxyHostAuthentikMeta | undefined): ProxyHostAu
meta.set_outpost_host_header !== undefined ? Boolean(meta.set_outpost_host_header) : true;
const protectedPaths =
Array.isArray(meta.protected_paths) && meta.protected_paths.length > 0 ? meta.protected_paths : null;
const excludedPaths =
Array.isArray(meta.excluded_paths) && meta.excluded_paths.length > 0 ? meta.excluded_paths : null;
return {
enabled,
@@ -1263,7 +1285,8 @@ function hydrateAuthentik(meta: ProxyHostAuthentikMeta | undefined): ProxyHostAu
copyHeaders,
trustedProxies,
setOutpostHostHeader,
protectedPaths
protectedPaths,
excludedPaths
};
}
@@ -1295,6 +1318,9 @@ function dehydrateAuthentik(config: ProxyHostAuthentikConfig | null): ProxyHostA
if (config.protectedPaths && config.protectedPaths.length > 0) {
meta.protected_paths = [...config.protectedPaths];
}
if (config.excludedPaths && config.excludedPaths.length > 0) {
meta.excluded_paths = [...config.excludedPaths];
}
return meta;
}
@@ -1559,7 +1585,7 @@ function parseProxyHost(row: ProxyHostRow): ProxyHost {
waf: meta.waf ?? null,
mtls: meta.mtls ?? null,
cpmForwardAuth: meta.cpm_forward_auth?.enabled
? { enabled: true, protected_paths: meta.cpm_forward_auth.protected_paths ?? null }
? { enabled: true, protected_paths: meta.cpm_forward_auth.protected_paths ?? null, excluded_paths: meta.cpm_forward_auth.excluded_paths ?? null }
: null,
redirects: meta.redirects ?? [],
rewrite: meta.rewrite ?? null,
@@ -1702,7 +1728,8 @@ export async function updateProxyHost(id: number, input: Partial<ProxyHostInput>
...(existing.cpmForwardAuth?.enabled ? {
cpm_forward_auth: {
enabled: true,
...(existing.cpmForwardAuth.protected_paths ? { protected_paths: existing.cpmForwardAuth.protected_paths } : {})
...(existing.cpmForwardAuth.protected_paths ? { protected_paths: existing.cpmForwardAuth.protected_paths } : {}),
...(existing.cpmForwardAuth.excluded_paths ? { excluded_paths: existing.cpmForwardAuth.excluded_paths } : {})
}
} : {}),
};

View 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);
}
});
});

View File

@@ -217,6 +217,78 @@ describe('proxy-hosts boolean fields', () => {
});
});
// ---------------------------------------------------------------------------
// Authentik forward auth meta round-trip
// ---------------------------------------------------------------------------
describe('proxy-hosts authentik meta', () => {
it('stores and retrieves authentik config with excluded_paths', async () => {
const meta = {
authentik: {
enabled: true,
outpost_domain: 'outpost.goauthentik.io',
outpost_upstream: 'http://authentik:9000',
excluded_paths: ['/share/*', '/rest/*'],
},
};
const host = await insertHost({ meta: JSON.stringify(meta) });
const row = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
const parsed = JSON.parse(row!.meta!);
expect(parsed.authentik.enabled).toBe(true);
expect(parsed.authentik.excluded_paths).toEqual(['/share/*', '/rest/*']);
});
it('stores authentik config with protected_paths (no excluded_paths)', async () => {
const meta = {
authentik: {
enabled: true,
outpost_domain: 'outpost.goauthentik.io',
outpost_upstream: 'http://authentik:9000',
protected_paths: ['/admin/*', '/secret/*'],
},
};
const host = await insertHost({ meta: JSON.stringify(meta) });
const row = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
const parsed = JSON.parse(row!.meta!);
expect(parsed.authentik.protected_paths).toEqual(['/admin/*', '/secret/*']);
expect(parsed.authentik.excluded_paths).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// CPM forward auth meta round-trip
// ---------------------------------------------------------------------------
describe('proxy-hosts CPM forward auth meta', () => {
it('stores and retrieves cpm_forward_auth config with excluded_paths', async () => {
const meta = {
cpm_forward_auth: {
enabled: true,
excluded_paths: ['/api/public/*', '/health'],
},
};
const host = await insertHost({ meta: JSON.stringify(meta) });
const row = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
const parsed = JSON.parse(row!.meta!);
expect(parsed.cpm_forward_auth.enabled).toBe(true);
expect(parsed.cpm_forward_auth.excluded_paths).toEqual(['/api/public/*', '/health']);
});
it('stores cpm_forward_auth config with protected_paths (no excluded_paths)', async () => {
const meta = {
cpm_forward_auth: {
enabled: true,
protected_paths: ['/admin/*'],
},
};
const host = await insertHost({ meta: JSON.stringify(meta) });
const row = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
const parsed = JSON.parse(row!.meta!);
expect(parsed.cpm_forward_auth.protected_paths).toEqual(['/admin/*']);
expect(parsed.cpm_forward_auth.excluded_paths).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Null meta field
// ---------------------------------------------------------------------------