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; } interface JWT { sub?: string; accessToken?: string; refreshToken?: 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; } async function refreshAccessToken(token: JWT) { try { console.log('Attempting to refresh access token'); 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) { console.error('Token refresh failed with status:', response.status); console.error('Error response:', refreshedTokens); throw refreshedTokens; } console.log('Token refresh successful'); return { ...token, accessToken: refreshedTokens.access_token, refreshToken: refreshedTokens.refresh_token ?? token.refreshToken, accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000, error: undefined, // Clear any previous errors }; } catch (error) { console.error("Error refreshing access token:", error); 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) { // Simplified profile logging to reduce console noise console.log('Keycloak profile received'); // Get roles from realm_access const roles = profile.realm_access?.roles || []; // Clean up roles by removing ROLE_ prefix and converting to lowercase const cleanRoles = roles.map((role: string) => role.replace(/^ROLE_/, '').toLowerCase() ); 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: 12 * 60 * 60, // Reduce to 12 hours to help with token size }, cookies: { sessionToken: { name: `next-auth.session-token`, options: { httpOnly: true, sameSite: 'none', path: '/', secure: true, domain: process.env.NEXTAUTH_COOKIE_DOMAIN || undefined, maxAge: 12 * 60 * 60, // Match session maxAge }, }, }, jwt: { // Maximum JWT size to prevent chunking maxAge: 12 * 60 * 60, // Reduce to 12 hours }, callbacks: { async jwt({ token, account, profile }) { // Initial sign in if (account && profile) { const keycloakProfile = profile as KeycloakProfile; const roles = keycloakProfile.realm_access?.roles || []; // Only include admin, owner, user roles (most critical) const criticalRoles = roles .filter(role => role.includes('admin') || role.includes('owner') || role.includes('user') ) .map(role => role.replace(/^ROLE_/, '').toLowerCase()); // Store absolute minimal data in the token token.accessToken = account.access_token; token.refreshToken = account.refresh_token; token.accessTokenExpires = account.expires_at ?? 0; token.sub = keycloakProfile.sub; token.role = criticalRoles.length > 0 ? criticalRoles : ['user']; // Only critical roles token.username = keycloakProfile.preferred_username?.substring(0, 30) ?? ''; token.error = undefined; // Don't store first/last name in the token to save space // Applications can get these from the userinfo endpoint if needed return token; } // Check if token has expired const tokenExpiresAt = token.accessTokenExpires ? token.accessTokenExpires as number : 0; const currentTime = Date.now(); const hasExpired = currentTime >= tokenExpiresAt; // If the token is still valid, return it if (!hasExpired) { return token; } // If refresh token is missing, force sign in if (!token.refreshToken) { console.warn('No refresh token available, session cannot be refreshed'); return { ...token, error: "RefreshAccessTokenError" }; } // Try to refresh the token const refreshedToken = await refreshAccessToken(token as JWT); // If there was an error refreshing, mark token for re-authentication if (refreshedToken.error) { console.warn('Token refresh failed, user will need to reauthenticate'); return { ...refreshedToken, error: "RefreshAccessTokenError" }; } return refreshedToken; }, async session({ session, token }) { try { // Handle the error from jwt callback if (token.error === "RefreshAccessTokenError") { console.warn("Session encountered a refresh token error, redirecting to login"); // Return minimal session with error flag that will trigger re-auth in client return { ...session, error: "RefreshTokenError", user: { id: token.sub ?? '', role: ['user'], // Default role username: '', // Empty username first_name: '', last_name: '', name: null, email: null, image: null, nextcloudInitialized: false, } }; } const userRoles = Array.isArray(token.role) ? token.role : []; // Create an extremely minimal user object session.user = { id: token.sub ?? '', email: token.email ?? null, name: token.name ?? null, image: null, username: token.username ?? '', first_name: token.first_name ?? '', last_name: token.last_name ?? '', role: userRoles, // Don't include nextcloudInitialized or other non-essential fields }; // Only store access token, not the entire token session.accessToken = token.accessToken; return session; } catch (error) { console.error("Error in session callback:", error); // Return minimal session with error flag return { ...session, error: "SessionError", user: { id: token.sub ?? '', role: ['user'], username: '', first_name: '', last_name: '', name: null, email: null, image: null, nextcloudInitialized: false, } }; } } }, pages: { signIn: '/signin', error: '/signin', }, debug: false, // Disable debug to reduce cookie size from logging }; const handler = NextAuth(authOptions); export { handler as GET, handler as POST }; interface JWT { accessToken: string; refreshToken: string; accessTokenExpires: number; } interface Profile { sub?: string; email?: string; name?: string; roles?: string[]; }