NeahNew/app/api/auth/options.ts
2026-01-04 11:06:15 +01:00

522 lines
18 KiB
TypeScript

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[];
};
groups?: 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;
refreshToken?: 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)
const errorType = refreshedTokens.error;
const errorDescription = refreshedTokens.error_description || '';
// Session invalide (logout depuis iframe ou Keycloak)
if (errorType === 'invalid_grant' ||
errorDescription.includes('Session not active') ||
errorDescription.includes('Token is not active') ||
errorDescription.includes('Session expired')) {
console.log("Keycloak session invalidated, marking token for removal");
// Return token with specific error to trigger session invalidation
return {
...token,
error: "SessionNotActive",
accessToken: undefined,
refreshToken: undefined,
idToken: undefined,
};
}
// Refresh token expiré (inactivité prolongée)
if (errorType === 'invalid_grant' &&
errorDescription.includes('Refresh token expired')) {
console.log("Refresh token expired, user needs to re-authenticate");
return {
...token,
error: "RefreshTokenExpired",
accessToken: undefined,
refreshToken: undefined,
idToken: undefined,
};
}
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,
error: undefined, // Clear any previous errors
};
} 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') ||
error?.error_description?.includes('Session expired')) {
return {
...token,
error: "SessionNotActive",
accessToken: undefined,
refreshToken: undefined,
idToken: undefined,
};
}
return {
...token,
error: "RefreshAccessTokenError",
accessToken: undefined,
refreshToken: undefined,
idToken: undefined,
};
}
}
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",
// prompt: "login" removed - will be added conditionally after logout
// This allows SSO to work naturally for legitimate users
// prompt will be forced via custom signin route when needed
}
},
profile(profile) {
console.log('=== KEYCLOAK PROFILE CALLBACK ===');
console.log('Profile keys:', Object.keys(profile));
console.log('Has realm_access:', !!profile.realm_access);
console.log('Has groups:', !!profile.groups);
console.log('Groups:', profile.groups);
// Note: realm_access.roles might not be in ID token
// Roles will be extracted from access token in JWT callback
const roles = profile.realm_access?.roles || [];
console.log('Profile callback raw roles (from ID token):', 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);
console.log('===================================');
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, // Will be updated in JWT callback from access token
}
},
}),
],
session: {
strategy: "jwt",
// 4 hours session timeout for security (reduces attack window from 30 days to 4 hours)
// Token refresh mechanism automatically renews session if user is active
// Users only need to re-authenticate if inactive longer than Keycloak refresh token lifetime
maxAge: 4 * 60 * 60, // 4 hours (14,400 seconds)
},
cookies: {
sessionToken: {
name: `next-auth.session-token`,
options: {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: process.env.NEXTAUTH_URL?.startsWith('https://') ?? false,
},
},
callbackUrl: {
name: `next-auth.callback-url`,
options: {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: process.env.NEXTAUTH_URL?.startsWith('https://') ?? false,
},
},
csrfToken: {
name: `next-auth.csrf-token`,
options: {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: process.env.NEXTAUTH_URL?.startsWith('https://') ?? false,
},
},
state: {
name: `next-auth.state`,
options: {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: process.env.NEXTAUTH_URL?.startsWith('https://') ?? false,
},
},
},
callbacks: {
async jwt({ token, account, profile }) {
// Initial sign-in: account and profile are present
if (account && profile) {
console.log('=== JWT CALLBACK - INITIAL SIGN-IN ===');
console.log('Has account:', !!account);
console.log('Has profile:', !!profile);
console.log('Has access_token:', !!account.access_token);
const keycloakProfile = profile as KeycloakProfile;
// Extract roles from access token (not from profile/ID token)
// Keycloak typically puts realm_access.roles in the access token, not the ID token
let roles: string[] = [];
// First, try to get roles from profile (ID token) - may be empty
if (keycloakProfile.realm_access?.roles) {
roles = keycloakProfile.realm_access.roles;
console.log('Roles from profile (ID token):', roles);
}
// If no roles in profile, try to decode access token
if (roles.length === 0 && account.access_token) {
try {
const decodedAccessToken = jwtDecode<DecodedToken>(account.access_token);
console.log('Decoded access token keys:', Object.keys(decodedAccessToken));
console.log('Decoded access token realm_access:', decodedAccessToken.realm_access);
if (decodedAccessToken.realm_access?.roles) {
roles = decodedAccessToken.realm_access.roles;
console.log('✅ Roles extracted from access token:', roles);
} else {
console.log('⚠️ No realm_access.roles in access token');
}
} catch (error) {
console.error('❌ Error decoding access token for roles:', error);
}
}
// If still no roles, try groups as fallback (some Keycloak configs use groups instead)
if (roles.length === 0 && keycloakProfile.groups && Array.isArray(keycloakProfile.groups)) {
console.log('⚠️ No roles found, using groups as fallback:', keycloakProfile.groups);
// Use groups as roles (they might be the actual roles in this Keycloak setup)
roles = keycloakProfile.groups;
}
const cleanRoles = roles.map((role: string) =>
role.replace(/^ROLE_/, '').toLowerCase()
);
console.log('✅ Final cleaned roles:', cleanRoles);
console.log('=====================================');
token.accessToken = account.access_token ?? '';
token.refreshToken = account.refresh_token ?? '';
token.idToken = account.id_token ?? '';
// expires_at from Keycloak is in seconds since epoch, convert to milliseconds
token.accessTokenExpires = account.expires_at ? account.expires_at * 1000 : Date.now() + 3600 * 1000;
token.sub = keycloakProfile.sub;
token.role = cleanRoles;
token.username = keycloakProfile.preferred_username ?? '';
token.first_name = keycloakProfile.given_name ?? '';
token.last_name = keycloakProfile.family_name ?? '';
// IMPORTANT: Set email and name for session callback
token.email = keycloakProfile.email ?? null;
token.name = keycloakProfile.name ?? keycloakProfile.preferred_username ?? null;
console.log('JWT token populated:', {
hasSub: !!token.sub,
hasEmail: !!token.email,
hasName: !!token.name,
hasUsername: !!token.username,
rolesCount: cleanRoles.length,
});
// Return immediately on initial sign-in - don't try to refresh tokens we just received
return token;
}
// Subsequent requests: check existing token
if (token.accessToken) {
try {
const decoded = jwtDecode<DecodedToken>(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
// accessTokenExpires is in milliseconds
const expiresAt = token.accessTokenExpires as number;
if (expiresAt && Date.now() < expiresAt) {
// Token is still valid, return as-is
return token;
}
// Token expired or invalidated, try to refresh
if (!token.refreshToken) {
console.log("No refresh token available, cannot refresh");
return {
...token,
accessToken: undefined,
refreshToken: undefined,
idToken: undefined,
error: "NoRefreshToken",
};
}
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,
};
}
// If refresh failed with invalid_grant (token not active), also clear tokens
if (refreshedToken.error === "RefreshAccessTokenError" && !refreshedToken.accessToken) {
console.log("Refresh token invalid, clearing session to force re-authentication");
return {
...refreshedToken,
accessToken: undefined,
refreshToken: undefined,
idToken: undefined,
};
}
return refreshedToken;
},
async session({ session, token }) {
try {
console.log('=== SESSION CALLBACK START ===');
console.log('Token error:', token.error);
console.log('Has accessToken:', !!token.accessToken);
console.log('Has refreshToken:', !!token.refreshToken);
console.log('Token role:', token.role);
console.log('Token sub:', token.sub);
console.log('Token email:', token.email);
console.log('Token name:', token.name);
console.log('Token username:', token.username);
// If session was invalidated or tokens are missing, return null to sign out
if (token.error === "SessionNotActive" ||
token.error === "NoRefreshToken" ||
!token.accessToken ||
!token.refreshToken) {
console.log("❌ Session invalidated or tokens missing, user will be signed out", {
error: token.error,
hasAccessToken: !!token.accessToken,
hasRefreshToken: !!token.refreshToken
});
// Return null to make NextAuth treat user as unauthenticated
// This will trigger automatic redirect to sign-in page
// The client-side code will detect session invalidation by checking for
// session cookie existence when status is unauthenticated
return null as any;
}
// For other errors, throw to trigger error handling
if (token.error) {
console.error("❌ Token error, throwing:", token.error);
throw new Error(token.error as string);
}
const userRoles = Array.isArray(token.role) ? token.role : [];
console.log('User roles for session:', userRoles);
// Validate required fields
if (!token.sub) {
console.error('❌ Missing token.sub (user ID)');
throw new Error('Missing user ID in token');
}
console.log('Creating session user object...');
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,
};
console.log('Setting session tokens...');
session.accessToken = token.accessToken as string | undefined;
session.idToken = token.idToken as string | undefined;
session.refreshToken = token.refreshToken as string | undefined;
console.log('✅ Session created successfully');
console.log('Session user id:', session.user.id);
console.log('Session user email:', session.user.email);
console.log('Session user roles:', session.user.role);
console.log('=== SESSION CALLBACK END ===');
return session;
} catch (error) {
console.error('❌❌❌ CRITICAL ERROR IN SESSION CALLBACK ❌❌❌');
console.error('Error type:', error instanceof Error ? error.constructor.name : typeof error);
console.error('Error message:', error instanceof Error ? error.message : String(error));
console.error('Error stack:', error instanceof Error ? error.stack : 'No stack trace');
console.error('Token state:', {
hasSub: !!token.sub,
hasEmail: !!token.email,
hasAccessToken: !!token.accessToken,
hasRefreshToken: !!token.refreshToken,
role: token.role,
});
// Re-throw to let NextAuth handle it
throw error;
}
}
},
pages: {
signIn: '/signin',
error: '/signin',
},
debug: process.env.NODE_ENV === 'development',
// Add error handling events
events: {
async signIn({ user, account, profile }) {
console.log('=== NEXTAUTH SIGNIN EVENT ===');
console.log('User:', user?.id, user?.email);
console.log('Account:', account?.provider);
console.log('Profile:', profile?.sub);
return true;
},
async signOut() {
console.log('=== NEXTAUTH SIGNOUT EVENT ===');
},
async error({ error }) {
console.error('=== NEXTAUTH ERROR EVENT ===');
console.error('Error:', error);
},
},
};
// JWT interface is declared in the module declaration above
interface Profile {
sub?: string;
email?: string;
name?: string;
roles?: string[];
}