import { NextAuthOptions } from 'next-auth'; import KeycloakProvider from 'next-auth/providers/keycloak'; declare module 'next-auth' { interface User { id: string; email: string; name?: string | null; role: string[]; first_name: string; last_name: string; username: string; } interface Session { user: { id: string; email: string; name?: string | null; role: string[]; first_name: string; last_name: string; username: string; }; accessToken: string; refreshToken: string; } interface Profile { sub?: string; email?: string; name?: string; roles?: string[]; given_name?: string; family_name?: string; preferred_username?: string; } } declare module 'next-auth/jwt' { interface JWT { id: string; email: string; name?: string; role: string[]; first_name: string; last_name: string; username: string; accessToken: string; refreshToken: string; accessTokenExpires: number; error?: string; } } export const authOptions: NextAuthOptions = { providers: [ KeycloakProvider({ clientId: process.env.KEYCLOAK_CLIENT_ID!, clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!, issuer: process.env.KEYCLOAK_ISSUER, authorization: { params: { scope: 'openid email profile', response_type: 'code', } }, }), ], debug: false, session: { strategy: 'jwt', maxAge: 30 * 24 * 60 * 60, // 30 days }, pages: { signIn: '/signin', error: '/signin', }, callbacks: { async jwt({ token, account, profile }) { if (account && profile) { if (!profile.sub) { throw new Error('No user ID (sub) provided by Keycloak'); } if (!account.access_token || !account.refresh_token || !account.expires_at) { throw new Error('Missing required token fields from Keycloak'); } token.id = profile.sub; token.email = profile.email || ''; token.name = profile.name; token.role = profile.roles || ['user']; token.first_name = profile.given_name || ''; token.last_name = profile.family_name || ''; token.username = profile.preferred_username || ''; token.accessToken = account.access_token; token.refreshToken = account.refresh_token; token.accessTokenExpires = account.expires_at * 1000; } // Return previous token if not expired if (token.accessTokenExpires && Date.now() < token.accessTokenExpires) { return token; } // Token expired, try to refresh if (!token.refreshToken) { throw new Error('No refresh token available'); } try { const response = await fetch( `${process.env.KEYCLOAK_BASE_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ grant_type: 'refresh_token', client_id: process.env.KEYCLOAK_CLIENT_ID!, client_secret: process.env.KEYCLOAK_CLIENT_SECRET!, refresh_token: token.refreshToken, }), } ); const tokens = await response.json(); if (!response.ok) { throw new Error('RefreshAccessTokenError'); } return { ...token, accessToken: tokens.access_token, refreshToken: tokens.refresh_token ?? token.refreshToken, accessTokenExpires: Date.now() + tokens.expires_in * 1000, }; } catch (error) { return { ...token, error: 'RefreshAccessTokenError', }; } }, async session({ session, token }) { if (token.error) { throw new Error('RefreshAccessTokenError'); } session.user = { id: token.sub ?? token.id ?? '', email: token.email ?? '', name: token.name ?? '', role: token.role ?? ['user'], first_name: token.first_name ?? '', last_name: token.last_name ?? '', username: token.username ?? '' }; return session; }, }, };