feat: add authentication for OSS (#167)

* feat: add authentication for OSS

Fixes #157 and #156

* fix: fix token generation

* fix: limit fastapi workers to 1
This commit is contained in:
Abhishek 2026-02-20 18:21:24 +05:30 committed by GitHub
parent 0791975864
commit 642cc34e8c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 994 additions and 303 deletions

View file

@ -1,3 +1,4 @@
import { isNextRouterError } from "next/dist/client/components/is-next-router-error";
import { redirect } from "next/navigation";
import { getWorkflowCountApiV1WorkflowCountGet } from "@/client/sdk.gen";
@ -9,7 +10,7 @@ export const dynamic = 'force-dynamic';
export default async function AfterSignInPage() {
logger.debug('[AfterSignInPage] Starting after-sign-in page');
const authProvider = getServerAuthProvider();
const authProvider = await getServerAuthProvider();
logger.debug('[AfterSignInPage] Auth provider:', authProvider);
logger.debug('[AfterSignInPage] Getting server user...');
const user = await getServerUser();
@ -54,6 +55,9 @@ export default async function AfterSignInPage() {
}
}
} catch (error) {
if (isNextRouterError(error)) {
throw error;
}
logger.error('[AfterSignInPage] Error checking workflows:', error);
}

View file

@ -20,12 +20,14 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { useAppConfig } from '@/context/AppConfigContext';
import { useAuth } from '@/lib/auth';
import logger from '@/lib/logger';
import { isOSSMode } from '@/lib/utils';
export default function APIKeysPage() {
const { user, getAccessToken, redirectToLogin, loading } = useAuth();
const { config } = useAppConfig();
const isOSS = config?.deploymentMode === 'oss';
logger.debug('[APIKeysPage] Component render', {
loading,
@ -313,8 +315,8 @@ export default function APIKeysPage() {
// In OSS mode, check if there's already an active service key
const activeServiceKeys = serviceKeys.filter(key => !key.archived_at);
const canCreateServiceKey = !isOSSMode() || activeServiceKeys.length === 0;
const showServiceKeyArchiveControls = !isOSSMode();
const canCreateServiceKey = !isOSS || activeServiceKeys.length === 0;
const showServiceKeyArchiveControls = !isOSS;
return (
<div className="min-h-screen bg-background">

View file

@ -0,0 +1,27 @@
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
const OSS_TOKEN_COOKIE = 'dograh_auth_token';
const OSS_USER_COOKIE = 'dograh_auth_user';
export async function POST() {
const cookieStore = await cookies();
cookieStore.set(OSS_TOKEN_COOKIE, '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 0,
path: '/',
});
cookieStore.set(OSS_USER_COOKIE, '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 0,
path: '/',
});
return NextResponse.json({ success: true });
}

View file

@ -1,19 +1,18 @@
/*
Provides authentication token to LocalProviderWrapper once loaded
in the browser
in the browser.
Returns 401 if no token cookie exists (user needs to log in).
*/
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
const OSS_TOKEN_COOKIE = 'dograh_oss_token';
const OSS_USER_COOKIE = 'dograh_oss_user';
import { getAuthProvider } from '@/lib/auth/config';
function generateOSSToken(): string {
return `oss_${Date.now()}_${crypto.randomUUID()}`;
}
const OSS_TOKEN_COOKIE = 'dograh_auth_token';
const OSS_USER_COOKIE = 'dograh_auth_user';
export async function GET() {
const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack';
const authProvider = await getAuthProvider();
// Only handle OSS mode
if (authProvider !== 'local') {
@ -21,40 +20,17 @@ export async function GET() {
}
const cookieStore = await cookies();
let token = cookieStore.get(OSS_TOKEN_COOKIE)?.value;
let user = cookieStore.get(OSS_USER_COOKIE)?.value;
const token = cookieStore.get(OSS_TOKEN_COOKIE)?.value;
const user = cookieStore.get(OSS_USER_COOKIE)?.value;
// If no token exists, create one
// If no token exists, return 401 (user needs to sign up or log in)
if (!token) {
token = generateOSSToken();
user = JSON.stringify({
id: token,
name: 'Local User',
provider: 'local',
organizationId: `org_${token}`,
});
// Set cookies
cookieStore.set(OSS_TOKEN_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30, // 30 days
path: '/',
});
cookieStore.set(OSS_USER_COOKIE, user, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30, // 30 days
path: '/',
});
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
// Return the auth info as JSON (safe to expose to client)
// Return the auth info as JSON
return NextResponse.json({
token,
user: JSON.parse(user!),
user: user ? JSON.parse(user) : { id: token, name: 'Local User', provider: 'local' },
});
}

View file

@ -0,0 +1,33 @@
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
const OSS_TOKEN_COOKIE = 'dograh_auth_token';
const OSS_USER_COOKIE = 'dograh_auth_user';
export async function POST(request: NextRequest) {
const { token, user } = await request.json();
if (!token) {
return NextResponse.json({ error: 'Missing token' }, { status: 400 });
}
const cookieStore = await cookies();
cookieStore.set(OSS_TOKEN_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30,
path: '/',
});
cookieStore.set(OSS_USER_COOKIE, JSON.stringify(user), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30,
path: '/',
});
return NextResponse.json({ success: true });
}

View file

@ -0,0 +1,8 @@
import { NextResponse } from 'next/server';
import { getAuthProvider } from '@/lib/auth/config';
export async function GET() {
const provider = await getAuthProvider();
return NextResponse.json({ provider });
}

View file

@ -6,28 +6,50 @@ import type { HealthResponse } from "@/client/types.gen";
// Import version from package.json at build time
import packageJson from "../../../../../package.json";
// Internal/local URLs that are not reachable from the browser
const INTERNAL_HOST_RE = /^https?:\/\/(localhost|127\.0\.0\.1|api)(:\d+)?(\/|$)/;
function isInternalUrl(url: string | undefined | null): boolean {
return !url || INTERNAL_HOST_RE.test(url);
}
export async function GET() {
const uiVersion = packageJson.version || "dev";
// Fetch backend version and config from health endpoint
let apiVersion = "unknown";
let backendApiEndpoint: string | null = null;
let deploymentMode = "oss";
let authProvider = "local";
try {
const response = await healthApiV1HealthGet();
if (response.data) {
const data = response.data as HealthResponse;
apiVersion = data.version;
// Pass through the backend's own endpoint for display purposes
backendApiEndpoint = data.backend_api_endpoint;
deploymentMode = data.deployment_mode;
authProvider = data.auth_provider;
}
} catch {
// Backend might not be reachable during build or in some deployments
apiVersion = "unavailable";
}
// For the API client base URL: prefer BACKEND_URL env, fall back to
// health endpoint value. Skip internal/Docker-only URLs (e.g. http://api:8000)
// that aren't reachable from the browser — the client will keep using
// window.location.origin via the Next.js proxy instead.
const clientCandidate = process.env.BACKEND_URL || backendApiEndpoint;
const clientApiBaseUrl = isInternalUrl(clientCandidate) ? 'http://localhost:8000' : clientCandidate;
return NextResponse.json({
ui: uiVersion,
api: apiVersion,
backendApiEndpoint,
clientApiBaseUrl,
deploymentMode,
authProvider,
});
}

View file

@ -0,0 +1,93 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import { loginApiV1AuthLoginPost } from "@/client/sdk.gen";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const res = await loginApiV1AuthLoginPost({
body: { email, password },
});
if (res.error || !res.data) {
const detail = (res.error as { detail?: string })?.detail;
toast.error(detail || "Login failed");
return;
}
// Set httpOnly cookies via server route
await fetch("/api/auth/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: res.data.token, user: res.data.user }),
});
window.location.href = "/after-sign-in";
} catch {
toast.error("An error occurred. Please try again.");
} finally {
setLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Sign in</CardTitle>
<CardDescription>Enter your email and password to continue</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Signing in..." : "Sign in"}
</Button>
</form>
<p className="mt-4 text-center text-sm text-muted-foreground">
Don&apos;t have an account?{" "}
<Link href="/auth/signup" className="text-primary underline-offset-4 hover:underline">
Sign up
</Link>
</p>
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,118 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import { signupApiV1AuthSignupPost } from "@/client/sdk.gen";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function SignupPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (password.length < 8) {
toast.error("Password must be at least 8 characters");
return;
}
if (password !== confirmPassword) {
toast.error("Passwords do not match");
return;
}
setLoading(true);
try {
const res = await signupApiV1AuthSignupPost({
body: { email, password },
});
if (res.error || !res.data) {
const detail = (res.error as { detail?: string })?.detail;
toast.error(detail || "Signup failed");
return;
}
// Set httpOnly cookies via server route
await fetch("/api/auth/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: res.data.token, user: res.data.user }),
});
window.location.href = "/after-sign-in";
} catch {
toast.error("An error occurred. Please try again.");
} finally {
setLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Create an account</CardTitle>
<CardDescription>Enter your details to get started</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="At least 8 characters"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm password</Label>
<Input
id="confirmPassword"
type="password"
placeholder="Confirm your password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Creating account..." : "Create account"}
</Button>
</form>
<p className="mt-4 text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link href="/auth/login" className="text-primary underline-offset-4 hover:underline">
Sign in
</Link>
</p>
</CardContent>
</Card>
</div>
);
}

View file

@ -1,12 +1,11 @@
import { StackHandler } from "@stackframe/stack";
import { stackServerApp } from "../../../stack";
import { getAuthProvider } from "@/lib/auth/config";
const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER;
export default async function Handler(props: unknown) {
const authProvider = await getAuthProvider();
export default function Handler(props: unknown) {
if (authProvider === "local") {
// Return a simple message when using local auth
return (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h1>Local Auth Mode</h1>
@ -14,9 +13,14 @@ export default function Handler(props: unknown) {
</div>
);
}
// Lazily import the real StackServerApp only when needed
const { getStackServerApp } = await import("@/lib/auth/server");
const app = await getStackServerApp();
return <StackHandler
fullPage
app={stackServerApp}
app={app!}
routeProps={props}
/>;
}

View file

@ -11,7 +11,7 @@ export const dynamic = 'force-dynamic';
// Server component for integration list
async function IntegrationList() {
const authProvider = getServerAuthProvider();
const authProvider = await getServerAuthProvider();
const accessToken = await getServerAccessToken();
if (!accessToken) {

View file

@ -21,7 +21,7 @@ interface PageProps {
}
async function PageContent({ params }: PageProps) {
const authProvider = getServerAuthProvider();
const authProvider = await getServerAuthProvider();
const accessToken = await getServerAccessToken();
if (!accessToken) {

View file

@ -11,7 +11,7 @@ export const dynamic = 'force-dynamic';
export default async function Home() {
logger.debug('[HomePage] Starting Home page render');
const authProvider = getServerAuthProvider();
const authProvider = await getServerAuthProvider();
logger.debug('[HomePage] Auth provider:', authProvider);
// For local/OSS provider, check if user has workflows
@ -39,6 +39,8 @@ export default async function Home() {
logger.debug('[HomePage] Redirecting to /workflow/create - no workflows found');
redirect('/workflow/create');
}
} else {
redirect('/auth/login');
}
} catch (error) {
// Re-throw navigation errors (redirects, not found, etc.) - they're intentional

View file

@ -13,7 +13,7 @@ export const dynamic = 'force-dynamic';
// Server component for workflow list
async function WorkflowList() {
const authProvider = getServerAuthProvider();
const authProvider = await getServerAuthProvider();
const accessToken = await getServerAccessToken();
if (!accessToken) {

File diff suppressed because one or more lines are too long

View file

@ -80,6 +80,11 @@ export type AdminCommentResponse = {
admin_comment_ts: string;
};
export type AuthResponse = {
token: string;
user: UserResponse;
};
export type AuthUserResponse = {
id: number;
is_superuser: boolean;
@ -599,6 +604,8 @@ export type HealthResponse = {
status: string;
version: string;
backend_api_endpoint: string;
deployment_mode: string;
auth_provider: string;
};
/**
@ -721,6 +728,11 @@ export type LoadTestStatsResponse = {
}>;
};
export type LoginRequest = {
email: string;
password: string;
};
export type PresignedUploadUrlRequest = {
/**
* CSV filename
@ -808,6 +820,12 @@ export type SessionResponse = {
expires_at: string;
};
export type SignupRequest = {
email: string;
password: string;
name?: string | null;
};
export type SuperuserWorkflowRunResponse = {
id: number;
name: string;
@ -1129,6 +1147,13 @@ export type UserConfigurationRequestResponseSchema = {
} | null;
};
export type UserResponse = {
id: number;
email: string | null;
name?: string | null;
organization_id?: number | null;
};
export type ValidateWorkflowResponse = {
is_valid: boolean;
errors: Array<WorkflowError>;
@ -5018,6 +5043,97 @@ export type SearchChunksApiV1KnowledgeBaseSearchPostResponses = {
export type SearchChunksApiV1KnowledgeBaseSearchPostResponse = SearchChunksApiV1KnowledgeBaseSearchPostResponses[keyof SearchChunksApiV1KnowledgeBaseSearchPostResponses];
export type SignupApiV1AuthSignupPostData = {
body: SignupRequest;
path?: never;
query?: never;
url: '/api/v1/auth/signup';
};
export type SignupApiV1AuthSignupPostErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type SignupApiV1AuthSignupPostError = SignupApiV1AuthSignupPostErrors[keyof SignupApiV1AuthSignupPostErrors];
export type SignupApiV1AuthSignupPostResponses = {
/**
* Successful Response
*/
200: AuthResponse;
};
export type SignupApiV1AuthSignupPostResponse = SignupApiV1AuthSignupPostResponses[keyof SignupApiV1AuthSignupPostResponses];
export type LoginApiV1AuthLoginPostData = {
body: LoginRequest;
path?: never;
query?: never;
url: '/api/v1/auth/login';
};
export type LoginApiV1AuthLoginPostErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type LoginApiV1AuthLoginPostError = LoginApiV1AuthLoginPostErrors[keyof LoginApiV1AuthLoginPostErrors];
export type LoginApiV1AuthLoginPostResponses = {
/**
* Successful Response
*/
200: AuthResponse;
};
export type LoginApiV1AuthLoginPostResponse = LoginApiV1AuthLoginPostResponses[keyof LoginApiV1AuthLoginPostResponses];
export type GetCurrentUserApiV1AuthMeGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
url: '/api/v1/auth/me';
};
export type GetCurrentUserApiV1AuthMeGetErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetCurrentUserApiV1AuthMeGetError = GetCurrentUserApiV1AuthMeGetErrors[keyof GetCurrentUserApiV1AuthMeGetErrors];
export type GetCurrentUserApiV1AuthMeGetResponses = {
/**
* Successful Response
*/
200: UserResponse;
};
export type GetCurrentUserApiV1AuthMeGetResponse = GetCurrentUserApiV1AuthMeGetResponses[keyof GetCurrentUserApiV1AuthMeGetResponses];
export type HealthApiV1HealthGetData = {
body?: never;
path?: never;

View file

@ -2,6 +2,10 @@
import { Loader2 } from 'lucide-react';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { useAuth } from '@/lib/auth';
import Footer from './Footer';
@ -12,16 +16,19 @@ const SignIn = dynamic(
);
export default function SignInClient() {
const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack';
const { provider } = useAuth();
const router = useRouter();
if (authProvider !== 'stack') {
useEffect(() => {
if (provider === 'local') {
router.replace('/auth/login');
}
}, [provider, router]);
if (provider !== 'stack') {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold mb-4">Local Authentication</h1>
<p className="text-gray-600">Local authentication is enabled. No sign-in required.</p>
</div>
<Footer />
<Loader2 className="w-5 h-5 animate-spin text-gray-600" />
</div>
);
}

View file

@ -14,7 +14,6 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { isOSSMode } from "@/lib/utils";
import { NodeContent } from "./common/NodeContent";
import { NodeEditDialog } from "./common/NodeEditDialog";
@ -350,21 +349,19 @@ const StartCallEditForm = ({
Add Global Prompt
</Label>
</div>
{!isOSSMode() && (
<div className="flex items-center space-x-2">
<Switch
id="detect-voicemail"
checked={detectVoicemail}
onCheckedChange={setDetectVoicemail}
/>
<Label htmlFor="detect-voicemail">
Detect Voicemail
</Label>
<Label className="text-xs text-muted-foreground">
Automatically detect and end call if voicemail is reached.
</Label>
</div>
)}
<div className="flex items-center space-x-2">
<Switch
id="detect-voicemail"
checked={detectVoicemail}
onCheckedChange={setDetectVoicemail}
/>
<Label htmlFor="detect-voicemail">
Detect Voicemail
</Label>
<Label className="text-xs text-muted-foreground">
Automatically detect and end call if voicemail is reached.
</Label>
</div>
<div className="flex flex-col space-y-2">
<div className="flex items-center space-x-2">
<Switch

View file

@ -36,8 +36,7 @@ export const TriggerNode = memo(({ data, selected, id }: TriggerNodeProps) => {
const [triggerPath] = useState(() => data.trigger_path ?? crypto.randomUUID());
// Get backend URL from app config (fetched from backend health endpoint)
// Falls back to env variable, then to localhost for local development
const backendUrl = config?.backendApiEndpoint || process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:8000";
const backendUrl = config?.backendApiEndpoint || "http://localhost:8000";
const endpoint = `${backendUrl}/api/v1/public/agent/${triggerPath}`;
// Copy state for button feedback

View file

@ -21,8 +21,8 @@ const AppLayout: React.FC<AppLayoutProps> = ({
const pathname = usePathname();
// Check if current route should have sidebar
// Hide sidebar for root (/) and /handler routes (Stack Auth routes)
const shouldShowSidebar = pathname !== "/" && !pathname.startsWith("/handler");
// Hide sidebar for root (/), /handler routes (Stack Auth routes), and /auth routes
const shouldShowSidebar = pathname !== "/" && !pathname.startsWith("/handler") && !pathname.startsWith("/auth");
// Check if we're in workflow editor mode or superadmin runs - collapse sidebar by default
const isWorkflowEditor = /^\/workflow\/\d+/.test(pathname);

View file

@ -8,7 +8,6 @@ import {
CircleDollarSign,
Database,
FileText,
HelpCircle,
Home,
Key,
LogOut,
@ -24,7 +23,7 @@ import {
} from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import React from "react";
import React, { useRef } from "react";
import ThemeToggle from "@/components/ThemeSwitcher";
import { Button } from "@/components/ui/button";
@ -57,6 +56,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useAppConfig } from "@/context/AppConfigContext";
import type { LocalUser } from "@/lib/auth";
import { useAuth } from "@/lib/auth";
import { cn } from "@/lib/utils";
@ -75,7 +75,14 @@ export function AppSidebar() {
const { config } = useAppConfig();
// Get selected team for Stack auth (cast to Team type from Stack)
const selectedTeam = provider === "stack" && getSelectedTeam ? getSelectedTeam() as Team | null : null;
// Stabilize the reference so SelectedTeamSwitcher only sees a change when the team ID changes,
// preventing unnecessary PATCH calls to Stack Auth on every route navigation.
const selectedTeamRef = useRef<Team | null>(null);
const rawSelectedTeam = provider === "stack" && getSelectedTeam ? getSelectedTeam() as Team | null : null;
if (rawSelectedTeam?.id !== selectedTeamRef.current?.id) {
selectedTeamRef.current = rawSelectedTeam;
}
const selectedTeam = selectedTeamRef.current;
// Version info from app config context
const versionInfo = config ? { ui: config.uiVersion, api: config.apiVersion } : null;
@ -358,54 +365,45 @@ export function AppSidebar() {
)}>
{/* Bottom Actions */}
<div className="space-y-2">
{/* Get Help - for OSS mode */}
{/* User Button - for local/OSS mode */}
{provider !== "stack" && (
<>
{state === "collapsed" ? (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="w-full hover:bg-accent hover:text-accent-foreground"
asChild
>
<a
href="https://github.com/dograh-hq/dograh/issues/new/choose"
target="_blank"
rel="noopener noreferrer"
>
<HelpCircle className="h-4 w-4" />
<span className="sr-only">Get Help</span>
</a>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Get Help</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Button
variant="ghost"
className="w-full justify-start hover:bg-accent hover:text-accent-foreground"
asChild
>
<a
href="https://github.com/dograh-hq/dograh/issues/new/choose"
target="_blank"
rel="noopener noreferrer"
>
<HelpCircle className="h-4 w-4" />
<span className="ml-2">Get Help</span>
</a>
</Button>
)}
</>
<div className={cn(
"flex",
state === "collapsed" ? "justify-center" : "justify-start"
)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="rounded-full h-8 w-8 cursor-pointer">
<span className="text-xs font-medium">
{(user?.displayName || (user as LocalUser | undefined)?.email || "")
.split(/[\s@]/)
.filter(Boolean)
.slice(0, 2)
.map((s: string) => s[0]?.toUpperCase())
.join("")
|| "U"}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" className="w-56">
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
{(user as LocalUser | undefined)?.email && (
<p className="text-xs text-muted-foreground">{(user as LocalUser).email}</p>
)}
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => logout()} className="cursor-pointer">
<LogOut className="mr-2 h-4 w-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{/* User Button - at the bottom */}
{/* User Button - for Stack auth */}
{provider === "stack" && (
<div className={cn(
"flex",

View file

@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useAppConfig } from '@/context/AppConfigContext';
import { useAuth } from '@/lib/auth';
import logger from '@/lib/logger';
@ -45,6 +46,7 @@ export function LiveAudioPlayer({
const nextStartTimeRef = useRef(0);
const animationFrameRef = useRef<number | undefined>(undefined);
const isConnectingRef = useRef(false);
const { config } = useAppConfig();
const { user, getAccessToken } = useAuth();
// Auto-start streaming when session starts
@ -98,7 +100,7 @@ export function LiveAudioPlayer({
const accessToken = await getAccessToken();
// Create WebSocket connection
const baseUrl = process.env.NEXT_PUBLIC_BACKEND_URL?.replace('http', 'ws') || 'ws://localhost:8000';
const baseUrl = (config?.backendApiEndpoint || 'http://localhost:8000').replace(/^http/, 'ws');
const wsUrl = `${baseUrl}/api/v1/looptalk/test-sessions/${testSessionId}/audio-stream?role=${audioRole}&token=${encodeURIComponent(accessToken || '')}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
@ -199,7 +201,7 @@ export function LiveAudioPlayer({
} finally {
isConnectingRef.current = false;
}
}, [testSessionId, audioRole, user, getAccessToken, volume, monitorAudioLevel]); // Removed connectionStatus to avoid loops
}, [testSessionId, audioRole, user, getAccessToken, volume, monitorAudioLevel, config]); // Removed connectionStatus to avoid loops
const disconnect = useCallback(() => {
if (wsRef.current) {

View file

@ -2,10 +2,14 @@
import { createContext, ReactNode, useContext, useEffect, useState } from 'react';
import { client } from '@/client/client.gen';
interface AppConfig {
uiVersion: string;
apiVersion: string;
backendApiEndpoint: string | null;
deploymentMode: string;
authProvider: string;
}
interface AppConfigContextType {
@ -17,6 +21,8 @@ const defaultConfig: AppConfig = {
uiVersion: 'dev',
apiVersion: 'unknown',
backendApiEndpoint: null,
deploymentMode: 'oss',
authProvider: 'local',
};
const AppConfigContext = createContext<AppConfigContextType>({
@ -32,10 +38,17 @@ export function AppConfigProvider({ children }: { children: ReactNode }) {
fetch('/api/config/version')
.then((res) => res.json())
.then((data) => {
// Use clientApiBaseUrl (filtered for browser-reachable URLs)
// to configure the API client; keep backendApiEndpoint for display
if (data.clientApiBaseUrl) {
client.setConfig({ baseUrl: data.clientApiBaseUrl });
}
setConfig({
uiVersion: data.ui || 'dev',
apiVersion: data.api || 'unknown',
backendApiEndpoint: data.backendApiEndpoint || null,
deploymentMode: data.deploymentMode || 'oss',
authProvider: data.authProvider || 'local',
});
})
.catch(() => {

View file

@ -2,6 +2,7 @@
import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { client } from '@/client/client.gen';
import { getUserConfigurationsApiV1UserConfigurationsUserGet, updateUserConfigurationsApiV1UserConfigurationsUserPut } from '@/client/sdk.gen';
import type { UserConfigurationRequestResponseSchema } from '@/client/types.gen';
import { setupAuthInterceptor } from '@/lib/apiClient';
@ -71,7 +72,7 @@ export function UserConfigProvider({ children }: { children: ReactNode }) {
// so it's in place before any child effects fire API calls.
// setupAuthInterceptor is idempotent — safe for strict mode double-renders.
if (!auth.loading && auth.isAuthenticated) {
setupAuthInterceptor(auth.getAccessToken);
setupAuthInterceptor(client, auth.getAccessToken);
}
// Fetch permissions once when auth is ready

View file

@ -1,5 +1,6 @@
import type { Client } from '@hey-api/client-fetch';
import type { CreateClientConfig } from '@/client/client.gen';
import { client } from '@/client/client.gen';
export const createClientConfig: CreateClientConfig = (config) => {
// Use different URLs for server-side vs client-side
@ -10,8 +11,9 @@ export const createClientConfig: CreateClientConfig = (config) => {
// for server-side rendering, still use environment variable as fallback
baseUrl = process.env.BACKEND_URL || 'http://api:8000';
} else {
// for client-side, use the current browser URL's origin
baseUrl = process.env.NEXT_PUBLIC_BACKEND_URL || window.location.origin;
// Client-side API calls are proxied through Next.js rewrites.
// AppConfigContext may update this later with the fetched backend URL.
baseUrl = window.location.origin;
}
return {
@ -26,11 +28,11 @@ let interceptorRegistered = false;
* Register a request interceptor that attaches a fresh access token
* to every outgoing SDK request. Idempotent safe for React strict mode.
*/
export function setupAuthInterceptor(getAccessToken: () => Promise<string>) {
export function setupAuthInterceptor(apiClient: Client, getAccessToken: () => Promise<string>) {
if (interceptorRegistered) return;
interceptorRegistered = true;
client.interceptors.request.use(async (request) => {
apiClient.interceptors.request.use(async (request) => {
if (request.headers.get('Authorization')) {
return request;
}

30
ui/src/lib/auth/config.ts Normal file
View file

@ -0,0 +1,30 @@
import "server-only";
let cachedAuthProvider: string | null = null;
/**
* Fetches the auth provider from the backend health endpoint and caches it.
* Falls back to 'local' on error.
*/
export async function getAuthProvider(): Promise<string> {
if (cachedAuthProvider) {
return cachedAuthProvider;
}
try {
const backendUrl = process.env.BACKEND_URL || "http://localhost:8000";
const res = await fetch(`${backendUrl}/api/v1/health`, {
next: { revalidate: 300 },
});
if (res.ok) {
const data = await res.json();
cachedAuthProvider = (data.auth_provider as string) || "local";
return cachedAuthProvider;
}
} catch {
// Backend not reachable — fall back to local
}
cachedAuthProvider = "local";
return cachedAuthProvider;
}

View file

@ -1,7 +1,7 @@
'use client';
import { Loader2 } from 'lucide-react';
import React, { createContext, lazy, Suspense, useContext } from 'react';
import React, { createContext, lazy, Suspense, useContext, useEffect, useState } from 'react';
import type { AuthUser } from '../types';
@ -34,17 +34,30 @@ const LocalProviderWrapper = lazy(() =>
}))
);
const LoadingFallback = (
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack';
const [authProvider, setAuthProvider] = useState<string | null>(null);
useEffect(() => {
fetch('/api/config/auth')
.then((res) => res.json())
.then((data) => setAuthProvider(data.provider || 'stack'))
.catch(() => setAuthProvider('local'));
}, []);
if (!authProvider) {
return LoadingFallback;
}
// For Stack provider, use the dedicated wrapper
if (authProvider === 'stack') {
return (
<Suspense fallback={
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
}>
<Suspense fallback={LoadingFallback}>
<StackProviderWrapper>
{children}
</StackProviderWrapper>
@ -54,11 +67,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
// For local/OSS provider
return (
<Suspense fallback={
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
}>
<Suspense fallback={LoadingFallback}>
<LocalProviderWrapper>
{children}
</LocalProviderWrapper>

View file

@ -23,6 +23,12 @@ export function LocalProviderWrapper({ children }: { children: React.ReactNode }
tokenRef.current = data.token;
setUser(data.user);
logger.info('OSS auth initialized', { user: data.user });
} else if (response.status === 401) {
// No token - redirect to login (but not if already on auth pages)
if (!window.location.pathname.startsWith('/auth/')) {
window.location.href = '/auth/login';
return;
}
} else {
logger.error('Failed to initialize OSS auth');
}
@ -48,17 +54,23 @@ export function LocalProviderWrapper({ children }: { children: React.ReactNode }
}, []);
const redirectToLogin = React.useCallback(() => {
logger.info('Login redirect not needed in local mode');
window.location.href = '/auth/login';
}, []);
const logout = React.useCallback(async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' });
} catch (error) {
logger.error('Error during logout', error);
}
setUser(null);
logger.info('Logout requested in OSS mode - server cookies need to be cleared');
tokenRef.current = null;
window.location.href = '/auth/login';
}, []);
const contextValue = useMemo(() => ({
user: user as AuthUser,
isAuthenticated: !loading,
isAuthenticated: !!user,
loading,
getAccessToken,
redirectToLogin,

View file

@ -5,20 +5,21 @@ import { cookies } from 'next/headers';
import logger from '@/lib/logger';
import { getAuthProvider } from './config';
import type { LocalUser } from './types';
// Server-side auth utilities for SSR pages
// This file should only be imported in server components
let stackServerApp: StackServerApp<boolean, string> | null = null;
const OSS_TOKEN_COOKIE = 'dograh_oss_token';
const OSS_USER_COOKIE = 'dograh_oss_user';
const OSS_TOKEN_COOKIE = 'dograh_auth_token';
const OSS_USER_COOKIE = 'dograh_auth_user';
// Lazy load and cache the stack server app
async function getStackServerApp(): Promise<StackServerApp<boolean, string> | null> {
export async function getStackServerApp(): Promise<StackServerApp<boolean, string> | null> {
if (!stackServerApp) {
// Only import if using Stack provider
const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack';
const authProvider = await getAuthProvider();
if (authProvider === 'stack') {
const stackModule = await import('@stackframe/stack');
const { StackServerApp } = stackModule;
@ -38,7 +39,7 @@ async function getStackServerApp(): Promise<StackServerApp<boolean, string> | nu
* Returns CurrentUser for stack, LocalUser for OSS, or null if not authenticated
*/
export async function getServerUser(): Promise<CurrentUser | LocalUser | null> {
const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack';
const authProvider = await getAuthProvider();
if (authProvider === 'stack') {
const app = await getStackServerApp();
@ -60,31 +61,12 @@ export async function getServerUser(): Promise<CurrentUser | LocalUser | null> {
return null;
}
/**
* Check if user is authenticated on the server side
* For local provider, always returns true in development
*/
export async function isServerAuthenticated(): Promise<boolean> {
const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack';
if (authProvider === 'stack') {
const user = await getServerUser();
return !!user;
}
// For local provider, consider authenticated in development
if (authProvider === 'local') {
return process.env.NODE_ENV === 'development';
}
return false;
}
/**
* Get provider name for server-side rendering
*/
export function getServerAuthProvider(): string {
return process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack';
export async function getServerAuthProvider(): Promise<string> {
return getAuthProvider();
}
/**
@ -105,14 +87,22 @@ export async function getOSSUser(): Promise<LocalUser | null> {
if (userCookie) {
try {
return JSON.parse(userCookie);
const parsed = JSON.parse(userCookie);
// Handle both legacy format and new JWT format
return {
id: String(parsed.id),
name: parsed.name || parsed.email || 'Local User',
email: parsed.email,
provider: 'local',
organizationId: parsed.organizationId || (parsed.organization_id ? String(parsed.organization_id) : undefined),
};
} catch (error) {
logger.error('Error listing permissions:', error);
logger.error('Error parsing user cookie:', error);
return null;
}
}
// If no user cookie, but we have a token, create user
// If no user cookie, but we have a token, create user from token
const token = cookieStore.get(OSS_TOKEN_COOKIE)?.value;
if (token) {
const user: LocalUser = {
@ -131,7 +121,7 @@ export async function getOSSUser(): Promise<LocalUser | null> {
* Get access token for API calls
*/
export async function getServerAccessToken(): Promise<string | null> {
const authProvider = getServerAuthProvider();
const authProvider = await getServerAuthProvider();
if (authProvider === 'stack') {
const user = await getServerUser();

View file

@ -104,12 +104,6 @@ export async function getRedirectUrl(token: string, permissions: { id: string }[
}
}
/**
* Check if the application is running in OSS (Open Source Software) mode
*/
export function isOSSMode(): boolean {
return process.env.NEXT_PUBLIC_DEPLOYMENT_MODE === 'oss';
}
/**
* --------------------------------------------------------------------------

View file

@ -1,53 +1,57 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
const OSS_TOKEN_COOKIE = 'dograh_oss_token';
const OSS_USER_COOKIE = 'dograh_oss_user';
const OSS_TOKEN_COOKIE = 'dograh_auth_token';
function generateOSSToken(): string {
return `oss_${Date.now()}_${crypto.randomUUID()}`;
// Paths that don't require authentication in OSS mode
const PUBLIC_PATHS = ['/auth/login', '/auth/signup'];
let cachedAuthProvider: string | null = null;
async function fetchAuthProvider(): Promise<string> {
if (cachedAuthProvider) {
return cachedAuthProvider;
}
try {
const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000';
const res = await fetch(`${backendUrl}/api/v1/health`);
if (res.ok) {
const data = await res.json();
cachedAuthProvider = (data.auth_provider as string) || 'local';
return cachedAuthProvider;
}
} catch {
// Backend not reachable — fall back to local
}
cachedAuthProvider = 'local';
return cachedAuthProvider;
}
export function middleware(request: NextRequest) {
const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack';
export async function middleware(request: NextRequest) {
const authProvider = await fetchAuthProvider();
// Only handle OSS mode
if (authProvider !== 'local') {
return NextResponse.next();
}
const response = NextResponse.next();
const token = request.cookies.get(OSS_TOKEN_COOKIE)?.value;
const { pathname } = request.nextUrl;
// If no token exists, create one
if (!token) {
const newToken = generateOSSToken();
const user = {
id: newToken,
name: 'Local User',
provider: 'local',
organizationId: `org_${newToken}`,
};
// Set cookies in the response (httpOnly for security)
response.cookies.set(OSS_TOKEN_COOKIE, newToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30, // 30 days
path: '/',
});
response.cookies.set(OSS_USER_COOKIE, JSON.stringify(user), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30, // 30 days
path: '/',
});
// Allow public paths without auth
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
return NextResponse.next();
}
return response;
// If no token, redirect to login
if (!token) {
const loginUrl = new URL('/auth/login', request.url);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
// Configure which routes the middleware runs on

View file

@ -1,21 +0,0 @@
import "server-only";
import { StackServerApp } from "@stackframe/stack";
const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER;
function createStackApp() {
if (authProvider === "local") {
// Return a dummy object when using local auth to prevent build errors
return {} as StackServerApp;
}
// Only initialize Stack Auth when actually using it
return new StackServerApp({
tokenStore: "nextjs-cookie",
urls: {
afterSignIn: "/after-sign-in"
}
});
}
export const stackServerApp = createStackApp();