Add forward auth E2E tests with Dex OIDC provider
- Add Dex OIDC provider to test Docker Compose stack with static test users (alice, bob) and pre-configured OAuth client - Add forward-auth.spec.ts: credential-based forward auth flow tests (redirect, portal form, login, session cookie, forged cookie rejection) - Add forward-auth-oauth.spec.ts: full OAuth forward auth flow tests including user-based access (allowed/denied), group-based access, access revocation, and credential login coexisting with OAuth - Add waitForStatus helper for polling specific HTTP status codes - Expand portal.spec.ts with OAuth button visibility, URI scheme rejection, and strict alert selector tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
37
tests/dex/config.yml
Normal file
37
tests/dex/config.yml
Normal file
@@ -0,0 +1,37 @@
|
||||
# Dex OIDC provider configuration for E2E tests.
|
||||
# Provides static test users and a pre-configured OAuth client.
|
||||
issuer: http://localhost:5556/dex
|
||||
|
||||
storage:
|
||||
type: memory
|
||||
|
||||
web:
|
||||
http: 0.0.0.0:5556
|
||||
|
||||
oauth2:
|
||||
# Speed up tests — short-lived tokens
|
||||
responseTypes: ["code"]
|
||||
skipApprovalScreen: true
|
||||
|
||||
staticClients:
|
||||
- id: cpm-test-client
|
||||
secret: cpm-test-secret
|
||||
name: "CPM E2E Test"
|
||||
redirectURIs:
|
||||
- "http://localhost:3000/api/auth/callback/oauth2"
|
||||
|
||||
enablePasswordDB: true
|
||||
|
||||
staticPasswords:
|
||||
# Primary test user — will be granted forward auth access
|
||||
- email: "alice@test.local"
|
||||
# password: "password"
|
||||
hash: "$2a$10$95mdmT5F.icxrUmXEC9Jf.pX2RWgMO0FD6.yqrrVnRwTzA/UrT7g2"
|
||||
username: "alice"
|
||||
userID: "alice-001"
|
||||
# Secondary test user — will NOT be granted forward auth access (for denial tests)
|
||||
- email: "bob@test.local"
|
||||
# password: "password"
|
||||
hash: "$2a$10$95mdmT5F.icxrUmXEC9Jf.pX2RWgMO0FD6.yqrrVnRwTzA/UrT7g2"
|
||||
username: "bob"
|
||||
userID: "bob-002"
|
||||
@@ -6,6 +6,16 @@ services:
|
||||
ADMIN_PASSWORD: "TestPassword2026!"
|
||||
BASE_URL: http://localhost:3000
|
||||
NEXTAUTH_URL: http://localhost:3000
|
||||
# OAuth via Dex OIDC provider
|
||||
OAUTH_ENABLED: "true"
|
||||
OAUTH_PROVIDER_NAME: "Dex"
|
||||
OAUTH_CLIENT_ID: cpm-test-client
|
||||
OAUTH_CLIENT_SECRET: cpm-test-secret
|
||||
OAUTH_ISSUER: http://localhost:5556/dex
|
||||
OAUTH_AUTHORIZATION_URL: http://localhost:5556/dex/auth
|
||||
OAUTH_TOKEN_URL: http://dex:5556/dex/token
|
||||
OAUTH_USERINFO_URL: http://dex:5556/dex/userinfo
|
||||
OAUTH_ALLOW_AUTO_LINKING: "true"
|
||||
caddy:
|
||||
ports:
|
||||
- "80:80"
|
||||
@@ -15,6 +25,16 @@ services:
|
||||
- "15433:15433"
|
||||
# L4 test ports (UDP)
|
||||
- "15353:15353/udp"
|
||||
# Dex OIDC provider for OAuth E2E tests
|
||||
dex:
|
||||
image: dexidp/dex:v2.41.1
|
||||
command: ["dex", "serve", "/etc/dex/config.yml"]
|
||||
volumes:
|
||||
- ./tests/dex/config.yml:/etc/dex/config.yml:ro
|
||||
ports:
|
||||
- "5556:5556"
|
||||
networks:
|
||||
- caddy-network
|
||||
# Lightweight echo server reachable by Caddy as "echo-server:8080".
|
||||
# Returns a fixed body so tests can assert the proxy routed the request.
|
||||
echo-server:
|
||||
|
||||
392
tests/e2e/functional/forward-auth-oauth.spec.ts
Normal file
392
tests/e2e/functional/forward-auth-oauth.spec.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* Functional tests: Forward Auth with OAuth (Dex OIDC).
|
||||
*
|
||||
* Tests the full forward auth flow including:
|
||||
* - Proxy host creation with forward auth via REST API
|
||||
* - OAuth login through Dex OIDC provider
|
||||
* - Allowed vs disallowed user access enforcement
|
||||
* - Group-based access control
|
||||
* - Session cookie lifecycle
|
||||
*
|
||||
* Note: Test domains (e.g. func-fwd-oauth.test) are not DNS-resolvable.
|
||||
* Browser-based navigation uses localhost:3000 (the portal). The callback
|
||||
* step and upstream access are verified via httpGet (which sends to
|
||||
* 127.0.0.1:80 with a custom Host header, bypassing DNS).
|
||||
*
|
||||
* Requires Dex to be running in the test stack (port 5556).
|
||||
*
|
||||
* Domain: func-fwd-oauth.test
|
||||
*/
|
||||
import { test, expect, type Page, type BrowserContext } from '@playwright/test';
|
||||
import { httpGet, waitForStatus } from '../../helpers/http';
|
||||
|
||||
const DOMAIN = 'func-fwd-oauth.test';
|
||||
const ECHO_BODY = 'echo-ok';
|
||||
const BASE_URL = 'http://localhost:3000';
|
||||
const API = `${BASE_URL}/api/v1`;
|
||||
|
||||
// Dex test users (must match tests/dex/config.yml)
|
||||
const ALICE = { email: 'alice@test.local', username: 'alice', password: 'password' };
|
||||
const BOB = { email: 'bob@test.local', username: 'bob', password: 'password' };
|
||||
|
||||
// State shared across serial tests
|
||||
let proxyHostId: number;
|
||||
let aliceUserId: number;
|
||||
let bobUserId: number;
|
||||
let testGroupId: number;
|
||||
|
||||
/** Make an authenticated API request using the admin session cookies from page context. */
|
||||
async function apiPost(page: Page, path: string, body: unknown) {
|
||||
return page.request.post(`${API}${path}`, {
|
||||
data: body,
|
||||
headers: { 'Content-Type': 'application/json', 'Origin': BASE_URL },
|
||||
});
|
||||
}
|
||||
|
||||
async function apiPut(page: Page, path: string, body: unknown) {
|
||||
return page.request.put(`${API}${path}`, {
|
||||
data: body,
|
||||
headers: { 'Content-Type': 'application/json', 'Origin': BASE_URL },
|
||||
});
|
||||
}
|
||||
|
||||
async function apiGet(page: Page, path: string) {
|
||||
return page.request.get(`${API}${path}`);
|
||||
}
|
||||
|
||||
/** Log into Dex with email/password. Handles the Dex login form. */
|
||||
async function dexLogin(page: Page, email: string, password: string) {
|
||||
// Dex shows a "Log in to dex" page with a link to the local (password) connector
|
||||
// or goes straight to the login form
|
||||
const loginLink = page.getByRole('link', { name: /log in with email/i });
|
||||
if (await loginLink.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
||||
await loginLink.click();
|
||||
}
|
||||
// Wait for the Dex login form to appear
|
||||
await expect(page.getByRole('button', { name: /login/i })).toBeVisible({ timeout: 10_000 });
|
||||
// Dex uses "email address" and "Password" as accessible names
|
||||
await page.getByRole('textbox', { name: /email/i }).fill(email);
|
||||
await page.getByRole('textbox', { name: /password/i }).fill(password);
|
||||
await page.getByRole('button', { name: /login/i }).click();
|
||||
}
|
||||
|
||||
/** Create a fresh browser context with no auth state for OAuth flows. */
|
||||
async function freshContext(page: Page): Promise<BrowserContext> {
|
||||
return page.context().browser()!.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform OAuth login on the portal and return the callback URL.
|
||||
* Does NOT navigate to the callback (test domains aren't DNS-resolvable).
|
||||
* Instead, intercepts the session-login API response to extract the redirect URL.
|
||||
*/
|
||||
async function oauthPortalLogin(
|
||||
page: Page,
|
||||
domain: string,
|
||||
user: { email: string; password: string },
|
||||
): Promise<{ redirectTo: string | null; error: string | null }> {
|
||||
// Intercept the session-login API to capture the response before the page navigates away
|
||||
let capturedResponse: { redirectTo: string | null; error: string | null } | null = null;
|
||||
await page.route('**/api/forward-auth/session-login', async (route) => {
|
||||
const response = await route.fetch();
|
||||
const json = await response.json();
|
||||
capturedResponse = {
|
||||
redirectTo: json.redirectTo ?? null,
|
||||
error: json.error ?? null,
|
||||
};
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
|
||||
await page.goto(`${BASE_URL}/portal?rd=http://${domain}/`);
|
||||
const oauthButton = page.getByRole('button', { name: /sign in with dex/i });
|
||||
await expect(oauthButton).toBeVisible({ timeout: 10_000 });
|
||||
await oauthButton.click();
|
||||
await dexLogin(page, user.email, user.password);
|
||||
|
||||
// After Dex login, the browser returns to the portal with ?rid=...
|
||||
// The portal auto-submits to session-login. Wait for the intercepted response.
|
||||
const deadline = Date.now() + 25_000;
|
||||
while (!capturedResponse && Date.now() < deadline) {
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
return capturedResponse ?? { redirectTo: null, error: 'timeout' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the forward auth callback via httpGet and return the session cookie.
|
||||
* Used when browser can't resolve the test domain.
|
||||
*/
|
||||
async function completeCallback(domain: string, callbackUrl: string): Promise<string> {
|
||||
const url = new URL(callbackUrl);
|
||||
const res = await httpGet(domain, url.pathname + url.search);
|
||||
expect(res.status).toBe(302);
|
||||
const setCookie = String(res.headers['set-cookie'] ?? '');
|
||||
expect(setCookie).toContain('_cpm_fa=');
|
||||
const match = setCookie.match(/_cpm_fa=([^;]+)/);
|
||||
expect(match).toBeTruthy();
|
||||
return match![1];
|
||||
}
|
||||
|
||||
test.describe.serial('Forward Auth with OAuth (Dex)', () => {
|
||||
// ── Setup ──────────────────────────────────────────────────────────
|
||||
|
||||
test('setup: wait for Dex to be ready', async () => {
|
||||
const deadline = Date.now() + 30_000;
|
||||
let ready = false;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch('http://localhost:5556/dex/.well-known/openid-configuration');
|
||||
if (res.ok) { ready = true; break; }
|
||||
} catch { /* not ready yet */ }
|
||||
await new Promise(r => setTimeout(r, 1_000));
|
||||
}
|
||||
expect(ready).toBe(true);
|
||||
});
|
||||
|
||||
test('setup: create proxy host with forward auth via API', async ({ page }) => {
|
||||
const res = await apiPost(page, '/proxy-hosts', {
|
||||
name: 'OAuth Forward Auth Test',
|
||||
domains: [DOMAIN],
|
||||
upstreams: ['echo-server:8080'],
|
||||
ssl_forced: false,
|
||||
cpm_forward_auth: { enabled: true },
|
||||
});
|
||||
expect(res.status()).toBe(201);
|
||||
const host = await res.json();
|
||||
proxyHostId = host.id;
|
||||
expect(proxyHostId).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('setup: trigger OAuth login for alice to create her user account', async ({ page }) => {
|
||||
const ctx = await freshContext(page);
|
||||
const p = await ctx.newPage();
|
||||
try {
|
||||
await p.goto(`${BASE_URL}/login`);
|
||||
const oauthButton = p.getByRole('button', { name: /continue with|sign in with/i });
|
||||
await expect(oauthButton).toBeVisible({ timeout: 10_000 });
|
||||
await oauthButton.click();
|
||||
await dexLogin(p, ALICE.email, ALICE.password);
|
||||
await p.waitForURL((url) => {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.origin === BASE_URL && !u.pathname.startsWith('/api/auth');
|
||||
} catch { return false; }
|
||||
}, { timeout: 30_000 });
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('setup: trigger OAuth login for bob to create his user account', async ({ page }) => {
|
||||
const ctx = await freshContext(page);
|
||||
const p = await ctx.newPage();
|
||||
try {
|
||||
await p.goto(`${BASE_URL}/login`);
|
||||
const oauthButton = p.getByRole('button', { name: /continue with|sign in with/i });
|
||||
await expect(oauthButton).toBeVisible({ timeout: 10_000 });
|
||||
await oauthButton.click();
|
||||
await dexLogin(p, BOB.email, BOB.password);
|
||||
await p.waitForURL((url) => {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.origin === BASE_URL && !u.pathname.startsWith('/api/auth');
|
||||
} catch { return false; }
|
||||
}, { timeout: 30_000 });
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('setup: find alice and bob user IDs', async ({ page }) => {
|
||||
const res = await apiGet(page, '/users');
|
||||
expect(res.status()).toBe(200);
|
||||
const users: Array<{ id: number; email: string }> = await res.json();
|
||||
|
||||
const alice = users.find(u => u.email === ALICE.email);
|
||||
const bob = users.find(u => u.email === BOB.email);
|
||||
expect(alice).toBeTruthy();
|
||||
expect(bob).toBeTruthy();
|
||||
aliceUserId = alice!.id;
|
||||
bobUserId = bob!.id;
|
||||
});
|
||||
|
||||
test('setup: grant alice forward auth access (not bob)', async ({ page }) => {
|
||||
const res = await apiPut(page, `/proxy-hosts/${proxyHostId}/forward-auth-access`, {
|
||||
userIds: [aliceUserId],
|
||||
groupIds: [],
|
||||
});
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
|
||||
test('setup: wait for Caddy to apply forward auth config', async () => {
|
||||
await waitForStatus(DOMAIN, 302, 20_000);
|
||||
});
|
||||
|
||||
// ── Unauthenticated tests ─────────────────────────────────────────
|
||||
|
||||
test('unauthenticated request redirects to portal with ?rd=', async () => {
|
||||
const res = await httpGet(DOMAIN, '/protected/page');
|
||||
expect(res.status).toBe(302);
|
||||
const location = String(res.headers['location']);
|
||||
expect(location).toContain('/portal?rd=');
|
||||
expect(location).toContain(DOMAIN);
|
||||
expect(location).toContain('/protected/page');
|
||||
});
|
||||
|
||||
test('forged session cookie gets redirected', async () => {
|
||||
const res = await httpGet(DOMAIN, '/', { Cookie: '_cpm_fa=forged-token' });
|
||||
expect(res.status).toBe(302);
|
||||
expect(String(res.headers['location'])).toContain('/portal');
|
||||
});
|
||||
|
||||
// ── User-based access control ─────────────────────────────────────
|
||||
|
||||
test('alice (allowed user) can complete OAuth forward auth login', async ({ page }) => {
|
||||
const ctx = await freshContext(page);
|
||||
const p = await ctx.newPage();
|
||||
try {
|
||||
const result = await oauthPortalLogin(p, DOMAIN, ALICE);
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.redirectTo).toBeTruthy();
|
||||
expect(result.redirectTo).toContain('/.cpm-auth/callback');
|
||||
|
||||
// Complete callback and verify upstream access
|
||||
const sessionCookie = await completeCallback(DOMAIN, result.redirectTo!);
|
||||
const upstreamRes = await httpGet(DOMAIN, '/', { Cookie: `_cpm_fa=${sessionCookie}` });
|
||||
expect(upstreamRes.status).toBe(200);
|
||||
expect(upstreamRes.body).toContain(ECHO_BODY);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('bob (disallowed user) is denied access via OAuth forward auth', async ({ page }) => {
|
||||
const ctx = await freshContext(page);
|
||||
const p = await ctx.newPage();
|
||||
try {
|
||||
const result = await oauthPortalLogin(p, DOMAIN, BOB);
|
||||
expect(result.error).toBeTruthy();
|
||||
expect(result.redirectTo).toBeNull();
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Group-based access control ────────────────────────────────────
|
||||
|
||||
test('setup: create a group and add bob to it', async ({ page }) => {
|
||||
const groupRes = await apiPost(page, '/groups', { name: 'OAuth Testers' });
|
||||
expect(groupRes.status()).toBe(201);
|
||||
const group = await groupRes.json();
|
||||
testGroupId = group.id;
|
||||
|
||||
const memberRes = await apiPost(page, `/groups/${testGroupId}/members`, { userId: bobUserId });
|
||||
expect(memberRes.status()).toBe(201);
|
||||
});
|
||||
|
||||
test('setup: grant group-based forward auth access', async ({ page }) => {
|
||||
const res = await apiPut(page, `/proxy-hosts/${proxyHostId}/forward-auth-access`, {
|
||||
userIds: [aliceUserId],
|
||||
groupIds: [testGroupId],
|
||||
});
|
||||
expect(res.status()).toBe(200);
|
||||
const access = await res.json();
|
||||
expect(access.length).toBe(2);
|
||||
});
|
||||
|
||||
test('bob (now in allowed group) can access via OAuth forward auth', async ({ page }) => {
|
||||
const ctx = await freshContext(page);
|
||||
const p = await ctx.newPage();
|
||||
try {
|
||||
const result = await oauthPortalLogin(p, DOMAIN, BOB);
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.redirectTo).toBeTruthy();
|
||||
|
||||
const sessionCookie = await completeCallback(DOMAIN, result.redirectTo!);
|
||||
const upstreamRes = await httpGet(DOMAIN, '/', { Cookie: `_cpm_fa=${sessionCookie}` });
|
||||
expect(upstreamRes.status).toBe(200);
|
||||
expect(upstreamRes.body).toContain(ECHO_BODY);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Revoke access ─────────────────────────────────────────────────
|
||||
|
||||
test('setup: revoke all access (both user and group)', async ({ page }) => {
|
||||
const res = await apiPut(page, `/proxy-hosts/${proxyHostId}/forward-auth-access`, {
|
||||
userIds: [],
|
||||
groupIds: [],
|
||||
});
|
||||
expect(res.status()).toBe(200);
|
||||
const access = await res.json();
|
||||
expect(access.length).toBe(0);
|
||||
});
|
||||
|
||||
test('alice is denied after access revocation', async ({ page }) => {
|
||||
const ctx = await freshContext(page);
|
||||
const p = await ctx.newPage();
|
||||
try {
|
||||
const result = await oauthPortalLogin(p, DOMAIN, ALICE);
|
||||
expect(result.error).toBeTruthy();
|
||||
expect(result.redirectTo).toBeNull();
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Credential-based forward auth (coexisting with OAuth) ─────────
|
||||
|
||||
test('setup: grant admin user direct access for credential login test', async ({ page }) => {
|
||||
const usersRes = await apiGet(page, '/users');
|
||||
const users: Array<{ id: number; email: string }> = await usersRes.json();
|
||||
const admin = users.find(u => u.email === 'testadmin@localhost');
|
||||
expect(admin).toBeTruthy();
|
||||
|
||||
const res = await apiPut(page, `/proxy-hosts/${proxyHostId}/forward-auth-access`, {
|
||||
userIds: [admin!.id],
|
||||
groupIds: [],
|
||||
});
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
|
||||
test('admin can log in via credential form on portal', async ({ page }) => {
|
||||
const ctx = await freshContext(page);
|
||||
const p = await ctx.newPage();
|
||||
try {
|
||||
await p.goto(`${BASE_URL}/portal?rd=http://${DOMAIN}/`);
|
||||
await expect(p.getByLabel('Username')).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Intercept the login API response before the page navigates away
|
||||
let capturedRedirect: string | null = null;
|
||||
await p.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 p.getByLabel('Username').fill('testadmin');
|
||||
await p.getByLabel('Password').fill('TestPassword2026!');
|
||||
await p.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||
|
||||
// Wait for the intercepted response
|
||||
const deadline = Date.now() + 15_000;
|
||||
while (!capturedRedirect && Date.now() < deadline) {
|
||||
await p.waitForTimeout(200);
|
||||
}
|
||||
|
||||
expect(capturedRedirect).toBeTruthy();
|
||||
expect(capturedRedirect).toContain('/.cpm-auth/callback');
|
||||
|
||||
// Complete via httpGet
|
||||
const sessionCookie = await completeCallback(DOMAIN, capturedRedirect!);
|
||||
const upstreamRes = await httpGet(DOMAIN, '/', { Cookie: `_cpm_fa=${sessionCookie}` });
|
||||
expect(upstreamRes.status).toBe(200);
|
||||
expect(upstreamRes.body).toContain(ECHO_BODY);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
187
tests/e2e/functional/forward-auth.spec.ts
Normal file
187
tests/e2e/functional/forward-auth.spec.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Functional tests: CPM Forward Auth (credential-based login).
|
||||
*
|
||||
* Creates a proxy host with CPM forward auth enabled via the REST API, then verifies:
|
||||
* - Unauthenticated requests get redirected to the portal with ?rd= param
|
||||
* - The portal page shows a login form when ?rd= is present
|
||||
* - The portal rejects invalid ?rd= values (non-forward-auth domains)
|
||||
* - Successful credential login completes the redirect flow
|
||||
* - Authenticated requests (with _cpm_fa cookie) reach the upstream
|
||||
* - Requests with an invalid session cookie get redirected again
|
||||
*
|
||||
* Domain: func-fwd-auth.test
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { httpGet, waitForStatus } from '../../helpers/http';
|
||||
|
||||
const DOMAIN = 'func-fwd-auth.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', () => {
|
||||
test('setup: create proxy host with forward auth via API', async ({ page }) => {
|
||||
const res = await page.request.post(`${API}/proxy-hosts`, {
|
||||
data: {
|
||||
name: 'Functional Forward Auth Test',
|
||||
domains: [DOMAIN],
|
||||
upstreams: ['echo-server:8080'],
|
||||
ssl_forced: false,
|
||||
cpm_forward_auth: { enabled: true },
|
||||
},
|
||||
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 forward auth config (expect 302 redirect to portal)
|
||||
await waitForStatus(DOMAIN, 302, 20_000);
|
||||
});
|
||||
|
||||
test('unauthenticated request redirects to portal with ?rd= param', async () => {
|
||||
const res = await httpGet(DOMAIN, '/some/page');
|
||||
expect(res.status).toBe(302);
|
||||
const location = res.headers['location'];
|
||||
expect(String(location)).toContain('/portal?rd=');
|
||||
expect(String(location)).toContain(DOMAIN);
|
||||
});
|
||||
|
||||
test('redirect preserves the original request path in ?rd=', async () => {
|
||||
const res = await httpGet(DOMAIN, '/deep/path?q=hello');
|
||||
expect(res.status).toBe(302);
|
||||
const location = String(res.headers['location']);
|
||||
expect(location).toContain('/deep/path');
|
||||
expect(location).toContain('q=hello');
|
||||
});
|
||||
|
||||
test('portal shows login form when ?rd= points to forward auth domain', async ({ page }) => {
|
||||
// Use fresh context — admin session triggers auto-redirect on the portal
|
||||
const ctx = await page.context().browser()!.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const p = await ctx.newPage();
|
||||
try {
|
||||
const response = await p.goto(`${BASE_URL}/portal?rd=http://${DOMAIN}/`);
|
||||
expect(response?.status()).toBeLessThan(500);
|
||||
// Wait for the page to fully render
|
||||
await p.waitForLoadState('networkidle');
|
||||
await expect(p.getByLabel('Username')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(p.getByLabel('Password')).toBeVisible();
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('portal shows target domain when ?rd= is valid', async ({ page }) => {
|
||||
const ctx = await page.context().browser()!.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const p = await ctx.newPage();
|
||||
try {
|
||||
await p.goto(`${BASE_URL}/portal?rd=http://${DOMAIN}/`);
|
||||
await expect(p.getByText(DOMAIN)).toBeVisible();
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('portal rejects ?rd= for non-forward-auth domains', async ({ page }) => {
|
||||
const ctx = await page.context().browser()!.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const p = await ctx.newPage();
|
||||
try {
|
||||
await p.goto(`${BASE_URL}/portal?rd=http://not-a-real-domain.test/`);
|
||||
// Non-forward-auth domain → form shows but no rid is created (generic "Sign in to continue")
|
||||
await expect(p.getByText('Sign in to continue')).toBeVisible();
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('portal rejects empty ?rd= parameter', async ({ page }) => {
|
||||
const ctx = await page.context().browser()!.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const p = await ctx.newPage();
|
||||
try {
|
||||
await p.goto(`${BASE_URL}/portal`);
|
||||
await expect(p.getByText('No redirect destination specified.')).toBeVisible();
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('credential login completes the redirect flow', 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}/test-path`);
|
||||
await expect(freshPage.getByLabel('Username')).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Intercept the login API response before the page navigates away
|
||||
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();
|
||||
|
||||
// Wait for the intercepted response
|
||||
const deadline = Date.now() + 15_000;
|
||||
while (!capturedRedirect && Date.now() < deadline) {
|
||||
await freshPage.waitForTimeout(200);
|
||||
}
|
||||
|
||||
expect(capturedRedirect).toBeTruthy();
|
||||
expect(capturedRedirect).toContain('/.cpm-auth/callback');
|
||||
expect(capturedRedirect).toContain('code=');
|
||||
const data = { redirectTo: capturedRedirect! };
|
||||
|
||||
// Complete the callback via httpGet (sends to 127.0.0.1:80 with Host header)
|
||||
const callbackUrl = new URL(data.redirectTo);
|
||||
const callbackRes = await httpGet(DOMAIN, callbackUrl.pathname + callbackUrl.search);
|
||||
// Callback sets _cpm_fa cookie and redirects to the original URL
|
||||
expect(callbackRes.status).toBe(302);
|
||||
const setCookie = String(callbackRes.headers['set-cookie'] ?? '');
|
||||
expect(setCookie).toContain('_cpm_fa=');
|
||||
|
||||
// Extract the session cookie and verify it grants access to the upstream
|
||||
const match = setCookie.match(/_cpm_fa=([^;]+)/);
|
||||
expect(match).toBeTruthy();
|
||||
const sessionCookie = match![1];
|
||||
const upstreamRes = await httpGet(DOMAIN, '/test-path', {
|
||||
Cookie: `_cpm_fa=${sessionCookie}`,
|
||||
});
|
||||
expect(upstreamRes.status).toBe(200);
|
||||
expect(upstreamRes.body).toContain(ECHO_BODY);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('request with invalid _cpm_fa cookie gets redirected', async () => {
|
||||
const res = await httpGet(DOMAIN, '/', {
|
||||
Cookie: '_cpm_fa=invalid-token-value',
|
||||
});
|
||||
expect(res.status).toBe(302);
|
||||
expect(String(res.headers['location'])).toContain('/portal');
|
||||
});
|
||||
|
||||
test('request with forged _cpm_fa cookie gets redirected', async () => {
|
||||
const forgedToken = 'a'.repeat(64);
|
||||
const res = await httpGet(DOMAIN, '/', {
|
||||
Cookie: `_cpm_fa=${forgedToken}`,
|
||||
});
|
||||
expect(res.status).toBe(302);
|
||||
expect(String(res.headers['location'])).toContain('/portal');
|
||||
});
|
||||
});
|
||||
@@ -33,8 +33,8 @@ test.describe('Portal login page', () => {
|
||||
await page.getByLabel('Password').fill('wrongpass');
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||
|
||||
// Should show an error message
|
||||
await expect(page.locator('[role="alert"]')).toBeVisible({ timeout: 10_000 });
|
||||
// Should show an error message (use .first() to avoid matching Next.js route announcer)
|
||||
await expect(page.getByRole('alert').first()).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('username and password fields are required', async ({ page }) => {
|
||||
@@ -47,4 +47,48 @@ test.describe('Portal login page', () => {
|
||||
await expect(username).toHaveAttribute('required', '');
|
||||
await expect(password).toHaveAttribute('required', '');
|
||||
});
|
||||
|
||||
test('rejects javascript: URI — no rid is created', async ({ page }) => {
|
||||
await page.goto('/portal?rd=javascript:alert(1)');
|
||||
// Form shows (hasRedirect is true) but no rid is created — login will fail
|
||||
await expect(page.getByText('Authentication Required')).toBeVisible();
|
||||
await expect(page.getByText('Sign in to continue')).toBeVisible();
|
||||
});
|
||||
|
||||
test('rejects data: URI — no rid is created', async ({ page }) => {
|
||||
await page.goto('/portal?rd=data:text/html,<h1>evil</h1>');
|
||||
await expect(page.getByText('Authentication Required')).toBeVisible();
|
||||
await expect(page.getByText('Sign in to continue')).toBeVisible();
|
||||
});
|
||||
|
||||
test('rejects file: URI — no rid is created', async ({ page }) => {
|
||||
await page.goto('/portal?rd=file:///etc/passwd');
|
||||
await expect(page.getByText('Authentication Required')).toBeVisible();
|
||||
await expect(page.getByText('Sign in to continue')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows OAuth sign-in button when OIDC is enabled', async ({ page }) => {
|
||||
await page.goto('/portal?rd=http://example.com');
|
||||
// Dex is configured in the test stack — the OAuth button should appear
|
||||
await expect(page.getByRole('button', { name: /Sign in with Dex/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows both OAuth button and credential form', async ({ page }) => {
|
||||
await page.goto('/portal?rd=http://example.com');
|
||||
// Both auth methods should be available
|
||||
await expect(page.getByRole('button', { name: /Sign in with Dex/i })).toBeVisible();
|
||||
await expect(page.getByLabel('Username')).toBeVisible();
|
||||
await expect(page.getByLabel('Password')).toBeVisible();
|
||||
// "or" separator between OAuth and credentials
|
||||
await expect(page.getByText('or', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('preserves ?rid= parameter for OAuth return flow', async ({ page }) => {
|
||||
// When returning from OAuth, the portal gets ?rid=<opaque>
|
||||
// With a fake rid it should still show the login form (not "No redirect destination")
|
||||
await page.goto('/portal?rid=abc123fakeopaqueid');
|
||||
await expect(page.getByText('Authentication Required')).toBeVisible();
|
||||
// It has a redirect (the rid), so it should show the form, not the "no destination" message
|
||||
await expect(page.getByText('No redirect destination specified.')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,6 +58,26 @@ export async function waitForRoute(domain: string, timeoutMs = 15_000): Promise<
|
||||
throw new Error(`Route for "${domain}" not ready after ${timeoutMs}ms (last status: ${lastStatus})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll until the route returns a specific expected status code.
|
||||
* Useful for forward auth routes where you expect 302 (redirect to portal).
|
||||
*/
|
||||
export async function waitForStatus(domain: string, expectedStatus: number, timeoutMs = 20_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastStatus = 0;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await httpGet(domain);
|
||||
lastStatus = res.status;
|
||||
if (res.status === expectedStatus) return;
|
||||
} catch {
|
||||
// Connection refused — not ready yet
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`Route for "${domain}" did not return ${expectedStatus} after ${timeoutMs}ms (last status: ${lastStatus})`);
|
||||
}
|
||||
|
||||
/** Inject hidden form fields into #create-host-form before submitting. */
|
||||
export async function injectFormFields(page: Page, fields: Record<string, string>): Promise<void> {
|
||||
await page.evaluate((f) => {
|
||||
|
||||
Reference in New Issue
Block a user