import NextAuth, { NextAuthOptions } from "next-auth"; import KeycloakProvider from "next-auth/providers/keycloak"; import { jwtDecode } from "jwt-decode"; interface KeycloakProfile { sub: string; email?: string; name?: string; roles?: string[]; preferred_username?: string; given_name?: string; family_name?: string; realm_access?: { roles: string[]; }; } interface DecodedToken { realm_access?: { roles: string[]; }; [key: string]: any; } declare module "next-auth" { interface Session { user: { id: string; name?: string | null; email?: string | null; image?: string | null; username: string; first_name: string; last_name: string; role: string[]; nextcloudInitialized?: boolean; }; accessToken?: string; idToken?: string; } interface JWT { sub?: string; accessToken?: string; refreshToken?: string; idToken?: string; accessTokenExpires?: number; role?: string[]; username?: string; first_name?: string; last_name?: string; error?: string; email?: string | null; name?: string | null; } } function getRequiredEnvVar(name: string): string { const value = process.env[name]; if (!value) { throw new Error(`Missing required environment variable: ${name}`); } return value; } type ExtendedJWT = { accessToken?: string; refreshToken?: string; idToken?: string; accessTokenExpires?: number; sub?: string; role?: string[]; username?: string; first_name?: string; last_name?: string; email?: string | null; name?: string | null; error?: string; [key: string]: any; }; async function refreshAccessToken(token: ExtendedJWT) { try { const response = await fetch(`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`, { headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: process.env.KEYCLOAK_CLIENT_ID!, client_secret: process.env.KEYCLOAK_CLIENT_SECRET!, grant_type: "refresh_token", refresh_token: token.refreshToken || '', }), method: "POST", }); const refreshedTokens = await response.json(); if (!response.ok) { // Check if the error is due to invalid session (e.g., user logged out from iframe) if (refreshedTokens.error === 'invalid_grant' || refreshedTokens.error_description?.includes('Session not active') || refreshedTokens.error_description?.includes('Token is not active')) { console.log("Keycloak session invalidated (likely logged out from iframe), marking token for removal"); // Return token with specific error to trigger session invalidation return { ...token, error: "SessionNotActive", }; } throw refreshedTokens; } return { ...token, accessToken: refreshedTokens.access_token, refreshToken: refreshedTokens.refresh_token ?? token.refreshToken, // Keep existing ID token (Keycloak doesn't return new ID token on refresh) idToken: token.idToken, accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000, }; } catch (error: any) { console.error("Error refreshing access token:", error); // Check if it's an invalid_grant error (session invalidated) if (error?.error === 'invalid_grant' || error?.error_description?.includes('Session not active') || error?.error_description?.includes('Token is not active')) { return { ...token, error: "SessionNotActive", }; } return { ...token, error: "RefreshAccessTokenError", }; } } export const authOptions: NextAuthOptions = { providers: [ KeycloakProvider({ clientId: getRequiredEnvVar("KEYCLOAK_CLIENT_ID"), clientSecret: getRequiredEnvVar("KEYCLOAK_CLIENT_SECRET"), issuer: getRequiredEnvVar("KEYCLOAK_ISSUER"), authorization: { params: { scope: "openid profile email roles" } }, profile(profile) { console.log('Keycloak profile callback:', { rawProfile: profile, rawRoles: profile.roles, realmAccess: profile.realm_access, groups: profile.groups }); // Get roles from realm_access const roles = profile.realm_access?.roles || []; console.log('Profile callback raw roles:', roles); // Clean up roles by removing ROLE_ prefix and converting to lowercase const cleanRoles = roles.map((role: string) => role.replace(/^ROLE_/, '').toLowerCase() ); console.log('Profile callback cleaned roles:', cleanRoles); return { id: profile.sub, name: profile.name ?? profile.preferred_username, email: profile.email, first_name: profile.given_name ?? '', last_name: profile.family_name ?? '', username: profile.preferred_username ?? profile.email?.split('@')[0] ?? '', role: cleanRoles, } }, }), ], session: { strategy: "jwt", maxAge: 30 * 24 * 60 * 60, // 30 days }, callbacks: { async jwt({ token, account, profile }) { if (account && profile) { const keycloakProfile = profile as KeycloakProfile; const roles = keycloakProfile.realm_access?.roles || []; const cleanRoles = roles.map((role: string) => role.replace(/^ROLE_/, '').toLowerCase() ); token.accessToken = account.access_token ?? ''; token.refreshToken = account.refresh_token ?? ''; token.idToken = account.id_token ?? ''; token.accessTokenExpires = account.expires_at ?? 0; token.sub = keycloakProfile.sub; token.role = cleanRoles; token.username = keycloakProfile.preferred_username ?? ''; token.first_name = keycloakProfile.given_name ?? ''; token.last_name = keycloakProfile.family_name ?? ''; } else if (token.accessToken) { try { const decoded = jwtDecode(token.accessToken as string); if (decoded.realm_access?.roles) { const roles = decoded.realm_access.roles; const cleanRoles = roles.map((role: string) => role.replace(/^ROLE_/, '').toLowerCase() ); token.role = cleanRoles; } } catch (error) { console.error('Error decoding token:', error); } } // Check if token is expired and needs refresh if (Date.now() < (token.accessTokenExpires as number) * 1000) { return token; } // Token expired, try to refresh const refreshedToken = await refreshAccessToken(token); // If refresh failed due to invalid session, clear the token to force re-authentication if (refreshedToken.error === "SessionNotActive") { console.log("Keycloak session invalidated, clearing token to force re-authentication"); // Return a token that will cause session callback to return null return { ...refreshedToken, accessToken: undefined, refreshToken: undefined, idToken: undefined, }; } return refreshedToken; }, async session({ session, token }) { // If session was invalidated or tokens are missing, return null to sign out if (token.error === "SessionNotActive" || !token.accessToken) { console.log("Session invalidated or tokens missing, user will be signed out"); // Return null to make NextAuth treat user as unauthenticated // This will trigger automatic redirect to sign-in page return null as any; } // For other errors, throw to trigger error handling if (token.error) { throw new Error(token.error as string); } const userRoles = Array.isArray(token.role) ? token.role : []; session.user = { id: (token.sub ?? '') as string, email: (token.email ?? null) as string | null, name: (token.name ?? null) as string | null, image: null, username: (token.username ?? '') as string, first_name: (token.first_name ?? '') as string, last_name: (token.last_name ?? '') as string, role: userRoles, nextcloudInitialized: false, }; session.accessToken = token.accessToken as string | undefined; session.idToken = token.idToken as string | undefined; return session; } }, pages: { signIn: '/signin', error: '/signin', }, debug: process.env.NODE_ENV === 'development', }; // JWT interface is declared in the module declaration above interface Profile { sub?: string; email?: string; name?: string; roles?: string[]; }