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:
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user