365 lines
13 KiB
TypeScript
365 lines
13 KiB
TypeScript
import NextAuth, { NextAuthOptions } from "next-auth";
|
|
import KeycloakProvider from "next-auth/providers/keycloak";
|
|
|
|
// Define Keycloak profile type
|
|
interface KeycloakProfile {
|
|
sub: string;
|
|
email?: string;
|
|
name?: string;
|
|
preferred_username?: string;
|
|
given_name?: string;
|
|
family_name?: string;
|
|
realm_access?: {
|
|
roles: string[];
|
|
};
|
|
}
|
|
|
|
// Define custom profile type
|
|
interface CustomProfile {
|
|
id: string;
|
|
name?: string | null;
|
|
email?: string | null;
|
|
username: string;
|
|
first_name: string;
|
|
last_name: string;
|
|
role: string[];
|
|
}
|
|
|
|
// Declare module augmentation for NextAuth types
|
|
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[];
|
|
};
|
|
accessToken?: string;
|
|
}
|
|
|
|
interface JWT {
|
|
sub?: string;
|
|
accessToken?: string;
|
|
refreshToken?: string;
|
|
role?: string[];
|
|
username?: string;
|
|
first_name?: string;
|
|
last_name?: string;
|
|
}
|
|
}
|
|
|
|
// Simple, minimal implementation - NO REFRESH TOKEN LOGIC
|
|
export const authOptions: NextAuthOptions = {
|
|
providers: [
|
|
KeycloakProvider({
|
|
clientId: process.env.KEYCLOAK_CLIENT_ID || "",
|
|
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET || "",
|
|
issuer: process.env.KEYCLOAK_ISSUER || "",
|
|
profile(profile: any) {
|
|
console.log("Raw Keycloak profile:", JSON.stringify(profile, null, 2));
|
|
|
|
// Try to extract all possible roles from the profile
|
|
let roles: string[] = [];
|
|
|
|
// Get roles from realm_access
|
|
if (profile.realm_access && Array.isArray(profile.realm_access.roles)) {
|
|
roles = roles.concat(profile.realm_access.roles);
|
|
}
|
|
|
|
// Get roles from resource_access
|
|
if (profile.resource_access) {
|
|
for (const client in profile.resource_access) {
|
|
if (profile.resource_access[client] &&
|
|
Array.isArray(profile.resource_access[client].roles)) {
|
|
roles = roles.concat(profile.resource_access[client].roles);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get roles from groups if available
|
|
if (profile.groups && Array.isArray(profile.groups)) {
|
|
roles = roles.concat(profile.groups);
|
|
}
|
|
|
|
console.log("Extracted roles from profile:", roles);
|
|
|
|
// Just return a simple profile with required fields
|
|
return {
|
|
id: profile.sub,
|
|
name: profile.name || profile.preferred_username,
|
|
email: profile.email,
|
|
image: null,
|
|
username: profile.preferred_username || profile.email?.split('@')[0] || '',
|
|
first_name: profile.given_name || '',
|
|
last_name: profile.family_name || '',
|
|
role: roles.length > 0 ? roles : ['user'],
|
|
// Store raw profile data for later processing
|
|
raw_profile: profile
|
|
};
|
|
}
|
|
}),
|
|
],
|
|
session: {
|
|
strategy: "jwt",
|
|
maxAge: 8 * 60 * 60, // 8 hours
|
|
},
|
|
callbacks: {
|
|
async jwt({ token, account, profile, user }: any) {
|
|
console.log("JWT CALLBACK TRIGGERED with token keys:", Object.keys(token));
|
|
console.log("JWT CALLBACK - Has account?", !!account);
|
|
console.log("JWT CALLBACK - Has user?", !!user);
|
|
|
|
// TEMPORARY DEBUG HACK - Force roles for specific user
|
|
if (token.sub === "203cbc91-61ab-47a2-95d2-b5e1159327d7") {
|
|
console.log("DEBUG HACK: Detected specific user, forcing all roles");
|
|
// Only add this if token.role doesn't already have these roles
|
|
if (!token.role || token.role.length <= 1) {
|
|
token.role = ["user", "admin", "expression", "mediation", "coding", "dataintelligence", "entrepreneurship"];
|
|
console.log("DEBUG HACK: Set roles to", token.role);
|
|
}
|
|
}
|
|
|
|
// Initial sign in
|
|
if (account && account.access_token) {
|
|
console.log("FULL USER OBJECT:", JSON.stringify(user, null, 2));
|
|
console.log("FULL ACCOUNT OBJECT:", JSON.stringify(account, null, 2));
|
|
|
|
token.accessToken = account.access_token;
|
|
token.refreshToken = account.refresh_token;
|
|
|
|
// Process the raw profile data if available
|
|
if (user && user.raw_profile) {
|
|
const rawProfile = user.raw_profile;
|
|
console.log("RAW KEYCLOAK PROFILE:", JSON.stringify(rawProfile, null, 2));
|
|
|
|
// Extract roles from all possible sources
|
|
let roles: string[] = [];
|
|
|
|
// Get roles from realm_access
|
|
if (rawProfile.realm_access && Array.isArray(rawProfile.realm_access.roles)) {
|
|
roles = roles.concat(rawProfile.realm_access.roles);
|
|
console.log("Roles from realm_access:", rawProfile.realm_access.roles);
|
|
}
|
|
|
|
// Get roles from resource_access
|
|
if (rawProfile.resource_access) {
|
|
const clientId = process.env.KEYCLOAK_CLIENT_ID;
|
|
console.log("Client ID for resource access:", clientId);
|
|
console.log("Resource access object:", JSON.stringify(rawProfile.resource_access, null, 2));
|
|
|
|
if (clientId &&
|
|
rawProfile.resource_access[clientId] &&
|
|
Array.isArray(rawProfile.resource_access[clientId].roles)) {
|
|
roles = roles.concat(rawProfile.resource_access[clientId].roles);
|
|
console.log("Roles from resource_access[clientId]:", rawProfile.resource_access[clientId].roles);
|
|
}
|
|
|
|
// Also check resource_access roles under 'account'
|
|
if (rawProfile.resource_access.account &&
|
|
Array.isArray(rawProfile.resource_access.account.roles)) {
|
|
roles = roles.concat(rawProfile.resource_access.account.roles);
|
|
console.log("Roles from resource_access.account:", rawProfile.resource_access.account.roles);
|
|
}
|
|
|
|
// Check for any roles in any client
|
|
for (const [clientKey, clientData] of Object.entries(rawProfile.resource_access)) {
|
|
if (clientData && Array.isArray((clientData as any).roles)) {
|
|
console.log(`Found roles in client ${clientKey}:`, (clientData as any).roles);
|
|
roles = roles.concat((clientData as any).roles);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Look for roles in other potential locations
|
|
if (rawProfile.groups && Array.isArray(rawProfile.groups)) {
|
|
console.log("Found groups that might contain roles:", rawProfile.groups);
|
|
roles = roles.concat(rawProfile.groups);
|
|
}
|
|
|
|
// Clean up roles and convert to lowercase
|
|
const cleanedRoles = roles
|
|
.filter(Boolean)
|
|
.map(role => role.toLowerCase());
|
|
|
|
console.log("Cleaned raw Keycloak roles:", cleanedRoles);
|
|
console.log("Directly from cleaning process:", cleanedRoles);
|
|
|
|
// Always ensure user has basic user role
|
|
const finalRoles = [...new Set([...cleanedRoles, 'user'])];
|
|
|
|
console.log("Input to mapping function:", finalRoles);
|
|
|
|
// Map Keycloak roles to application roles
|
|
token.role = mapToApplicationRoles(finalRoles);
|
|
console.log("Mapped application roles:", token.role);
|
|
console.log("Output from mapping function:", token.role);
|
|
} else if (user && user.role) {
|
|
console.log("Using direct user.role:", user.role);
|
|
token.role = Array.isArray(user.role) ? user.role : [user.role];
|
|
console.log("Using user.role directly:", token.role);
|
|
} else {
|
|
// Default roles if no profile data available
|
|
console.log("No role data found in user object or profile");
|
|
token.role = ['user'];
|
|
console.log("Using default 'user' role only");
|
|
}
|
|
|
|
// Store user information
|
|
if (user) {
|
|
token.username = user.username || user.name || '';
|
|
token.first_name = user.first_name || '';
|
|
token.last_name = user.last_name || '';
|
|
}
|
|
}
|
|
// Token exists but no roles, add default user role
|
|
else if (token && !token.role) {
|
|
token.role = ['user'];
|
|
console.log("Adding default 'user' role to existing token");
|
|
}
|
|
|
|
console.log("FINAL TOKEN:", JSON.stringify(token, null, 2));
|
|
return token;
|
|
},
|
|
async session({ session, token }: any) {
|
|
console.log("SESSION CALLBACK TRIGGERED with token keys:", Object.keys(token));
|
|
console.log("SESSION CALLBACK - Token role:", token.role);
|
|
|
|
// Pass necessary info to the session
|
|
session.accessToken = token.accessToken;
|
|
if (session.user) {
|
|
session.user.id = token.sub || "";
|
|
|
|
// Ensure roles are passed to the session
|
|
if (token.role && Array.isArray(token.role)) {
|
|
session.user.role = token.role;
|
|
session.user.username = token.username || '';
|
|
session.user.first_name = token.first_name || '';
|
|
session.user.last_name = token.last_name || '';
|
|
console.log("Session updated with roles from token:", token.role);
|
|
} else {
|
|
// Fallback roles
|
|
session.user.role = ["user"];
|
|
session.user.username = '';
|
|
session.user.first_name = '';
|
|
session.user.last_name = '';
|
|
console.log("Session using fallback 'user' role only");
|
|
}
|
|
}
|
|
return session;
|
|
}
|
|
},
|
|
pages: {
|
|
signIn: '/signin',
|
|
error: '/signin',
|
|
},
|
|
cookies: {
|
|
sessionToken: {
|
|
name: 'next-auth.session-token',
|
|
options: {
|
|
httpOnly: true,
|
|
sameSite: 'none',
|
|
path: '/',
|
|
secure: true,
|
|
},
|
|
},
|
|
},
|
|
debug: process.env.NODE_ENV === 'development',
|
|
};
|
|
|
|
/**
|
|
* Maps Keycloak roles to application-specific roles
|
|
*/
|
|
function mapToApplicationRoles(keycloakRoles: string[]): string[] {
|
|
// Debug input
|
|
console.log("Mapping input roles:", keycloakRoles);
|
|
|
|
// For development/testing, directly assign application roles based on username
|
|
// This helps in case Keycloak isn't properly configured
|
|
let appRoles: string[] = ['user']; // Always include 'user' role
|
|
|
|
// The mappings object maps Keycloak role names to application role names
|
|
const mappings: Record<string, string[]> = {
|
|
// Map Keycloak roles to your application's role names
|
|
'admin': ['admin', 'dataintelligence', 'coding', 'expression', 'mediation', 'entrepreneurship'],
|
|
'owner': ['admin', 'dataintelligence', 'coding', 'expression', 'mediation', 'entrepreneurship'],
|
|
'manager': ['dataintelligence', 'coding', 'expression', 'mediation', 'entrepreneurship'],
|
|
'developer': ['coding', 'dataintelligence'],
|
|
'data-scientist': ['dataintelligence'],
|
|
'designer': ['expression'],
|
|
'writer': ['expression'],
|
|
'mediator': ['mediation'],
|
|
'entrepreneur': ['entrepreneurship'],
|
|
|
|
// Exact matches for capitalized roles from Keycloak token
|
|
'Expression': ['expression'],
|
|
'Mediation': ['mediation'],
|
|
'DataIntelligence': ['dataintelligence'],
|
|
'Admin': ['admin', 'dataintelligence', 'coding', 'expression', 'mediation', 'entrepreneurship'],
|
|
|
|
// Common prefixed variants
|
|
'role_admin': ['admin', 'dataintelligence', 'coding', 'expression', 'mediation', 'entrepreneurship'],
|
|
'realm_admin': ['admin', 'dataintelligence', 'coding', 'expression', 'mediation', 'entrepreneurship'],
|
|
'app_admin': ['admin', 'dataintelligence', 'coding', 'expression', 'mediation', 'entrepreneurship'],
|
|
|
|
// Default access roles from Keycloak
|
|
'default-roles-cercle': ['user'],
|
|
'uma_authorization': ['user'],
|
|
'offline_access': ['user'],
|
|
|
|
// Direct mapping for flexibility
|
|
'expression': ['expression'],
|
|
'mediation': ['mediation'],
|
|
'coding': ['coding'],
|
|
'dataintelligence': ['dataintelligence'],
|
|
'entrepreneurship': ['entrepreneurship'],
|
|
};
|
|
|
|
// Try to match each role with our mappings
|
|
for (const role of keycloakRoles) {
|
|
// First, check for an exact case-sensitive match
|
|
if (mappings[role]) {
|
|
appRoles = [...appRoles, ...mappings[role]];
|
|
console.log(`Exact matched ${role} to: ${mappings[role].join(', ')}`);
|
|
continue; // Skip to next role
|
|
}
|
|
|
|
// If no direct match, normalize and try again
|
|
// Try different variations of the role name
|
|
const normalizedRole = role.toLowerCase()
|
|
.replace(/^role_/i, '') // Remove ROLE_ prefix
|
|
.replace(/^realm_/i, '') // Remove REALM_ prefix
|
|
.replace(/^app_/i, ''); // Remove APP_ prefix
|
|
|
|
console.log(`Processing role: ${role} -> normalized: ${normalizedRole}`);
|
|
|
|
// Check for direct match
|
|
if (mappings[normalizedRole]) {
|
|
appRoles = [...appRoles, ...mappings[normalizedRole]];
|
|
console.log(`Mapped ${role} to: ${mappings[normalizedRole].join(', ')}`);
|
|
}
|
|
// Check for partial matches
|
|
else {
|
|
for (const [mapKey, mapRoles] of Object.entries(mappings)) {
|
|
if (normalizedRole.includes(mapKey)) {
|
|
appRoles = [...appRoles, ...mapRoles];
|
|
console.log(`Partially matched ${role} with ${mapKey} to: ${mapRoles.join(', ')}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove duplicates and return
|
|
const uniqueRoles = [...new Set(appRoles)];
|
|
console.log("Final mapped roles:", uniqueRoles);
|
|
return uniqueRoles;
|
|
}
|
|
|
|
const handler = NextAuth(authOptions);
|
|
|
|
export { handler as GET, handler as POST };
|
|
|