Next.js Integration
Next.js Integration
This guide covers how to integrate Skycloak authentication into Next.js applications using NextAuth.js and other modern authentication patterns.
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
1. Install Dependencies
npm install next-auth keycloak-js @auth/core
# or for custom implementation
npm install jose axios swr2. 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-app3. 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)
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
// 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
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
// 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
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
// 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
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
// 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
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
// 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
// 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
// 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
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: '[email protected]',
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
// __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
// 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: '[email protected]',
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
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
// 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
// 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
// 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
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
// 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
// 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 }
);
}