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

@@ -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 = {