Add forward auth portal — CPM as built-in IdP replacing Authentik

CPM can now act as its own forward auth provider for proxied sites.
Users authenticate at a login portal (credentials or OAuth) and Caddy
gates access via a verify subrequest, eliminating the need for external
IdPs like Authentik.

Key components:
- Forward auth flow: verify endpoint, exchange code callback, login portal
- User groups with membership management
- Per-proxy-host access control (users and/or groups)
- Caddy config generation for forward_auth handler + callback route
- OAuth and credential login on the portal page
- Admin UI: groups page, inline user/group assignment in proxy host form
- REST API: /api/v1/groups, /api/v1/forward-auth-sessions, per-host access
- Integration tests for groups and forward auth schema

Also fixes mTLS E2E test selectors broken by the RBAC refactor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
fuomag9
2026-04-05 22:32:17 +02:00
parent 277ae6e79c
commit 03c8f40417
34 changed files with 2788 additions and 11 deletions
+37
View File
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import { redeemExchangeCode } from "@/src/lib/models/forward-auth";
const COOKIE_NAME = "_cpm_fa";
const COOKIE_MAX_AGE = 7 * 24 * 60 * 60; // 7 days
/**
* Forward auth callback — redeems an exchange code and sets the session cookie.
* Caddy routes /.cpm-auth/callback on proxied domains to this endpoint.
*/
export async function GET(request: NextRequest) {
const code = request.nextUrl.searchParams.get("code");
if (!code) {
return new NextResponse("Missing code parameter", { status: 400 });
}
const result = await redeemExchangeCode(code);
if (!result) {
return new NextResponse(
"Invalid or expired authorization code. Please try logging in again.",
{ status: 401 }
);
}
// Redirect back to original URL with the session cookie set
const response = NextResponse.redirect(result.redirectUri, 302);
response.cookies.set(COOKIE_NAME, result.rawSessionToken, {
path: "/",
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: COOKIE_MAX_AGE
});
return response;
}
+113
View File
@@ -0,0 +1,113 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import db from "@/src/lib/db";
import { config } from "@/src/lib/config";
import {
createForwardAuthSession,
createExchangeCode,
checkHostAccessByDomain
} from "@/src/lib/models/forward-auth";
import { logAuditEvent } from "@/src/lib/audit";
import { isRateLimited } from "@/src/lib/rate-limit";
/**
* Forward auth login endpoint — validates credentials and starts the exchange flow.
* Called by the portal login form.
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const username = typeof body.username === "string" ? body.username.trim() : "";
const password = typeof body.password === "string" ? body.password : "";
const redirectUri = typeof body.redirectUri === "string" ? body.redirectUri : "";
if (!username || !password) {
return NextResponse.json({ error: "Username and password are required" }, { status: 400 });
}
if (!redirectUri) {
return NextResponse.json({ error: "Redirect URI is required" }, { status: 400 });
}
// Validate redirect URI
let targetUrl: URL;
try {
targetUrl = new URL(redirectUri);
} catch {
return NextResponse.json({ error: "Invalid redirect URI" }, { status: 400 });
}
// Rate limiting
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
const rateLimitResult = isRateLimited(ip);
if (rateLimitResult.blocked) {
return NextResponse.json(
{ error: "Too many login attempts. Please try again later." },
{ status: 429 }
);
}
// Authenticate using the same logic as the credentials provider
const email = `${username}@localhost`;
const user = await db.query.users.findFirst({
where: (table, operators) => operators.eq(table.email, email)
});
if (!user || user.status !== "active" || !user.passwordHash) {
logAuditEvent({
userId: null,
action: "forward_auth_login_failed",
entityType: "user",
summary: `Forward auth login failed for username: ${username}`
});
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
const isValid = bcrypt.compareSync(password, user.passwordHash);
if (!isValid) {
logAuditEvent({
userId: user.id,
action: "forward_auth_login_failed",
entityType: "user",
entityId: user.id,
summary: `Forward auth login failed for user ${user.email}`
});
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
// Check if user has access to the target host
const { hasAccess } = await checkHostAccessByDomain(user.id, targetUrl.hostname);
if (!hasAccess) {
logAuditEvent({
userId: user.id,
action: "forward_auth_access_denied",
entityType: "proxy_host",
summary: `Forward auth access denied for user ${user.email} to host ${targetUrl.hostname}`
});
return NextResponse.json(
{ error: "You do not have access to this application." },
{ status: 403 }
);
}
// Create session and exchange code
const { rawToken, session } = await createForwardAuthSession(user.id);
const { rawCode } = await createExchangeCode(session.id, rawToken, redirectUri);
logAuditEvent({
userId: user.id,
action: "forward_auth_login",
entityType: "user",
entityId: user.id,
summary: `Forward auth login for user ${user.email} to ${targetUrl.hostname}`
});
// Build callback URL on the target domain
const callbackUrl = new URL("/.cpm-auth/callback", targetUrl.origin);
callbackUrl.searchParams.set("code", rawCode);
return NextResponse.json({ redirectTo: callbackUrl.toString() });
} catch (error) {
console.error("Forward auth login error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/src/lib/auth";
import {
createForwardAuthSession,
createExchangeCode,
checkHostAccessByDomain
} from "@/src/lib/models/forward-auth";
import { logAuditEvent } from "@/src/lib/audit";
/**
* Forward auth session login — creates a forward auth session from an existing
* NextAuth session (used after OAuth login redirects back to the portal).
*/
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const body = await request.json();
const redirectUri = typeof body.redirectUri === "string" ? body.redirectUri : "";
if (!redirectUri) {
return NextResponse.json({ error: "Redirect URI is required" }, { status: 400 });
}
let targetUrl: URL;
try {
targetUrl = new URL(redirectUri);
} catch {
return NextResponse.json({ error: "Invalid redirect URI" }, { status: 400 });
}
const userId = Number(session.user.id);
// Check if user has access to the target host
const { hasAccess } = await checkHostAccessByDomain(userId, targetUrl.hostname);
if (!hasAccess) {
logAuditEvent({
userId,
action: "forward_auth_access_denied",
entityType: "proxy_host",
summary: `Forward auth access denied for user ${session.user.email} to host ${targetUrl.hostname}`
});
return NextResponse.json(
{ error: "You do not have access to this application." },
{ status: 403 }
);
}
// Create forward auth session and exchange code
const { rawToken, session: faSession } = await createForwardAuthSession(userId);
const { rawCode } = await createExchangeCode(faSession.id, rawToken, redirectUri);
logAuditEvent({
userId,
action: "forward_auth_login",
entityType: "user",
entityId: userId,
summary: `Forward auth login (session) for user ${session.user.email} to ${targetUrl.hostname}`
});
const callbackUrl = new URL("/.cpm-auth/callback", targetUrl.origin);
callbackUrl.searchParams.set("code", rawCode);
return NextResponse.json({ redirectTo: callbackUrl.toString() });
} catch (error) {
console.error("Forward auth session login error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
+53
View File
@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from "next/server";
import { validateForwardAuthSession, checkHostAccessByDomain } from "@/src/lib/models/forward-auth";
import { getUserById } from "@/src/lib/models/user";
import { getGroupsForUser } from "@/src/lib/models/groups";
const COOKIE_NAME = "_cpm_fa";
/**
* Forward auth verify endpoint — called by Caddy as a subrequest.
* Returns 200 + user headers on success, 401 on failure.
*/
export async function GET(request: NextRequest) {
const token = request.cookies.get(COOKIE_NAME)?.value;
if (!token) {
return new NextResponse(null, { status: 401 });
}
const session = await validateForwardAuthSession(token);
if (!session) {
return new NextResponse(null, { status: 401 });
}
const user = await getUserById(session.userId);
if (!user || user.status !== "active") {
return new NextResponse(null, { status: 401 });
}
// Check host access using X-Forwarded-Host header set by Caddy
const forwardedHost = request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? "";
if (!forwardedHost) {
return new NextResponse(null, { status: 401 });
}
const { hasAccess } = await checkHostAccessByDomain(session.userId, forwardedHost);
if (!hasAccess) {
return new NextResponse("Forbidden", { status: 403 });
}
// Get user's groups for the header
const userGroups = await getGroupsForUser(session.userId);
const groupNames = userGroups.map((g) => g.name).join(",");
// Return 200 with user info headers that Caddy will copy to upstream
return new NextResponse(null, {
status: 200,
headers: {
"X-CPM-User": user.name ?? user.email.split("@")[0],
"X-CPM-Email": user.email,
"X-CPM-Groups": groupNames,
"X-CPM-User-Id": String(user.id)
}
});
}