mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-21 07:26:50 +00:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
Reference in New Issue
Block a user