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

@@ -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", {