feat(auth): wire auth end-to-end (middleware + frontend replacement)

Backend:
- Port auth_middleware, csrf_middleware, langgraph_auth, routers/auth
- Port authz decorator (owner_filter_key defaults to 'owner_id')
- Merge app.py: register AuthMiddleware + CSRFMiddleware + CORS, add
  _ensure_admin_user lifespan hook, _migrate_orphaned_threads helper,
  register auth router
- Merge deps.py: add get_local_provider, get_current_user_from_request,
  get_optional_user_from_request; keep get_current_user as thin str|None
  adapter for feedback router
- langgraph.json: add auth path pointing to langgraph_auth.py:auth
- Rename metadata['user_id'] -> metadata['owner_id'] in langgraph_auth
  (both metadata write and LangGraph filter dict) + test fixtures

Frontend:
- Delete better-auth library and api catch-all route
- Remove better-auth npm dependency and env vars (BETTER_AUTH_SECRET,
  BETTER_AUTH_GITHUB_*) from env.js
- Port frontend/src/core/auth/* (AuthProvider, gateway-config,
  proxy-policy, server-side getServerSideUser, types)
- Port frontend/src/core/api/fetcher.ts
- Port (auth)/layout, (auth)/login, (auth)/setup pages
- Rewrite workspace/layout.tsx as server component that calls
  getServerSideUser and wraps in AuthProvider
- Port workspace/workspace-content.tsx for the client-side sidebar logic

Tests:
- Port 5 auth test files (test_auth, test_auth_middleware,
  test_auth_type_system, test_ensure_admin, test_langgraph_auth)
- 176 auth tests PASS

After this commit: login/logout/registration flow works, but persistence
layer does not yet filter by owner_id. Commit 4 closes that gap.
This commit is contained in:
greatmengqi
2026-04-08 09:41:56 +08:00
parent 03c3b18565
commit f942e4e597
32 changed files with 3859 additions and 268 deletions
+39
View File
@@ -0,0 +1,39 @@
import { buildLoginUrl } from "@/core/auth/types";
/**
* Fetch with credentials. Automatically redirects to login on 401.
*/
export async function fetchWithAuth(
input: RequestInfo | string,
init?: RequestInit,
): Promise<Response> {
const url = typeof input === "string" ? input : input.url;
const res = await fetch(url, {
...init,
credentials: "include",
});
if (res.status === 401) {
window.location.href = buildLoginUrl(window.location.pathname);
throw new Error("Unauthorized");
}
return res;
}
/**
* Build headers for CSRF-protected requests
* Per RFC-001: Double Submit Cookie pattern
*/
export function getCsrfHeaders(): HeadersInit {
const token = getCsrfToken();
return token ? { "X-CSRF-Token": token } : {};
}
/**
* Get CSRF token from cookie
*/
function getCsrfToken(): string | null {
const match = /csrf_token=([^;]+)/.exec(document.cookie);
return match?.[1] ?? null;
}
+165
View File
@@ -0,0 +1,165 @@
"use client";
import { useRouter, usePathname } from "next/navigation";
import React, {
createContext,
useContext,
useState,
useCallback,
useEffect,
type ReactNode,
} from "react";
import { type User, buildLoginUrl } from "./types";
// Re-export for consumers
export type { User };
/**
* Authentication context provided to consuming components
*/
interface AuthContextType {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
logout: () => Promise<void>;
refreshUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
interface AuthProviderProps {
children: ReactNode;
initialUser: User | null;
}
/**
* AuthProvider - Unified authentication context for the application
*
* Per RFC-001:
* - Only holds display information (user), never JWT or tokens
* - initialUser comes from server-side guard, avoiding client flicker
* - Provides logout and refresh capabilities
*/
export function AuthProvider({ children, initialUser }: AuthProviderProps) {
const [user, setUser] = useState<User | null>(initialUser);
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const pathname = usePathname();
const isAuthenticated = user !== null;
/**
* Fetch current user from FastAPI
* Used when initialUser might be stale (e.g., after tab was inactive)
*/
const refreshUser = useCallback(async () => {
try {
setIsLoading(true);
const res = await fetch("/api/v1/auth/me", {
credentials: "include",
});
if (res.ok) {
const data = await res.json();
setUser(data);
} else if (res.status === 401) {
// Session expired or invalid
setUser(null);
// Redirect to login if on a protected route
if (pathname?.startsWith("/workspace")) {
router.push(buildLoginUrl(pathname));
}
}
} catch (err) {
console.error("Failed to refresh user:", err);
setUser(null);
} finally {
setIsLoading(false);
}
}, [pathname, router]);
/**
* Logout - call FastAPI logout endpoint and clear local state
* Per RFC-001: Immediately clear local state, don't wait for server confirmation
*/
const logout = useCallback(async () => {
// Immediately clear local state to prevent UI flicker
setUser(null);
try {
await fetch("/api/v1/auth/logout", {
method: "POST",
credentials: "include",
});
} catch (err) {
console.error("Logout request failed:", err);
// Still redirect even if logout request fails
}
// Redirect to home page
router.push("/");
}, [router]);
/**
* Handle visibility change - refresh user when tab becomes visible again.
* Throttled to at most once per 60 s to avoid spamming the backend on rapid tab switches.
*/
const lastCheckRef = React.useRef(0);
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState !== "visible" || user === null) return;
const now = Date.now();
if (now - lastCheckRef.current < 60_000) return;
lastCheckRef.current = now;
void refreshUser();
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [user, refreshUser]);
const value: AuthContextType = {
user,
isAuthenticated,
isLoading,
logout,
refreshUser,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
/**
* Hook to access authentication context
* Throws if used outside AuthProvider - this is intentional for proper usage
*/
export function useAuth(): AuthContextType {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
/**
* Hook to require authentication - redirects to login if not authenticated
* Useful for client-side checks in addition to server-side guards
*/
export function useRequireAuth(): AuthContextType {
const auth = useAuth();
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
// Only redirect if we're sure user is not authenticated (not just loading)
if (!auth.isLoading && !auth.isAuthenticated) {
router.push(buildLoginUrl(pathname || "/workspace"));
}
}, [auth.isAuthenticated, auth.isLoading, router, pathname]);
return auth;
}
+34
View File
@@ -0,0 +1,34 @@
import { z } from "zod";
const gatewayConfigSchema = z.object({
internalGatewayUrl: z.string().url(),
trustedOrigins: z.array(z.string()).min(1),
});
export type GatewayConfig = z.infer<typeof gatewayConfigSchema>;
let _cached: GatewayConfig | null = null;
export function getGatewayConfig(): GatewayConfig {
if (_cached) return _cached;
const isDev = process.env.NODE_ENV === "development";
const rawUrl = process.env.DEER_FLOW_INTERNAL_GATEWAY_BASE_URL?.trim();
const internalGatewayUrl =
rawUrl?.replace(/\/+$/, "") ??
(isDev ? "http://localhost:8001" : undefined);
const rawOrigins = process.env.DEER_FLOW_TRUSTED_ORIGINS?.trim();
const trustedOrigins = rawOrigins
? rawOrigins
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: isDev
? ["http://localhost:3000"]
: undefined;
_cached = gatewayConfigSchema.parse({ internalGatewayUrl, trustedOrigins });
return _cached;
}
+55
View File
@@ -0,0 +1,55 @@
export interface ProxyPolicy {
/** Allowed upstream path prefixes */
readonly allowedPaths: readonly string[];
/** Request headers to strip before forwarding */
readonly strippedRequestHeaders: ReadonlySet<string>;
/** Response headers to strip before returning */
readonly strippedResponseHeaders: ReadonlySet<string>;
/** Credential mode: which cookie to forward */
readonly credential: { readonly type: "cookie"; readonly name: string };
/** Timeout in ms */
readonly timeoutMs: number;
/** CSRF: required for non-GET/HEAD */
readonly csrf: boolean;
}
export const LANGGRAPH_COMPAT_POLICY: ProxyPolicy = {
allowedPaths: [
"threads",
"runs",
"assistants",
"store",
"models",
"mcp",
"skills",
"memory",
],
strippedRequestHeaders: new Set([
"host",
"connection",
"keep-alive",
"transfer-encoding",
"te",
"trailer",
"upgrade",
"authorization",
"x-api-key",
"origin",
"referer",
"proxy-authorization",
"proxy-authenticate",
]),
strippedResponseHeaders: new Set([
"connection",
"keep-alive",
"transfer-encoding",
"te",
"trailer",
"upgrade",
"content-length",
"set-cookie",
]),
credential: { type: "cookie", name: "access_token" },
timeoutMs: 120_000,
csrf: true,
};
+57
View File
@@ -0,0 +1,57 @@
import { cookies } from "next/headers";
import { getGatewayConfig } from "./gateway-config";
import { type AuthResult, userSchema } from "./types";
const SSR_AUTH_TIMEOUT_MS = 5_000;
/**
* Fetch the authenticated user from the gateway using the request's cookies.
* Returns a tagged AuthResult — callers use exhaustive switch, no try/catch.
*/
export async function getServerSideUser(): Promise<AuthResult> {
const cookieStore = await cookies();
const sessionCookie = cookieStore.get("access_token");
let internalGatewayUrl: string;
try {
internalGatewayUrl = getGatewayConfig().internalGatewayUrl;
} catch (err) {
return { tag: "config_error", message: String(err) };
}
if (!sessionCookie) return { tag: "unauthenticated" };
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), SSR_AUTH_TIMEOUT_MS);
try {
const res = await fetch(`${internalGatewayUrl}/api/v1/auth/me`, {
headers: { Cookie: `access_token=${sessionCookie.value}` },
cache: "no-store",
signal: controller.signal,
});
clearTimeout(timeout); // Clear immediately — covers all response branches
if (res.ok) {
const parsed = userSchema.safeParse(await res.json());
if (!parsed.success) {
console.error("[SSR auth] Malformed /auth/me response:", parsed.error);
return { tag: "gateway_unavailable" };
}
if (parsed.data.needs_setup) {
return { tag: "needs_setup", user: parsed.data };
}
return { tag: "authenticated", user: parsed.data };
}
if (res.status === 401 || res.status === 403) {
return { tag: "unauthenticated" };
}
console.error(`[SSR auth] /api/v1/auth/me responded ${res.status}`);
return { tag: "gateway_unavailable" };
} catch (err) {
clearTimeout(timeout);
console.error("[SSR auth] Failed to reach gateway:", err);
return { tag: "gateway_unavailable" };
}
}
+72
View File
@@ -0,0 +1,72 @@
import { z } from "zod";
// ── User schema (single source of truth) ──────────────────────────
export const userSchema = z.object({
id: z.string(),
email: z.string().email(),
system_role: z.enum(["admin", "user"]),
needs_setup: z.boolean().optional().default(false),
});
export type User = z.infer<typeof userSchema>;
// ── SSR auth result (tagged union) ────────────────────────────────
export type AuthResult =
| { tag: "authenticated"; user: User }
| { tag: "needs_setup"; user: User }
| { tag: "unauthenticated" }
| { tag: "gateway_unavailable" }
| { tag: "config_error"; message: string };
export function assertNever(x: never): never {
throw new Error(`Unexpected auth result: ${JSON.stringify(x)}`);
}
export function buildLoginUrl(returnPath: string): string {
return `/login?next=${encodeURIComponent(returnPath)}`;
}
// ── Backend error response parsing ────────────────────────────────
const AUTH_ERROR_CODES = [
"invalid_credentials",
"token_expired",
"token_invalid",
"user_not_found",
"email_already_exists",
"provider_not_found",
"not_authenticated",
] as const;
export type AuthErrorCode = (typeof AUTH_ERROR_CODES)[number];
export interface AuthErrorResponse {
code: AuthErrorCode;
message: string;
}
const authErrorSchema = z.object({
code: z.enum(AUTH_ERROR_CODES),
message: z.string(),
});
export function parseAuthError(data: unknown): AuthErrorResponse {
// Try top-level {code, message} first
const parsed = authErrorSchema.safeParse(data);
if (parsed.success) return parsed.data;
// Unwrap FastAPI's {detail: {code, message}} envelope
if (typeof data === "object" && data !== null && "detail" in data) {
const detail = (data as Record<string, unknown>).detail;
const nested = authErrorSchema.safeParse(detail);
if (nested.success) return nested.data;
// Legacy string-detail responses
if (typeof detail === "string") {
return { code: "invalid_credentials", message: detail };
}
}
return { code: "invalid_credentials", message: "Authentication failed" };
}