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:
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
12
drizzle/0018_forward_auth_redirect_intents.sql
Normal file
12
drizzle/0018_forward_auth_redirect_intents.sql
Normal 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`);
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user