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>
);
}
+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" };
}
-10
View File
@@ -7,12 +7,6 @@ export const env = createEnv({
* isn't built with invalid env vars.
*/
server: {
BETTER_AUTH_SECRET:
process.env.NODE_ENV === "production"
? z.string()
: z.string().optional(),
BETTER_AUTH_GITHUB_CLIENT_ID: z.string().optional(),
BETTER_AUTH_GITHUB_CLIENT_SECRET: z.string().optional(),
GITHUB_OAUTH_TOKEN: z.string().optional(),
NODE_ENV: z
.enum(["development", "test", "production"])
@@ -35,10 +29,6 @@ export const env = createEnv({
* middlewares) or client-side so we need to destruct manually.
*/
runtimeEnv: {
BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET,
BETTER_AUTH_GITHUB_CLIENT_ID: process.env.BETTER_AUTH_GITHUB_CLIENT_ID,
BETTER_AUTH_GITHUB_CLIENT_SECRET:
process.env.BETTER_AUTH_GITHUB_CLIENT_SECRET,
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_BACKEND_BASE_URL: process.env.NEXT_PUBLIC_BACKEND_BASE_URL,
@@ -1,5 +0,0 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient();
export type Session = typeof authClient.$Infer.Session;
@@ -1,9 +0,0 @@
import { betterAuth } from "better-auth";
export const auth = betterAuth({
emailAndPassword: {
enabled: true,
},
});
export type Session = typeof auth.$Infer.Session;
-1
View File
@@ -1 +0,0 @@
export { auth } from "./config";
@@ -1,8 +0,0 @@
import { headers } from "next/headers";
import { cache } from "react";
import { auth } from ".";
export const getSession = cache(async () =>
auth.api.getSession({ headers: await headers() }),
);