diff --git a/tests/dex/config.yml b/tests/dex/config.yml new file mode 100644 index 00000000..5bee4a88 --- /dev/null +++ b/tests/dex/config.yml @@ -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" diff --git a/tests/docker-compose.test.yml b/tests/docker-compose.test.yml index ae5a58a6..8e5b7145 100644 --- a/tests/docker-compose.test.yml +++ b/tests/docker-compose.test.yml @@ -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: diff --git a/tests/e2e/functional/forward-auth-oauth.spec.ts b/tests/e2e/functional/forward-auth-oauth.spec.ts new file mode 100644 index 00000000..b48f434a --- /dev/null +++ b/tests/e2e/functional/forward-auth-oauth.spec.ts @@ -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 { + 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 { + 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(); + } + }); +}); diff --git a/tests/e2e/functional/forward-auth.spec.ts b/tests/e2e/functional/forward-auth.spec.ts new file mode 100644 index 00000000..e97764ad --- /dev/null +++ b/tests/e2e/functional/forward-auth.spec.ts @@ -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'); + }); +}); diff --git a/tests/e2e/portal.spec.ts b/tests/e2e/portal.spec.ts index cc81183f..c865c902 100644 --- a/tests/e2e/portal.spec.ts +++ b/tests/e2e/portal.spec.ts @@ -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,

evil

'); + 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= + // 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(); + }); }); diff --git a/tests/helpers/http.ts b/tests/helpers/http.ts index 3db54214..1699c1ac 100644 --- a/tests/helpers/http.ts +++ b/tests/helpers/http.ts @@ -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 { + 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): Promise { await page.evaluate((f) => {