Next.js Integration
Dieser Inhalt ist noch nicht in deiner Sprache verfügbar.
This guide covers how to integrate Skycloak authentication into Next.js applications using NextAuth.js and other modern authentication patterns.
Prerequisites
Section titled “Prerequisites”- Next.js 13+ application (App Router or Pages Router)
- Node.js 16+
- Skycloak cluster with configured realm and client
- Basic understanding of Next.js and React
Quick Start
Section titled “Quick Start”1. Install Dependencies
Section titled “1. Install Dependencies”npm install next-auth keycloak-js @auth/core
# or for custom implementation
npm install jose axios swr
2. Configure Environment Variables
Section titled “2. Configure Environment Variables”Create .env.local:
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=your-generated-secret
KEYCLOAK_ID=your-nextjs-app
KEYCLOAK_SECRET=your-client-secret
KEYCLOAK_ISSUER=https://your-cluster-id.app.skycloak.io/realms/your-realm
NEXT_PUBLIC_KEYCLOAK_URL=https://your-cluster-id.app.skycloak.io
NEXT_PUBLIC_KEYCLOAK_REALM=your-realm
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=your-nextjs-app
3. Configure NextAuth.js (App Router)
Section titled “3. Configure NextAuth.js (App Router)”Create app/api/auth/[...nextauth]/route.ts:
import NextAuth, { NextAuthOptions } from 'next-auth';
import KeycloakProvider from 'next-auth/providers/keycloak';
import { JWT } from 'next-auth/jwt';
// Refresh token rotation
async function refreshAccessToken(token: JWT) {
try {
const url = `${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`;
const response = await fetch(url, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
method: 'POST',
body: new URLSearchParams({
client_id: process.env.KEYCLOAK_ID!,
client_secret: process.env.KEYCLOAK_SECRET!,
grant_type: 'refresh_token',
refresh_token: token.refreshToken as string,
}),
});
const refreshedTokens = await response.json();
if (!response.ok) {
throw refreshedTokens;
}
return {
...token,
accessToken: refreshedTokens.access_token,
accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000,
refreshToken: refreshedTokens.refresh_token ?? token.refreshToken,
idToken: refreshedTokens.id_token,
};
} catch (error) {
console.error('RefreshAccessTokenError', error);
return {
...token,
error: 'RefreshAccessTokenError',
};
}
}
export const authOptions: NextAuthOptions = {
providers: [
KeycloakProvider({
clientId: process.env.KEYCLOAK_ID!,
clientSecret: process.env.KEYCLOAK_SECRET!,
issuer: process.env.KEYCLOAK_ISSUER,
}),
],
callbacks: {
async jwt({ token, account, profile }) {
// Initial sign in
if (account && profile) {
return {
accessToken: account.access_token,
accessTokenExpires: account.expires_at! * 1000,
refreshToken: account.refresh_token,
idToken: account.id_token,
user: {
id: profile.sub,
name: profile.name,
email: profile.email,
username: profile.preferred_username,
roles: {
realm: profile.realm_access?.roles || [],
client: profile.resource_access?.[process.env.KEYCLOAK_ID!]?.roles || [],
},
groups: profile.groups || [],
},
};
}
// Return previous token if the access token has not expired yet
if (Date.now() < (token.accessTokenExpires as number)) {
return token;
}
// Access token has expired, try to update it
return refreshAccessToken(token);
},
async session({ session, token }) {
if (token) {
session.user = token.user as any;
session.accessToken = token.accessToken;
session.error = token.error;
}
return session;
},
},
events: {
async signOut({ token }) {
// Logout from Keycloak
const logoutUrl = `${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/logout?id_token_hint=${token.idToken}`;
try {
await fetch(logoutUrl);
} catch (error) {
console.error('Keycloak logout error:', error);
}
},
},
pages: {
signIn: '/auth/signin',
signOut: '/auth/signout',
error: '/auth/error',
},
};
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
4. Create Auth Provider (App Router)
Section titled “4. Create Auth Provider (App Router)”Create app/providers.tsx:
'use client';
import { SessionProvider } from 'next-auth/react';
import { ReactNode } from 'react';
interface ProvidersProps {
children: ReactNode;
}
export function Providers({ children }: ProvidersProps) {
return <SessionProvider refetchInterval={5 * 60}>{children}</SessionProvider>;
}
Update app/layout.tsx:
import { Providers } from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
5. Create Authentication Components
Section titled “5. Create Authentication Components”// components/auth/LoginButton.tsx
'use client';
import { signIn, signOut, useSession } from 'next-auth/react';
import { Button } from '@/components/ui/button';
export function LoginButton() {
const { data: session, status } = useSession();
if (status === 'loading') {
return <Button disabled>Loading...</Button>;
}
if (session) {
return (
<div className="flex items-center gap-4">
<span>Welcome, {session.user?.name}!</span>
<Button onClick={() => signOut()}>Sign Out</Button>
</div>
);
}
return <Button onClick={() => signIn('keycloak')}>Sign In with Skycloak</Button>;
}
Server Components Authentication
Section titled “Server Components Authentication”Protected Page (App Router)
Section titled “Protected Page (App Router)”// app/dashboard/page.tsx
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { redirect } from 'next/navigation';
export default async function DashboardPage() {
const session = await getServerSession(authOptions);
if (!session) {
redirect('/auth/signin');
}
return (
<div>
<h1>Dashboard</h1>
<p>Welcome, {session.user.name}!</p>
<pre>{JSON.stringify(session.user, null, 2)}</pre>
</div>
);
}
Role-Based Server Component
Section titled “Role-Based Server Component”// app/admin/page.tsx
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { redirect } from 'next/navigation';
export default async function AdminPage() {
const session = await getServerSession(authOptions);
if (!session) {
redirect('/auth/signin');
}
const hasAdminRole = session.user.roles.realm.includes('admin');
if (!hasAdminRole) {
redirect('/unauthorized');
}
return (
<div>
<h1>Admin Panel</h1>
<p>Only admins can see this page</p>
</div>
);
}
Client Components Authentication
Section titled “Client Components Authentication”Protected Client Component
Section titled “Protected Client Component”// components/ProtectedComponent.tsx
'use client';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
interface ProtectedComponentProps {
children: React.ReactNode;
requiredRoles?: string[];
fallback?: React.ReactNode;
}
export function ProtectedComponent({
children,
requiredRoles = [],
fallback,
}: ProtectedComponentProps) {
const { data: session, status } = useSession();
const router = useRouter();
useEffect(() => {
if (status === 'loading') return;
if (!session) {
router.push('/auth/signin');
}
}, [session, status, router]);
if (status === 'loading') {
return <div>Loading...</div>;
}
if (!session) {
return null;
}
if (requiredRoles.length > 0) {
const userRoles = session.user.roles.realm;
const hasRequiredRole = requiredRoles.some((role) => userRoles.includes(role));
if (!hasRequiredRole) {
return fallback || <div>Access Denied</div>;
}
}
return <>{children}</>;
}
Custom Auth Hook
Section titled “Custom Auth Hook”// hooks/useAuth.ts
import { useSession } from 'next-auth/react';
import { useCallback } from 'react';
export function useAuth() {
const { data: session, status } = useSession();
const hasRole = useCallback(
(role: string) => {
if (!session?.user) return false;
return session.user.roles.realm.includes(role);
},
[session]
);
const hasAnyRole = useCallback(
(...roles: string[]) => {
if (!session?.user) return false;
return roles.some((role) => session.user.roles.realm.includes(role));
},
[session]
);
const hasAllRoles = useCallback(
(...roles: string[]) => {
if (!session?.user) return false;
return roles.every((role) => session.user.roles.realm.includes(role));
},
[session]
);
const hasClientRole = useCallback(
(clientId: string, role: string) => {
if (!session?.user) return false;
const clientRoles = session.user.roles.client[clientId] || [];
return clientRoles.includes(role);
},
[session]
);
return {
user: session?.user,
isAuthenticated: !!session,
isLoading: status === 'loading',
hasRole,
hasAnyRole,
hasAllRoles,
hasClientRole,
accessToken: session?.accessToken,
};
}
API Routes Protection
Section titled “API Routes Protection”Protected API Route (App Router)
Section titled “Protected API Route (App Router)”// app/api/users/route.ts
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { NextResponse } from 'next/server';
export async function GET() {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Check for specific role
if (!session.user.roles.realm.includes('admin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Fetch data using the access token
const response = await fetch('https://api.example.com/users', {
headers: {
Authorization: `Bearer ${session.accessToken}`,
},
});
const data = await response.json();
return NextResponse.json(data);
}
Middleware Protection
Section titled “Middleware Protection”// middleware.ts
import { withAuth } from 'next-auth/middleware';
import { NextResponse } from 'next/server';
export default withAuth(
function middleware(req) {
const token = req.nextauth.token;
const isAdmin = token?.user?.roles?.realm?.includes('admin');
// Check admin routes
if (req.nextUrl.pathname.startsWith('/admin') && !isAdmin) {
return NextResponse.redirect(new URL('/unauthorized', req.url));
}
return NextResponse.next();
},
{
callbacks: {
authorized: ({ token }) => !!token,
},
}
);
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*', '/api/protected/:path*'],
};
Advanced Features
Section titled “Advanced Features”Custom Token Management
Section titled “Custom Token Management”// lib/auth/token-manager.ts
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
export class TokenManager {
static async getAccessToken() {
const session = await getServerSession(authOptions);
if (!session?.accessToken) {
throw new Error('No access token available');
}
// Check if token needs refresh
if (session.error === 'RefreshAccessTokenError') {
throw new Error('Token refresh failed');
}
return session.accessToken;
}
static async makeAuthenticatedRequest(url: string, options: RequestInit = {}) {
const token = await this.getAccessToken();
return fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${token}`,
},
});
}
}
SWR Integration for Client-Side Data Fetching
Section titled “SWR Integration for Client-Side Data Fetching”// hooks/useAuthenticatedSWR.ts
import useSWR, { SWRConfiguration } from 'swr';
import { useSession } from 'next-auth/react';
const fetcher = async (url: string, token: string) => {
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error('Failed to fetch');
}
return response.json();
};
export function useAuthenticatedSWR<T>(url: string | null, config?: SWRConfiguration) {
const { data: session } = useSession();
return useSWR<T>(
session?.accessToken && url ? [url, session.accessToken] : null,
([url, token]) => fetcher(url, token),
config
);
}
Server Actions with Authentication
Section titled “Server Actions with Authentication”// app/actions/user-actions.ts
'use server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { revalidatePath } from 'next/cache';
export async function updateUserProfile(formData: FormData) {
const session = await getServerSession(authOptions);
if (!session) {
throw new Error('Unauthorized');
}
const response = await fetch('https://api.example.com/profile', {
method: 'PUT',
headers: {
Authorization: `Bearer ${session.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: formData.get('name'),
bio: formData.get('bio'),
}),
});
if (!response.ok) {
throw new Error('Failed to update profile');
}
revalidatePath('/profile');
return { success: true };
}
Role-Based Layouts
Section titled “Role-Based Layouts”// app/dashboard/layout.tsx
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { redirect } from 'next/navigation';
import { AdminSidebar } from '@/components/AdminSidebar';
import { UserSidebar } from '@/components/UserSidebar';
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const session = await getServerSession(authOptions);
if (!session) {
redirect('/auth/signin');
}
const isAdmin = session.user.roles.realm.includes('admin');
return (
<div className="flex">
{isAdmin ? <AdminSidebar /> : <UserSidebar />}
<main className="flex-1">{children}</main>
</div>
);
}
Testing
Section titled “Testing”Unit Tests
Section titled “Unit Tests”// __tests__/hooks/useAuth.test.ts
import { renderHook } from '@testing-library/react';
import { useSession } from 'next-auth/react';
import { useAuth } from '@/hooks/useAuth';
jest.mock('next-auth/react');
describe('useAuth', () => {
it('returns user data when authenticated', () => {
const mockSession = {
user: {
name: 'Test User',
email: 'test@example.com',
roles: {
realm: ['user', 'admin'],
client: {},
},
},
accessToken: 'mock-token',
};
(useSession as jest.Mock).mockReturnValue({
data: mockSession,
status: 'authenticated',
});
const { result } = renderHook(() => useAuth());
expect(result.current.isAuthenticated).toBe(true);
expect(result.current.user).toEqual(mockSession.user);
expect(result.current.hasRole('admin')).toBe(true);
expect(result.current.hasRole('guest')).toBe(false);
});
it('returns false for roles when not authenticated', () => {
(useSession as jest.Mock).mockReturnValue({
data: null,
status: 'unauthenticated',
});
const { result } = renderHook(() => useAuth());
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.hasRole('admin')).toBe(false);
});
});
Integration Tests
Section titled “Integration Tests”// __tests__/api/users.test.ts
import { GET } from '@/app/api/users/route';
import { getServerSession } from 'next-auth';
jest.mock('next-auth');
describe('/api/users', () => {
it('returns 401 when not authenticated', async () => {
(getServerSession as jest.Mock).mockResolvedValue(null);
const response = await GET();
const data = await response.json();
expect(response.status).toBe(401);
expect(data).toEqual({ error: 'Unauthorized' });
});
it('returns 403 when user lacks admin role', async () => {
(getServerSession as jest.Mock).mockResolvedValue({
user: {
roles: {
realm: ['user'],
},
},
});
const response = await GET();
const data = await response.json();
expect(response.status).toBe(403);
expect(data).toEqual({ error: 'Forbidden' });
});
it('returns data when user is admin', async () => {
(getServerSession as jest.Mock).mockResolvedValue({
user: {
roles: {
realm: ['admin'],
},
},
accessToken: 'mock-token',
});
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ users: [] }),
});
const response = await GET();
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({ users: [] });
});
});
E2E Tests
Section titled “E2E Tests”// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Authentication', () => {
test('redirects to login when accessing protected route', async ({ page }) => {
await page.goto('/dashboard');
// Should redirect to sign in page
await expect(page).toHaveURL('/auth/signin');
});
test('shows user info after login', async ({ page }) => {
// Mock authenticated session
await page.route('**/api/auth/session', async (route) => {
await route.fulfill({
json: {
user: {
name: 'Test User',
email: 'test@example.com',
roles: {
realm: ['user'],
client: {},
},
},
},
});
});
await page.goto('/');
await expect(page.getByText('Welcome, Test User!')).toBeVisible();
});
test('protects admin routes', async ({ page }) => {
// Mock non-admin session
await page.route('**/api/auth/session', async (route) => {
await route.fulfill({
json: {
user: {
name: 'Test User',
roles: {
realm: ['user'],
client: {},
},
},
},
});
});
await page.goto('/admin');
// Should redirect to unauthorized
await expect(page).toHaveURL('/unauthorized');
});
});
Production Considerations
Section titled “Production Considerations”Environment Configuration
Section titled “Environment Configuration”// lib/config/auth.ts
const isDevelopment = process.env.NODE_ENV === 'development';
export const authConfig = {
// Use secure cookies in production
useSecureCookies: !isDevelopment,
// Token refresh settings
tokenRefreshThreshold: 5 * 60 * 1000, // 5 minutes
// Session configuration
sessionMaxAge: 30 * 24 * 60 * 60, // 30 days
sessionUpdateAge: 24 * 60 * 60, // 24 hours
// Keycloak settings
keycloak: {
clientId: process.env.KEYCLOAK_ID!,
clientSecret: process.env.KEYCLOAK_SECRET!,
issuer: process.env.KEYCLOAK_ISSUER!,
},
};
Security Headers
Section titled “Security Headers”// next.config.js
module.exports = {
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'X-Frame-Options',
value: 'SAMEORIGIN',
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
{
key: 'Content-Security-Policy',
value: `
default-src 'self';
connect-src 'self' https://*.skycloak.io;
frame-src 'self' https://*.skycloak.io;
`
.replace(/\s+/g, ' ')
.trim(),
},
],
},
];
},
};
Performance Optimization
Section titled “Performance Optimization”// components/AuthProvider.tsx
'use client';
import { SessionProvider } from 'next-auth/react';
import { useEffect } from 'react';
export function AuthProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
// Prefetch auth endpoints
const endpoints = ['/api/auth/session', '/api/auth/csrf'];
endpoints.forEach((endpoint) => {
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = endpoint;
document.head.appendChild(link);
});
}, []);
return (
<SessionProvider refetchInterval={5 * 60} refetchOnWindowFocus={true}>
{children}
</SessionProvider>
);
}
Monitoring and Logging
Section titled “Monitoring and Logging”// lib/monitoring/auth-logger.ts
import { NextAuthOptions } from 'next-auth';
export function addAuthLogging(options: NextAuthOptions): NextAuthOptions {
return {
...options,
events: {
...options.events,
signIn: async (message) => {
console.log('User signed in:', {
user: message.user.email,
provider: message.account?.provider,
timestamp: new Date().toISOString(),
});
},
signOut: async (message) => {
console.log('User signed out:', {
userId: message.token?.sub,
timestamp: new Date().toISOString(),
});
},
createUser: async (message) => {
console.log('New user created:', {
user: message.user.email,
timestamp: new Date().toISOString(),
});
},
linkAccount: async (message) => {
console.log('Account linked:', {
user: message.user.email,
provider: message.account.provider,
timestamp: new Date().toISOString(),
});
},
session: async (message) => {
// Log session access (be careful with frequency)
if (Math.random() < 0.01) {
// 1% sampling
console.log('Session accessed:', {
userId: message.session.user?.id,
timestamp: new Date().toISOString(),
});
}
},
},
};
}
Troubleshooting
Section titled “Troubleshooting”Common Issues
Section titled “Common Issues”-
NEXTAUTH_URL Mismatch
- Ensure NEXTAUTH_URL matches your deployment URL
- Include protocol (http/https) in the URL
- Check for trailing slashes
-
Session Not Persisting
- Verify NEXTAUTH_SECRET is set and consistent
- Check cookie settings and domain configuration
- Ensure secure cookies are used in production
-
Token Refresh Failures
- Check refresh token expiration in Keycloak
- Verify client credentials are correct
- Monitor network requests for CORS issues
Debug Mode
Section titled “Debug Mode”// lib/debug/auth-debug.ts
export function enableAuthDebug() {
if (process.env.NODE_ENV === 'development') {
// Log all auth events
window.addEventListener('next-auth.session-event', (event) => {
console.log('Session event:', event);
});
// Monitor token expiration
setInterval(() => {
const session = window.__NEXTAUTH?.session;
if (session?.expires) {
const expiresIn = new Date(session.expires).getTime() - Date.now();
console.log(`Token expires in: ${Math.round(expiresIn / 1000)}s`);
}
}, 60000);
}
}
Health Check Endpoint
Section titled “Health Check Endpoint”// app/api/health/auth/route.ts
import { NextResponse } from 'next/server';
export async function GET() {
const checks = {
keycloak: false,
session: false,
};
try {
// Check Keycloak availability
const keycloakResponse = await fetch(
`${process.env.KEYCLOAK_ISSUER}/.well-known/openid-configuration`,
{ signal: AbortSignal.timeout(5000) }
);
checks.keycloak = keycloakResponse.ok;
} catch (error) {
console.error('Keycloak health check failed:', error);
}
// Check session store (if using database)
// checks.session = await checkSessionStore()
const isHealthy = Object.values(checks).every(Boolean);
return NextResponse.json(
{
status: isHealthy ? 'healthy' : 'unhealthy',
checks,
timestamp: new Date().toISOString(),
},
{ status: isHealthy ? 200 : 503 }
);
}