mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-16 08:25:18 +02:00
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:
parent
0791975864
commit
642cc34e8c
48 changed files with 994 additions and 303 deletions
|
|
@ -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
30
ui/src/lib/auth/config.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue