changes: - Implemented Authetik as IDP

- middleware updated for route handling
- Auth js for authentication with credentials and authentil provider
This commit is contained in:
2025-10-17 08:17:13 -06:00
parent cc8d26a1fa
commit 06216faf29
8 changed files with 279 additions and 137 deletions

View File

@@ -8,6 +8,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Auth.js Credentials Provider Integration**
- Integrated Auth.js (NextAuth.js v5) with Credentials provider for direct login with backend
- Users can now log in using username/password through Auth.js authentication
- Credentials provider calls ASP.NET Core `/api/TokenAuth/Authenticate` endpoint
- Seamless integration with existing OIDC providers (Authentik, etc.)
- Single authentication system supporting both credentials and external OIDC providers
- **Authentik SSO Button in Sign-In Page**
- Added "Sign in with Authentik" button to the main login page
- Users can choose between traditional credentials or SSO authentication
- Visual separator between credentials and OAuth login options
- Consistent design with shadcn-ui components
- Automatic redirect to callback URL after successful OAuth authentication
- **Auth.js (NextAuth.js v5) Integration for SSO**
- Configured Auth.js with OIDC provider support for external authentication
- Created `/testSSO` page for testing Single Sign-On with Authentik
- Implemented token exchange flow: Authentik OIDC → Backend JWT
- Added automatic token refresh when backend JWT expires
- **JWT Utilities**
- Created `src/lib/auth/jwt-decoder.ts` with JWT parsing and validation functions
- Created `src/lib/auth/token-exchange.ts` for external token exchange with backend
- Functions: `decodeJwt()`, `isTokenExpired()`, `getTokenTtl()`, `formatTokenTtl()`
- User info extraction from JWT tokens
- **Test SSO Components**
- Created `AuthStatusCard` component to display current authentication state
- Created `TokenViewer` component to inspect and decode JWT tokens with accordion UI
- Created `AuthentikButton` component for sign-in/sign-out with Authentik
- All components use shadcn-ui for consistent styling
- **External Authentication Support**
- Extended `AuthContext` with `loginWithExternal()` method
- Support for both traditional (username/password) and OIDC authentication
- Unified token management for both authentication methods
- **Auth.js Configuration**
- Created `src/auth.ts` with Auth.js configuration and callbacks
- Created `src/app/api/auth/[...nextauth]/route.ts` for Auth.js API routes
- JWT callback performs token exchange with backend on initial sign-in
- Session callback exposes backend JWT to client
- Automatic token refresh before expiration (5-minute threshold)
- **Provider Configuration UI**
- Created `ProviderConfigCheck` component to display backend provider status
- Automatically fetches enabled providers from backend API
- Shows configuration issues and setup instructions in real-time
- Provides SQL script helper values for easy setup
- **Backend Integration**
- Created `providers.ts` helper to fetch provider config from backend
- Function `getEnabledProviders()` calls `ExternalAuthProviderAppService`
- Dynamic provider loading from database
- **Documentation and Setup**
- Updated `env.example.txt` with Auth.js and Authentik configuration variables
- Added instructions for generating AUTH_SECRET
- Documented Authentik OIDC provider setup steps
- Created `setup-authentik-provider.sql` script for easy database setup
- Added comprehensive troubleshooting guide
- Explained hybrid approach (backend config + env vars)
- **TypeScript Type Generation from OpenAPI**
- Added `@hey-api/openapi-ts` for automatic type generation from backend Swagger spec
- Created `openapi-ts.config.ts` configuration using local swagger.json file (avoids SSL certificate issues)
@@ -52,11 +105,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Removed Clerk references from `.gitignore`
### Fixed
- **Fixed Middleware Authentication Protection (Critical Security Fix)**
- Fixed `authorized` callback in `auth.ts` that was allowing unauthenticated access by default
- Changed default behavior from `return true` (allow all) to `return isLoggedIn` (require auth)
- Middleware now properly requires authentication for ALL routes by default (except public routes)
- Added explicit list of public routes that don't require authentication
- Root path (`/`) now redirects to dashboard if authenticated, or sign-in if not
- Authenticated users trying to access auth pages are redirected to dashboard
- All other routes now properly require authentication before access
- **IMPORTANT**: Restart dev server after this change for it to take effect
- Fixed import path for generated API client (use `@/client/client.gen` instead of `@/client`)
- Resolved "Export client doesn't exist" error in auth-context.tsx
- Fixed SSL certificate issues when generating types from localhost by downloading swagger.json first
### Changed
- **Migrated to Auth.js for All Authentication**
- Updated `custom-sign-in-form.tsx` to use `signIn()` from `next-auth/react` instead of custom login
- Updated `auth-context.tsx` to use `useSession()` hook from Auth.js
- Updated `middleware.ts` to use Auth.js middleware for route protection
- Updated `lib/api/client.ts` to retrieve tokens from Auth.js session
- Modified `auth.ts` to support both Credentials and OIDC authentication methods
- JWT callback handles both credential-based and OIDC-based authentication flows
- Session callback exposes tokens (accessToken, encryptedAccessToken) to client
- Changed login page redirect from `/testSSO` to `/auth/sign-in`
- **Complete Authentication System Replacement**
- Replaced Clerk authentication with custom JWT-based authentication system
- Created `AuthProvider` and `useAuth` hook to manage authentication state

View File

@@ -56,10 +56,55 @@ SENTRY_AUTH_TOKEN= #Example: sntrys_************************************
NEXT_PUBLIC_SENTRY_DISABLED= "false"
# =================================================================
# ASP.NET Core Backend API Configuration
# =================================================================
# Backend API base URL for API calls
NEXT_PUBLIC_API_URL=https://localhost:44313
# =================================================================
# Auth.js (NextAuth.js v5) Configuration
# =================================================================
# Required for both Credentials and OIDC authentication
# Auth Secret: Generate with: openssl rand -base64 32
# Run this command in your terminal: openssl rand -base64 32
# Or use: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
AUTH_SECRET=
# NextAuth URL (your frontend URL)
NEXTAUTH_URL=http://localhost:3000
# =================================================================
# Authentik OIDC Provider Configuration
# =================================================================
# To set up Authentik:
# 1. Create an OIDC Provider in Authentik
# 2. Create an Application linked to the provider
# 3. Copy the following values from Authentik
# Client ID (from Provider settings)
AUTH_AUTHENTIK_ID=
# Client Secret (from Provider settings)
AUTH_AUTHENTIK_SECRET=
# Issuer URL (format: https://your-authentik-domain/application/o/your-app-slug/)
# Example: https://auth.example.com/application/o/aspbase-oidc/
AUTH_AUTHENTIK_ISSUER=
# Redirect URI to configure in Authentik:
# http://localhost:3000/api/auth/callback/authentik
# =================================================================
# Important Notes:
# =================================================================
# 1. Rename this file to '.env' for local development
# 2. Never commit the actual '.env' file to version control
# 1. Rename this file to '.env.local' for local development
# 2. Never commit the actual '.env.local' file to version control
# 3. Make sure to replace all placeholder values with real ones
# 4. Keep your secret keys private and never share them
# 5. For production, set these as environment variables in your hosting platform

View File

@@ -1,5 +1,6 @@
'use client';
import React from 'react';
import { SessionProvider } from 'next-auth/react';
import { AuthProvider } from '@/contexts/auth-context';
import { ActiveThemeProvider } from '../active-theme';
@@ -12,9 +13,11 @@ export default function Providers({
}) {
return (
<>
<ActiveThemeProvider initialTheme={activeThemeValue}>
<AuthProvider>{children}</AuthProvider>
</ActiveThemeProvider>
<SessionProvider>
<ActiveThemeProvider initialTheme={activeThemeValue}>
<AuthProvider>{children}</AuthProvider>
</ActiveThemeProvider>
</SessionProvider>
</>
);
}

View File

@@ -1,23 +1,11 @@
'use client';
import { client } from '@/lib/api/client';
import {
clearAuthToken,
getAuthToken,
getUserInfo,
isAuthenticated,
setAuthTokens,
setUserInfo,
type AuthTokens,
type UserInfo
} from '@/lib/auth';
import { useSession, signOut as nextAuthSignOut } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
type ReactNode
} from 'react';
@@ -36,118 +24,44 @@ interface AuthContextType {
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
login: (
userNameOrEmailAddress: string,
password: string
) => Promise<void>;
logout: () => void;
refreshUser: () => Promise<void>;
accessToken: string | null;
encryptedAccessToken: string | null;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const { data: session, status } = useSession();
const router = useRouter();
// Load user from localStorage on mount
useEffect(() => {
const loadUser = () => {
if (isAuthenticated()) {
const userInfo = getUserInfo();
if (userInfo) {
setUser(mapUserInfoToUser(userInfo));
}
const user: User | null = session?.user
? {
id: session.user.id,
userName: session.user.userName || session.user.email || 'Unknown',
name: session.user.name?.split(' ')[0],
surname: session.user.name?.split(' ').slice(1).join(' '),
emailAddress: session.user.email || undefined,
fullName: session.user.name || session.user.userName || 'Unknown User',
imageUrl: session.user.image || undefined,
emailAddresses: session.user.email
? [{ emailAddress: session.user.email }]
: []
}
setIsLoading(false);
};
: null;
loadUser();
}, []);
const mapUserInfoToUser = (userInfo: UserInfo): User => {
const fullName = [userInfo.name, userInfo.surname]
.filter(Boolean)
.join(' ');
return {
id: userInfo.id,
userName: userInfo.userName,
name: userInfo.name,
surname: userInfo.surname,
emailAddress: userInfo.emailAddress,
fullName: fullName || userInfo.userName,
imageUrl: undefined, // Could be fetched from backend if available
emailAddresses: userInfo.emailAddress
? [{ emailAddress: userInfo.emailAddress }]
: []
};
};
const login = useCallback(
async (userNameOrEmailAddress: string, password: string) => {
try {
const response = await client.POST('/api/TokenAuth/Authenticate', {
body: {
userNameOrEmailAddress,
password,
rememberClient: false
}
});
if (response.data) {
// Save tokens
setAuthTokens({
accessToken: response.data.accessToken || '',
encryptedAccessToken: response.data.encryptedAccessToken || '',
expireInSeconds: response.data.expireInSeconds || 0
});
// Save user info
const userInfo: UserInfo = {
id: response.data.userId || 0,
userName: userNameOrEmailAddress,
emailAddress: undefined, // Will need to fetch from user profile endpoint
name: undefined,
surname: undefined
};
setUserInfo(userInfo);
setUser(mapUserInfoToUser(userInfo));
// Redirect to dashboard
router.push('/dashboard/overview');
}
} catch (error) {
console.error('Login failed:', error);
throw error;
}
},
[router]
);
const logout = useCallback(() => {
clearAuthToken();
setUser(null);
const logout = useCallback(async () => {
await nextAuthSignOut({ redirect: false });
router.push('/auth/sign-in');
}, [router]);
const refreshUser = useCallback(async () => {
// Optionally fetch updated user info from backend
const userInfo = getUserInfo();
if (userInfo) {
setUser(mapUserInfoToUser(userInfo));
}
}, []);
const value: AuthContextType = {
user,
isLoading,
isAuthenticated: !!user,
login,
isLoading: status === 'loading',
isAuthenticated: !!session,
logout,
refreshUser
accessToken: session?.accessToken || session?.user.backendJwt || null,
encryptedAccessToken: session?.encryptedAccessToken || null
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;

View File

@@ -10,13 +10,20 @@ import {
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useAuth } from '@/contexts/auth-context';
import { Separator } from '@/components/ui/separator';
import { signIn } from 'next-auth/react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'sonner';
import { IconBrandAuth0 } from '@tabler/icons-react';
export function CustomSignInForm() {
const { login } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get('callbackUrl') || '/dashboard/overview';
const [isLoading, setIsLoading] = useState(false);
const [isOAuthLoading, setIsOAuthLoading] = useState(false);
const [formData, setFormData] = useState({
userNameOrEmailAddress: '',
password: ''
@@ -27,16 +34,41 @@ export function CustomSignInForm() {
setIsLoading(true);
try {
await login(formData.userNameOrEmailAddress, formData.password);
toast.success('Successfully signed in!');
const result = await signIn('credentials', {
userNameOrEmailAddress: formData.userNameOrEmailAddress,
password: formData.password,
redirect: false
});
if (result?.error) {
toast.error('Invalid credentials. Please check your username and password.');
console.error('Login error:', result.error);
} else if (result?.ok) {
toast.success('Successfully signed in!');
router.push(callbackUrl);
router.refresh();
}
} catch (error: any) {
console.error('Login error:', error);
toast.error(error.message || 'Failed to sign in. Please check your credentials.');
toast.error(error.message || 'An unexpected error occurred. Please try again.');
} finally {
setIsLoading(false);
}
};
const handleAuthentikSignIn = async () => {
setIsOAuthLoading(true);
try {
await signIn('authentik', {
callbackUrl: callbackUrl
});
} catch (error: any) {
console.error('Authentik sign-in error:', error);
toast.error('Failed to sign in with Authentik. Please try again.');
setIsOAuthLoading(false);
}
};
return (
<Card className='w-full max-w-md'>
<CardHeader className='space-y-1'>
@@ -88,11 +120,31 @@ export function CustomSignInForm() {
/>
</div>
<Button type='submit' className='w-full' disabled={isLoading}>
<Button type='submit' className='w-full' disabled={isLoading || isOAuthLoading}>
{isLoading ? 'Signing in...' : 'Sign In'}
</Button>
</form>
<div className='relative my-6'>
<Separator />
<div className='absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-background px-2'>
<span className='text-xs text-muted-foreground uppercase'>
Or continue with
</span>
</div>
</div>
<Button
type='button'
variant='outline'
className='w-full'
disabled={isLoading || isOAuthLoading}
onClick={handleAuthentikSignIn}
>
<IconBrandAuth0 className='mr-2 h-5 w-5' />
{isOAuthLoading ? 'Connecting...' : 'Sign in with Authentik'}
</Button>
<div className='mt-4 text-center text-sm'>
Don&apos;t have an account?{' '}
<a

View File

@@ -12,6 +12,7 @@
import { client } from '@/client/client.gen';
import { getAuthToken, getTenantId } from '@/lib/auth';
import { getSession } from 'next-auth/react';
/**
* ASP.NET Boilerplate response wrapper
@@ -43,9 +44,26 @@ client.setConfig({
/**
* Request interceptor: Add authentication and tenant headers
*/
client.interceptors.request.use((request) => {
client.interceptors.request.use(async (request) => {
// Try to get token from Auth.js session first (client-side)
let token: string | null = null;
if (typeof window !== 'undefined') {
try {
const session = await getSession();
token = session?.accessToken || session?.user?.backendJwt || null;
} catch (error) {
// Fallback to localStorage if session not available
console.warn('Failed to get session, falling back to localStorage:', error);
}
}
// Fallback to localStorage (legacy support)
if (!token) {
token = getAuthToken();
}
// Add JWT token
const token = getAuthToken();
if (token) {
request.headers.set('Authorization', `Bearer ${token}`);
}

View File

@@ -32,3 +32,21 @@ export function absoluteUrl(path: string) {
? `http://localhost:3000${path}`
: `https://${config.appUrl}${path}`;
}
export function formatBytes(
bytes: number,
opts: {
decimals?: number;
sizeType?: "accurate" | "normal";
} = {}
) {
const { decimals = 0, sizeType = "normal" } = opts;
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
const accurateSizes = ["Bytes", "KiB", "MiB", "GiB", "TiB"];
if (bytes === 0) return "0 Byte";
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(decimals)} ${
sizeType === "accurate" ? accurateSizes[i] ?? "Bytes" : sizes[i] ?? "Bytes"
}`;
}

View File

@@ -1,28 +1,49 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
export default auth((req) => {
const { pathname } = req.nextUrl;
const isLoggedIn = !!req.auth;
// Public routes that don't require authentication
const publicRoutes = ['/auth/sign-in', '/auth/sign-up', '/auth/forgot-password'];
const publicRoutes = [
'/auth/sign-in',
'/auth/sign-up',
'/auth/forgot-password',
'/testSSO',
'/api/auth', // Auth.js API routes
];
// Check if current path is a public route
const isPublicRoute = publicRoutes.some((route) => pathname.startsWith(route));
// Get token from cookie or check localStorage (client-side only)
// Note: In middleware, we can only check cookies, not localStorage
const token = request.cookies.get('accessToken')?.value;
// If trying to access protected route without token, redirect to sign-in
if (!isPublicRoute && !token && pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/auth/sign-in', request.url));
// Root path - redirect based on auth status
if (pathname === '/') {
if (isLoggedIn) {
return NextResponse.redirect(new URL('/dashboard/overview', req.url));
}
return NextResponse.redirect(new URL('/auth/sign-in', req.url));
}
// If authenticated and trying to access auth pages, redirect to dashboard
if (isPublicRoute && token) {
return NextResponse.redirect(new URL('/dashboard/overview', request.url));
// Allow access to public routes
if (isPublicRoute) {
// If already logged in and trying to access auth pages, redirect to dashboard
if (isLoggedIn && pathname.startsWith('/auth/')) {
return NextResponse.redirect(new URL('/dashboard/overview', req.url));
}
return NextResponse.next();
}
// For all other routes (including dashboard), require authentication
if (!isLoggedIn) {
const signInUrl = new URL('/auth/sign-in', req.url);
signInUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(signInUrl);
}
// User is authenticated, allow access
return NextResponse.next();
}
});
export const config = {
matcher: [