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,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';
}
/**
* --------------------------------------------------------------------------