Aller au contenu

Next.js Integration

Ce contenu n’est pas encore disponible dans votre langue.

This guide covers how to integrate Skycloak authentication into Next.js applications using NextAuth.js and other modern authentication patterns.

  • 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
npm install next-auth keycloak-js @auth/core
# or for custom implementation
npm install jose axios swr

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

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 };

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>
  );
}
// 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>;
}
// 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>
  );
}
// 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>
  );
}
// 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}</>;
}
// 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,
  };
}
// 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.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*'],
};
// 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
  );
}
// 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 };
}
// 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>
  );
}
// __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);
  });
});
// __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/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');
  });
});
// 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!,
  },
};
// 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(),
          },
        ],
      },
    ];
  },
};
// 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>
  );
}
// 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(),
          });
        }
      },
    },
  };
}
  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
// 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);
  }
}
// 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 }
  );
}