Add OpenID Connect + API Key authentication

Backend:
- Cookie + OIDC + API Key authentication schemes
- ApiKeyAuthenticationHandler with SHA-256 validation and 24h cache
- AuthController with login/logout/profile endpoints
- API Key domain model (EventFlow aggregate, events, commands)
- ApiKeyReadModel and repository for key validation
- Database migration 002_ApiKeys.sql
- CORS configuration for frontend

Frontend:
- authService.ts for login/logout/profile API calls
- authStore.ts (Zustand) for user context state
- ProtectedRoute component for route guards
- Header updated with user display and logout
- GraphQL client with credentials: include

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Nicolaj Hartmann 2026-01-18 11:49:29 +01:00
parent c4a27f0bac
commit 926085eeab
28 changed files with 849 additions and 24 deletions

View file

@ -1,2 +1 @@
# Development environment defaults
VITE_GRAPHQL_ENDPOINT=http://localhost:5000/graphql
VITE_GRAPHQL_ENDPOINT=https://localhost:5001/graphql

View file

@ -2,14 +2,17 @@ import { BrowserRouter } from 'react-router-dom';
import { App as AntApp } from 'antd';
import AppRoutes from './routes';
import AppLayout from './components/layout/AppLayout';
import ProtectedRoute from './components/auth/ProtectedRoute';
function App() {
return (
<AntApp>
<BrowserRouter>
<AppLayout>
<AppRoutes />
</AppLayout>
<ProtectedRoute>
<AppLayout>
<AppRoutes />
</AppLayout>
</ProtectedRoute>
</BrowserRouter>
</AntApp>
);

View file

@ -4,22 +4,11 @@ import { QueryClient } from '@tanstack/react-query';
// GraphQL endpoint - configure based on environment
const GRAPHQL_ENDPOINT = import.meta.env.VITE_GRAPHQL_ENDPOINT || 'http://localhost:5000/graphql';
// Create GraphQL client
// Create GraphQL client with cookie-based authentication
export const graphqlClient = new GraphQLClient(GRAPHQL_ENDPOINT, {
headers: {
// Add auth headers here when authentication is implemented
// 'Authorization': `Bearer ${token}`,
},
credentials: 'include', // Send cookies with requests
});
// Configure headers dynamically (for auth tokens, etc.)
export const setAuthHeader = (token: string) => {
graphqlClient.setHeader('Authorization', `Bearer ${token}`);
};
export const removeAuthHeader = () => {
graphqlClient.setHeader('Authorization', '');
};
// Create TanStack Query client with default options
export const queryClient = new QueryClient({

View file

@ -0,0 +1,64 @@
import { useEffect, type ReactNode } from 'react';
import { Spin } from 'antd';
import { useAuthStore } from '@/stores/authStore';
interface ProtectedRouteProps {
children: ReactNode;
}
/**
* Wrapper component that ensures the user is authenticated
* Shows loading spinner while checking auth status
* Redirects to login if not authenticated
*/
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated, isLoading, login, refreshUser } = useAuthStore();
// Check auth on mount
useEffect(() => {
refreshUser();
}, [refreshUser]);
// Show loading spinner while checking auth
if (isLoading) {
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
flexDirection: 'column',
gap: 16,
}}
>
<Spin size="large" />
<span style={{ color: '#666' }}>Logger ind...</span>
</div>
);
}
// Redirect to login if not authenticated
if (!isAuthenticated) {
login();
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
flexDirection: 'column',
gap: 16,
}}
>
<Spin size="large" />
<span style={{ color: '#666' }}>Omdirigerer til login...</span>
</div>
);
}
return <>{children}</>;
}
export default ProtectedRoute;

View file

@ -9,11 +9,14 @@ import {
import type { MenuProps } from 'antd';
import CompanySwitcher from './CompanySwitcher';
import FiscalYearSelector from './FiscalYearSelector';
import { useAuthStore } from '@/stores/authStore';
const { Header: AntHeader } = Layout;
const { Text } = Typography;
export default function Header() {
const { user, logout } = useAuthStore();
const userMenuItems: MenuProps['items'] = [
{
key: 'profile',
@ -39,8 +42,7 @@ export default function Header() {
const handleUserMenuClick: MenuProps['onClick'] = ({ key }) => {
switch (key) {
case 'logout':
// Handle logout
console.log('Logout clicked');
logout();
break;
case 'settings':
// Navigate to settings
@ -103,7 +105,7 @@ export default function Header() {
style={{ backgroundColor: '#1677ff' }}
/>
<Text style={{ maxWidth: 120 }} ellipsis>
Bruger
{user?.name || user?.email || 'Bruger'}
</Text>
</Space>
</Dropdown>

View file

@ -0,0 +1,82 @@
// API URL - extract base from GraphQL endpoint
const API_URL = (import.meta.env.VITE_GRAPHQL_ENDPOINT || 'http://localhost:5000/graphql').replace('/graphql', '');
export interface UserContext {
id: string | null;
email: string | null;
name: string | null;
companyId: string | null;
isApiKey: boolean;
}
class AuthService {
private userContext: UserContext | null = null;
/**
* Fetch user profile from the backend
* Returns null if not authenticated
*/
async refreshUserContext(): Promise<UserContext | null> {
try {
const response = await fetch(`${API_URL}/api/profile`, {
credentials: 'include',
});
if (response.ok) {
this.userContext = await response.json();
return this.userContext;
}
// 401/403 means not authenticated
this.userContext = null;
return null;
} catch (error) {
console.error('Failed to fetch user profile:', error);
this.userContext = null;
return null;
}
}
/**
* Redirect to login page
* The backend will handle OIDC authentication and redirect back
*/
login(returnUrl?: string): void {
const url = returnUrl || window.location.href;
window.location.href = `${API_URL}/api/login?returnUrl=${encodeURIComponent(url)}`;
}
/**
* Logout and clear session
*/
async logout(): Promise<void> {
try {
await fetch(`${API_URL}/api/logout`, {
method: 'GET',
credentials: 'include',
});
} catch (error) {
console.error('Logout error:', error);
}
this.userContext = null;
window.location.reload();
}
/**
* Get the current user context
*/
get user(): UserContext | null {
return this.userContext;
}
/**
* Check if user is authenticated
*/
get isAuthenticated(): boolean {
return this.userContext !== null;
}
}
// Singleton instance
export const authService = new AuthService();

View file

@ -0,0 +1,61 @@
import { create } from 'zustand';
import { authService, type UserContext } from '@/services/authService';
interface AuthState {
// User context from backend
user: UserContext | null;
// Loading state for initial auth check
isLoading: boolean;
// Whether user is authenticated
isAuthenticated: boolean;
// Error message if auth failed
error: string | null;
// Actions
login: (returnUrl?: string) => void;
logout: () => Promise<void>;
refreshUser: () => Promise<void>;
clearError: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
isLoading: true,
isAuthenticated: false,
error: null,
login: (returnUrl?: string) => {
authService.login(returnUrl);
},
logout: async () => {
await authService.logout();
set({ user: null, isAuthenticated: false });
},
refreshUser: async () => {
set({ isLoading: true, error: null });
try {
const user = await authService.refreshUserContext();
set({
user,
isAuthenticated: user !== null,
isLoading: false,
});
} catch (error) {
set({
user: null,
isAuthenticated: false,
isLoading: false,
error: error instanceof Error ? error.message : 'Authentication failed',
});
}
},
clearError: () => set({ error: null }),
}));
// Selector hooks for convenience
export const useUser = () => useAuthStore((state) => state.user);
export const useIsAuthenticated = () => useAuthStore((state) => state.isAuthenticated);
export const useAuthLoading = () => useAuthStore((state) => state.isLoading);

View file

@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/layout/applayout.tsx","./src/components/layout/companyswitcher.tsx","./src/components/layout/fiscalyearselector.tsx","./src/components/layout/header.tsx","./src/components/layout/sidebar.tsx","./src/components/modals/closefiscalyearwizard.tsx","./src/components/modals/createfiscalyearmodal.tsx","./src/components/simple-booking/accountquickpicker.tsx","./src/components/simple-booking/banktransactioncard.tsx","./src/components/simple-booking/quickbookmodal.tsx","./src/components/simple-booking/splitbookmodal.tsx","./src/components/simple-booking/index.ts","./src/components/tables/datatable.tsx","./src/hooks/usecompany.ts","./src/hooks/usedatatable.ts","./src/hooks/useperiod.ts","./src/lib/accounting.ts","./src/lib/fiscalyear.ts","./src/lib/formatters.ts","./src/lib/periods.ts","./src/lib/vatcalculation.ts","./src/lib/vatcodes.ts","./src/pages/bankafstemning.tsx","./src/pages/dashboard.tsx","./src/pages/hurtigbogforing.tsx","./src/pages/kassekladde.tsx","./src/pages/kontooversigt.tsx","./src/pages/loenforstaelse.tsx","./src/pages/momsindberetning.tsx","./src/pages/settings.tsx","./src/stores/companystore.ts","./src/stores/periodstore.ts","./src/stores/reconciliationstore.ts","./src/stores/simplebookingstore.ts","./src/stores/uistore.ts","./src/styles/theme.ts","./src/types/accounting.ts","./src/types/api.ts","./src/types/periods.ts","./src/types/ui.ts","./src/types/vat.ts"],"errors":true,"version":"5.6.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/auth/protectedroute.tsx","./src/components/layout/applayout.tsx","./src/components/layout/companyswitcher.tsx","./src/components/layout/fiscalyearselector.tsx","./src/components/layout/header.tsx","./src/components/layout/sidebar.tsx","./src/components/modals/closefiscalyearwizard.tsx","./src/components/modals/createfiscalyearmodal.tsx","./src/components/simple-booking/accountquickpicker.tsx","./src/components/simple-booking/banktransactioncard.tsx","./src/components/simple-booking/quickbookmodal.tsx","./src/components/simple-booking/splitbookmodal.tsx","./src/components/simple-booking/index.ts","./src/components/tables/datatable.tsx","./src/hooks/usecompany.ts","./src/hooks/usedatatable.ts","./src/hooks/useperiod.ts","./src/lib/accounting.ts","./src/lib/fiscalyear.ts","./src/lib/formatters.ts","./src/lib/periods.ts","./src/lib/vatcalculation.ts","./src/lib/vatcodes.ts","./src/pages/bankafstemning.tsx","./src/pages/dashboard.tsx","./src/pages/hurtigbogforing.tsx","./src/pages/kassekladde.tsx","./src/pages/kontooversigt.tsx","./src/pages/loenforstaelse.tsx","./src/pages/momsindberetning.tsx","./src/pages/settings.tsx","./src/services/authservice.ts","./src/stores/authstore.ts","./src/stores/companystore.ts","./src/stores/periodstore.ts","./src/stores/reconciliationstore.ts","./src/stores/simplebookingstore.ts","./src/stores/uistore.ts","./src/styles/theme.ts","./src/types/accounting.ts","./src/types/api.ts","./src/types/periods.ts","./src/types/ui.ts","./src/types/vat.ts"],"errors":true,"version":"5.6.3"}