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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
103
src/lib/caddy.ts
103
src/lib/caddy.ts
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
} : {}),
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user