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 swr

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)

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

  1. NEXTAUTH_URL Mismatch

    • Ensure NEXTAUTH_URL matches your deployment URL
    • Include protocol (http/https) in the URL
    • Check for trailing slashes
  2. Session Not Persisting

    • Verify NEXTAUTH_SECRET is set and consistent
    • Check cookie settings and domain configuration
    • Ensure secure cookies are used in production
  3. 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 }
  );
}

Next Steps