Harden forward auth: store redirect URIs server-side, eliminate client control

Replace client-controlled redirectUri with server-side redirect intents.
The portal server component validates the ?rd= hostname against
isForwardAuthDomain, stores the URI in a new forward_auth_redirect_intents
table, and passes only an opaque rid (128-bit random, SHA-256 hashed) to
the client. Login endpoints consume the intent atomically (one-time use,
10-minute TTL) and retrieve the stored URI — the client never sends the
redirect URL to any API endpoint.

Security properties:
- Redirect URI is never client-controlled in API requests
- rid is 128-bit random, stored as SHA-256 hash (not reversible from DB)
- Atomic one-time consumption prevents replay
- 10-minute TTL limits attack window for OAuth round-trip
- Immediate deletion after consumption
- Expired intents cleaned up opportunistically
- Hostname validated against registered forward-auth domains before storage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
fuomag9
2026-04-06 18:12:01 +02:00
parent 38d29cb7e0
commit fbf8ca38b0
8 changed files with 157 additions and 53 deletions

View File

@@ -11,14 +11,16 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
import { Separator } from "@/components/ui/separator";
interface PortalLoginFormProps {
redirectUri: string;
rid: string;
hasRedirect: boolean;
targetDomain: string;
enabledProviders?: Array<{ id: string; name: string }>;
existingSession?: { userId: string; name: string | null; email: string | null } | null;
}
export default function PortalLoginForm({
redirectUri,
rid,
hasRedirect,
targetDomain,
enabledProviders = [],
existingSession,
@@ -29,12 +31,12 @@ export default function PortalLoginForm({
// If user already has a NextAuth session (e.g. from OAuth), auto-create forward auth session
useEffect(() => {
if (existingSession && redirectUri) {
if (existingSession && rid) {
setPending(true);
fetch("/api/forward-auth/session-login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ redirectUri }),
body: JSON.stringify({ rid }),
})
.then((res) => res.json())
.then((data) => {
@@ -50,7 +52,7 @@ export default function PortalLoginForm({
setPending(false);
});
}
}, [existingSession, redirectUri]);
}, [existingSession, rid]);
const handleCredentialSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -71,7 +73,7 @@ export default function PortalLoginForm({
const response = await fetch("/api/forward-auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password, redirectUri }),
body: JSON.stringify({ username, password, rid }),
});
const data = await response.json();
@@ -92,14 +94,15 @@ export default function PortalLoginForm({
const handleOAuthSignIn = (providerId: string) => {
setError(null);
setOauthPending(providerId);
// Redirect back to this portal page after OAuth, with the rd param preserved
const callbackUrl = `/portal?rd=${encodeURIComponent(redirectUri)}`;
// Redirect back to this portal page after OAuth, with the rid param preserved.
// The rid is an opaque server-side ID — the actual redirect URI is never in the URL.
const callbackUrl = `/portal?rid=${encodeURIComponent(rid)}`;
signIn(providerId, { callbackUrl });
};
const disabled = pending || !!oauthPending;
if (!redirectUri) {
if (!hasRedirect) {
return (
<div className="min-h-screen flex items-center justify-center bg-background px-4">
<Card className="w-full max-w-sm">

View File

@@ -1,29 +1,38 @@
import { auth } from "@/src/lib/auth";
import { getEnabledOAuthProviders } from "@/src/lib/config";
import { isForwardAuthDomain } from "@/src/lib/models/forward-auth";
import { isForwardAuthDomain, createRedirectIntent } from "@/src/lib/models/forward-auth";
import PortalLoginForm from "./PortalLoginForm";
interface PortalPageProps {
searchParams: Promise<{ rd?: string }>;
searchParams: Promise<{ rd?: string; rid?: string }>;
}
export default async function PortalPage({ searchParams }: PortalPageProps) {
const params = await searchParams;
const redirectUri = params.rd ?? "";
// After OAuth callback, the portal is loaded with ?rid= (the opaque ID we created earlier)
const existingRid = params.rid ?? "";
// Only display the target domain if it's a genuine forward-auth-protected host.
// This prevents attackers from using the portal to phish with arbitrary domain names
// and avoids leaking the list of configured proxies (we only confirm/deny a specific domain).
// Two entry modes:
// 1. Fresh from Caddy redirect: ?rd=<full-url> → validate, store server-side, create rid
// 2. Returning from OAuth: ?rid=<opaque-id> → reuse the existing rid (redirect already stored)
let targetDomain = "";
try {
if (redirectUri) {
const hostname = new URL(redirectUri).hostname;
if (await isForwardAuthDomain(hostname)) {
targetDomain = hostname;
let rid = existingRid;
if (!rid && redirectUri) {
try {
const parsed = new URL(redirectUri);
if (
(parsed.protocol === "https:" || parsed.protocol === "http:") &&
await isForwardAuthDomain(parsed.hostname)
) {
targetDomain = parsed.hostname;
// Store the redirect URI server-side. The client only gets an opaque ID,
// so a tampered ?rd= parameter cannot influence the final redirect target.
rid = await createRedirectIntent(redirectUri);
}
} catch {
// invalid URL — portal will show a generic message
}
} catch {
// invalid URL — will be caught by the login endpoint
}
const session = await auth();
@@ -31,7 +40,8 @@ export default async function PortalPage({ searchParams }: PortalPageProps) {
return (
<PortalLoginForm
redirectUri={redirectUri}
rid={rid}
hasRedirect={!!redirectUri || !!existingRid}
targetDomain={targetDomain}
enabledProviders={enabledProviders}
existingSession={session ? { userId: session.user.id, name: session.user.name ?? null, email: session.user.email ?? null } : null}

View File

@@ -5,14 +5,15 @@ import { config } from "@/src/lib/config";
import {
createForwardAuthSession,
createExchangeCode,
checkHostAccessByDomain
checkHostAccessByDomain,
consumeRedirectIntent
} from "@/src/lib/models/forward-auth";
import { logAuditEvent } from "@/src/lib/audit";
import { isRateLimited, registerFailedAttempt, resetAttempts } from "@/src/lib/rate-limit";
/**
* Forward auth login endpoint — validates credentials and starts the exchange flow.
* Called by the portal login form.
* Called by the portal login form with an opaque redirect intent ID (rid).
*/
export async function POST(request: NextRequest) {
try {
@@ -26,24 +27,13 @@ export async function POST(request: NextRequest) {
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 : "";
const rid = typeof body.rid === "string" ? body.rid : "";
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 — only allow http/https schemes
let targetUrl: URL;
try {
targetUrl = new URL(redirectUri);
} catch {
return NextResponse.json({ error: "Invalid redirect URI" }, { status: 400 });
}
if (targetUrl.protocol !== "https:" && targetUrl.protocol !== "http:") {
return NextResponse.json({ error: "Invalid redirect URI scheme" }, { status: 400 });
if (!rid) {
return NextResponse.json({ error: "Missing redirect intent" }, { status: 400 });
}
// Rate limiting — prefer x-real-ip (set by reverse proxy) over x-forwarded-for
@@ -92,6 +82,15 @@ export async function POST(request: NextRequest) {
// Successful credential check — reset rate limiter for this IP
resetAttempts(ip);
// Consume the redirect intent — returns the server-stored redirect URI.
// This is a one-time operation: the intent is deleted after consumption.
const redirectUri = await consumeRedirectIntent(rid);
if (!redirectUri) {
return NextResponse.json({ error: "Invalid or expired redirect intent. Please try again." }, { status: 400 });
}
const targetUrl = new URL(redirectUri);
// Check if user has access to the target host
const { hasAccess } = await checkHostAccessByDomain(user.id, targetUrl.hostname);
if (!hasAccess) {

View File

@@ -4,13 +4,15 @@ import { config } from "@/src/lib/config";
import {
createForwardAuthSession,
createExchangeCode,
checkHostAccessByDomain
checkHostAccessByDomain,
consumeRedirectIntent
} 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).
* Forward auth session login — uses an existing NextAuth session to create
* a forward auth session. Called automatically when the portal detects the
* user is already logged in (e.g. after OAuth).
*/
export async function POST(request: NextRequest) {
try {
@@ -27,22 +29,19 @@ export async function POST(request: NextRequest) {
}
const body = await request.json();
const redirectUri = typeof body.redirectUri === "string" ? body.redirectUri : "";
const rid = typeof body.rid === "string" ? body.rid : "";
if (!rid) {
return NextResponse.json({ error: "Missing redirect intent" }, { status: 400 });
}
// Consume the redirect intent — returns the server-stored redirect URI
const redirectUri = await consumeRedirectIntent(rid);
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 });
}
if (targetUrl.protocol !== "https:" && targetUrl.protocol !== "http:") {
return NextResponse.json({ error: "Invalid redirect URI scheme" }, { status: 400 });
return NextResponse.json({ error: "Invalid or expired redirect intent. Please try again." }, { status: 400 });
}
const targetUrl = new URL(redirectUri);
const userId = Number(session.user.id);
// Check if user has access to the target host

View File

@@ -0,0 +1,12 @@
CREATE TABLE `forward_auth_redirect_intents` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`rid_hash` text NOT NULL,
`redirect_uri` text NOT NULL,
`expires_at` text NOT NULL,
`consumed` integer DEFAULT false NOT NULL,
`created_at` text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `fari_rid_hash_unique` ON `forward_auth_redirect_intents` (`rid_hash`);
--> statement-breakpoint
CREATE INDEX `fari_expires_idx` ON `forward_auth_redirect_intents` (`expires_at`);

View File

@@ -127,6 +127,13 @@
"when": 1775500000000,
"tag": "0017_forward_auth",
"breakpoints": true
},
{
"idx": 18,
"version": "6",
"when": 1775600000000,
"tag": "0018_forward_auth_redirect_intents",
"breakpoints": true
}
]
}

View File

@@ -428,6 +428,22 @@ export const forwardAuthExchanges = sqliteTable(
})
);
export const forwardAuthRedirectIntents = sqliteTable(
"forward_auth_redirect_intents",
{
id: integer("id").primaryKey({ autoIncrement: true }),
ridHash: text("rid_hash").notNull(),
redirectUri: text("redirect_uri").notNull(),
expiresAt: text("expires_at").notNull(),
consumed: integer("consumed", { mode: "boolean" }).notNull().default(false),
createdAt: text("created_at").notNull()
},
(table) => ({
ridHashUnique: uniqueIndex("fari_rid_hash_unique").on(table.ridHash),
expiresIdx: index("fari_expires_idx").on(table.expiresAt)
})
);
// ── L4 Proxy Hosts ───────────────────────────────────────────────────
export const l4ProxyHosts = sqliteTable("l4_proxy_hosts", {

View File

@@ -5,17 +5,75 @@ import {
forwardAuthSessions,
forwardAuthExchanges,
forwardAuthAccess,
forwardAuthRedirectIntents,
groupMembers,
} from "../db/schema";
import { and, eq, gt, inArray, lt } from "drizzle-orm";
const DEFAULT_SESSION_TTL = 7 * 24 * 60 * 60; // 7 days in seconds
const EXCHANGE_CODE_TTL = 60; // 60 seconds
const REDIRECT_INTENT_TTL = 10 * 60; // 10 minutes — covers login + OAuth flow time
function hashToken(raw: string): string {
return createHash("sha256").update(raw).digest("hex");
}
// ── Redirect Intents ────────────────────────────────────────────────
// Store redirect URIs server-side so the client only holds an opaque ID.
export async function createRedirectIntent(redirectUri: string): Promise<string> {
const rid = randomBytes(16).toString("hex");
const ridHash = hashToken(rid);
const now = nowIso();
const expiresAt = new Date(Date.now() + REDIRECT_INTENT_TTL * 1000).toISOString();
await db.insert(forwardAuthRedirectIntents).values({
ridHash,
redirectUri,
expiresAt,
consumed: false,
createdAt: now
});
// Opportunistic cleanup of expired intents
await db
.delete(forwardAuthRedirectIntents)
.where(lt(forwardAuthRedirectIntents.expiresAt, now));
return rid;
}
export async function consumeRedirectIntent(
rid: string
): Promise<string | null> {
const ridHash = hashToken(rid);
const now = nowIso();
// Atomic claim: only succeeds if the intent exists, is unconsumed, and not expired
const claimed = await db
.update(forwardAuthRedirectIntents)
.set({ consumed: true })
.where(
and(
eq(forwardAuthRedirectIntents.ridHash, ridHash),
eq(forwardAuthRedirectIntents.consumed, false),
gt(forwardAuthRedirectIntents.expiresAt, now)
)
)
.returning();
if (claimed.length === 0) return null;
const redirectUri = claimed[0].redirectUri;
// Delete immediately after consumption
await db
.delete(forwardAuthRedirectIntents)
.where(eq(forwardAuthRedirectIntents.id, claimed[0].id));
return redirectUri;
}
// ── Sessions ─────────────────────────────────────────────────────────
export type ForwardAuthSession = {