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
+45
View File
@@ -0,0 +1,45 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { type ReactNode } from "react";
import { AuthProvider } from "@/core/auth/AuthProvider";
import { getServerSideUser } from "@/core/auth/server";
import { assertNever } from "@/core/auth/types";
export const dynamic = "force-dynamic";
export default async function AuthLayout({
children,
}: {
children: ReactNode;
}) {
const result = await getServerSideUser();
switch (result.tag) {
case "authenticated":
redirect("/workspace");
case "needs_setup":
// Allow access to setup page
return <AuthProvider initialUser={result.user}>{children}</AuthProvider>;
case "unauthenticated":
return <AuthProvider initialUser={null}>{children}</AuthProvider>;
case "gateway_unavailable":
return (
<div className="flex h-screen flex-col items-center justify-center gap-4">
<p className="text-muted-foreground">
Service temporarily unavailable.
</p>
<Link
href="/login"
className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-4 py-2 text-sm"
>
Retry
</Link>
</div>
);
case "config_error":
throw new Error(result.message);
default:
assertNever(result);
}
}
+183
View File
@@ -0,0 +1,183 @@
"use client";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useAuth } from "@/core/auth/AuthProvider";
import { parseAuthError } from "@/core/auth/types";
/**
* Validate next parameter
* Prevent open redirect attacks
* Per RFC-001: Only allow relative paths starting with /
*/
function validateNextParam(next: string | null): string | null {
if (!next) {
return null;
}
// Need start with / (relative path)
if (!next.startsWith("/")) {
return null;
}
// Disallow protocol-relative URLs
if (
next.startsWith("//") ||
next.startsWith("http://") ||
next.startsWith("https://")
) {
return null;
}
// Disallow URLs with different protocols (e.g., javascript:, data:, etc)
if (next.includes(":") && !next.startsWith("/")) {
return null;
}
// Valid relative path
return next;
}
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { isAuthenticated } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isLogin, setIsLogin] = useState(true);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
// Get next parameter for validated redirect
const nextParam = searchParams.get("next");
const redirectPath = validateNextParam(nextParam) ?? "/workspace";
// Redirect if already authenticated (client-side, post-login)
useEffect(() => {
if (isAuthenticated) {
router.push(redirectPath);
}
}, [isAuthenticated, redirectPath, router]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const endpoint = isLogin
? "/api/v1/auth/login/local"
: "/api/v1/auth/register";
const body = isLogin
? `username=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}`
: JSON.stringify({ email, password });
const headers: HeadersInit = isLogin
? { "Content-Type": "application/x-www-form-urlencoded" }
: { "Content-Type": "application/json" };
const res = await fetch(endpoint, {
method: "POST",
headers,
body,
credentials: "include", // Important: include HttpOnly cookie
});
if (!res.ok) {
const data = await res.json();
const authError = parseAuthError(data);
setError(authError.message);
return;
}
// Both login and register set a cookie — redirect to workspace
router.push(redirectPath);
} catch (_err) {
setError("Network error. Please try again.");
} finally {
setLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-[#0a0a0a]">
<div className="border-border/20 w-full max-w-md space-y-6 rounded-lg border bg-black/50 p-8 backdrop-blur-sm">
<div className="text-center">
<h1 className="font-serif text-3xl">DeerFlow</h1>
<p className="text-muted-foreground mt-2">
{isLogin ? "Sign in to your account" : "Create a new account"}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="text-sm font-medium">
Email
</label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
className="mt-1 bg-white text-black"
/>
</div>
<div>
<label htmlFor="password" className="text-sm font-medium">
Password
</label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="•••••••"
required
minLength={isLogin ? 6 : 8}
className="mt-1 bg-white text-black"
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
{loading
? "Please wait..."
: isLogin
? "Sign In"
: "Create Account"}
</Button>
</form>
<div className="text-center text-sm">
<button
type="button"
onClick={() => {
setIsLogin(!isLogin);
setError("");
}}
className="text-blue-500 hover:underline"
>
{isLogin
? "Don't have an account? Sign up"
: "Already have an account? Sign in"}
</button>
</div>
<div className="text-muted-foreground text-center text-xs">
<Link href="/" className="hover:underline">
Back to home
</Link>
</div>
</div>
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { getCsrfHeaders } from "@/core/api/fetcher";
import { parseAuthError } from "@/core/auth/types";
export default function SetupPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [currentPassword, setCurrentPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSetup = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (newPassword !== confirmPassword) {
setError("Passwords do not match");
return;
}
if (newPassword.length < 8) {
setError("Password must be at least 8 characters");
return;
}
setLoading(true);
try {
const res = await fetch("/api/v1/auth/change-password", {
method: "POST",
headers: {
"Content-Type": "application/json",
...getCsrfHeaders(),
},
credentials: "include",
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword,
new_email: email || undefined,
}),
});
if (!res.ok) {
const data = await res.json();
const authError = parseAuthError(data);
setError(authError.message);
return;
}
router.push("/workspace");
} catch {
setError("Network error. Please try again.");
} finally {
setLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center">
<div className="w-full max-w-sm space-y-6 p-6">
<div className="text-center">
<h1 className="font-serif text-3xl">DeerFlow</h1>
<p className="text-muted-foreground mt-2">
Complete admin account setup
</p>
<p className="text-muted-foreground mt-1 text-xs">
Set your real email and a new password.
</p>
</div>
<form onSubmit={handleSetup} className="space-y-4">
<Input
type="email"
placeholder="Your email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<Input
type="password"
placeholder="Current password (from console log)"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
/>
<Input
type="password"
placeholder="New password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={8}
/>
<Input
type="password"
placeholder="Confirm new password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
/>
{error && <p className="text-sm text-red-500">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Setting up..." : "Complete Setup"}
</Button>
</form>
</div>
</div>
);
}
@@ -1,5 +0,0 @@
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/server/better-auth";
export const { GET, POST } = toNextJsHandler(auth.handler);
+51 -40
View File
@@ -1,47 +1,58 @@
"use client";
import Link from "next/link";
import { redirect } from "next/navigation";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { Toaster } from "sonner";
import { AuthProvider } from "@/core/auth/AuthProvider";
import { getServerSideUser } from "@/core/auth/server";
import { assertNever } from "@/core/auth/types";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { CommandPalette } from "@/components/workspace/command-palette";
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
import { getLocalSettings, useLocalSettings } from "@/core/settings";
import { WorkspaceContent } from "./workspace-content";
const queryClient = new QueryClient();
export const dynamic = "force-dynamic";
export default function WorkspaceLayout({
export default async function WorkspaceLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
const [settings, setSettings] = useLocalSettings();
const [open, setOpen] = useState(false); // SSR default: open (matches server render)
useLayoutEffect(() => {
// Runs synchronously before first paint on the client — no visual flash
setOpen(!getLocalSettings().layout.sidebar_collapsed);
}, []);
useEffect(() => {
setOpen(!settings.layout.sidebar_collapsed);
}, [settings.layout.sidebar_collapsed]);
const handleOpenChange = useCallback(
(open: boolean) => {
setOpen(open);
setSettings("layout", { sidebar_collapsed: !open });
},
[setSettings],
);
return (
<QueryClientProvider client={queryClient}>
<SidebarProvider
className="h-screen"
open={open}
onOpenChange={handleOpenChange}
>
<WorkspaceSidebar />
<SidebarInset className="min-w-0">{children}</SidebarInset>
</SidebarProvider>
<CommandPalette />
<Toaster position="top-center" />
</QueryClientProvider>
);
const result = await getServerSideUser();
switch (result.tag) {
case "authenticated":
return (
<AuthProvider initialUser={result.user}>
<WorkspaceContent>{children}</WorkspaceContent>
</AuthProvider>
);
case "needs_setup":
redirect("/setup");
case "unauthenticated":
redirect("/login");
case "gateway_unavailable":
return (
<div className="flex h-screen flex-col items-center justify-center gap-4">
<p className="text-muted-foreground">
Service temporarily unavailable.
</p>
<p className="text-muted-foreground text-xs">
The backend may be restarting. Please wait a moment and try again.
</p>
<div className="flex gap-3">
<Link
href="/workspace"
className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-4 py-2 text-sm"
>
Retry
</Link>
<Link
href="/api/v1/auth/logout"
className="text-muted-foreground hover:bg-muted rounded-md border px-4 py-2 text-sm"
>
Logout &amp; Reset
</Link>
</div>
</div>
);
case "config_error":
throw new Error(result.message);
default:
assertNever(result);
}
}
@@ -0,0 +1,50 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { Toaster } from "sonner";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { CommandPalette } from "@/components/workspace/command-palette";
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
import { getLocalSettings, useLocalSettings } from "@/core/settings";
export function WorkspaceContent({
children,
}: Readonly<{ children: React.ReactNode }>) {
const [queryClient] = useState(() => new QueryClient());
const [settings, setSettings] = useLocalSettings();
const [open, setOpen] = useState(false); // SSR default: open (matches server render)
useLayoutEffect(() => {
// Runs synchronously before first paint on the client — no visual flash
setOpen(!getLocalSettings().layout.sidebar_collapsed);
}, []);
useEffect(() => {
setOpen(!settings.layout.sidebar_collapsed);
}, [settings.layout.sidebar_collapsed]);
const handleOpenChange = useCallback(
(open: boolean) => {
setOpen(open);
setSettings("layout", { sidebar_collapsed: !open });
},
[setSettings],
);
return (
<QueryClientProvider client={queryClient}>
<SidebarProvider
className="h-screen"
open={open}
onOpenChange={handleOpenChange}
>
<WorkspaceSidebar />
<SidebarInset className="min-w-0">{children}</SidebarInset>
</SidebarProvider>
<CommandPalette />
<Toaster position="top-center" />
</QueryClientProvider>
);
}