// server/utils/userSync.ts // Shared utility for syncing user data from JWT tokens to database import Database from 'better-sqlite3'; import { join } from 'path'; import { existsSync, mkdirSync } from 'fs'; // Helper to get database path const getDbPath = () => { const dbDir = join(process.cwd(), 'data'); if (!existsSync(dbDir)) { mkdirSync(dbDir, { recursive: true }); } return join(dbDir, 'users.db'); }; // Initialize database and create table if not exists const initDb = () => { const dbPath = getDbPath(); const db = new Database(dbPath); // Create table if not exists db.exec(` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, namaLengkap TEXT NOT NULL, namaUser TEXT UNIQUE NOT NULL, email TEXT, tipeUser TEXT DEFAULT '', lastLogin INTEGER, roles TEXT DEFAULT '[]', realmRoles TEXT DEFAULT '[]', accountRoles TEXT DEFAULT '[]', resourceRoles TEXT DEFAULT '[]', groups TEXT DEFAULT '[]', given_name TEXT, family_name TEXT, createdAt INTEGER DEFAULT (strftime('%s', 'now')), updatedAt INTEGER DEFAULT (strftime('%s', 'now')) ) `); // Migration: Check and add missing columns one by one try { const tableInfo = db.prepare("PRAGMA table_info(users)").all() as any[]; const columnNames = tableInfo.map(col => col.name); // Add missing columns one by one if (!columnNames.includes('realmRoles')) { db.exec(`ALTER TABLE users ADD COLUMN realmRoles TEXT DEFAULT '[]'`); console.log('✅ Added column: realmRoles'); } if (!columnNames.includes('accountRoles')) { db.exec(`ALTER TABLE users ADD COLUMN accountRoles TEXT DEFAULT '[]'`); console.log('✅ Added column: accountRoles'); } if (!columnNames.includes('resourceRoles')) { db.exec(`ALTER TABLE users ADD COLUMN resourceRoles TEXT DEFAULT '[]'`); console.log('✅ Added column: resourceRoles'); } if (!columnNames.includes('lastLogin')) { db.exec(`ALTER TABLE users ADD COLUMN lastLogin INTEGER`); console.log('✅ Added column: lastLogin'); } if (!columnNames.includes('given_name')) { db.exec(`ALTER TABLE users ADD COLUMN given_name TEXT`); console.log('✅ Added column: given_name'); } if (!columnNames.includes('family_name')) { db.exec(`ALTER TABLE users ADD COLUMN family_name TEXT`); console.log('✅ Added column: family_name'); } } catch (e: any) { console.error('❌ Migration error:', e.message); // Don't throw, continue with existing schema } return db; }; // Helper function to decode JWT token payload const decodeTokenPayload = (token: string | undefined): any | null => { if (!token) return null; try { const parts = token.split("."); if (parts.length < 2) return null; const payloadBase64 = parts[1]; return JSON.parse(Buffer.from(payloadBase64, "base64").toString()); } catch (e) { console.error("❌ Failed to decode token payload:", e); return null; } }; /** * Sync user data from JWT tokens to database * @param idToken - The ID token from Keycloak * @param accessToken - The access token from Keycloak * @param loginTime - Optional login timestamp (from session createdAt). If not provided, uses current time * @returns Object with success status and action taken */ export const syncUserFromTokens = ( idToken: string, accessToken: string, loginTime?: number ): { success: boolean; action: 'created' | 'updated' | 'unchanged'; message: string } => { try { // Decode tokens const idTokenPayload = decodeTokenPayload(idToken); const accessTokenPayload = decodeTokenPayload(accessToken); if (!idTokenPayload) { throw new Error('Invalid ID token'); } // Extract user data const userId = idTokenPayload.sub; const namaLengkap = idTokenPayload.name || `${idTokenPayload.given_name || ''} ${idTokenPayload.family_name || ''}`.trim() || idTokenPayload.preferred_username; const namaUser = idTokenPayload.preferred_username || idTokenPayload.email?.split('@')[0]; const email = idTokenPayload.email; if (!userId || !namaUser) { throw new Error('Missing required user data (id or username)'); } // Extract roles from different sources const realmRoles = accessTokenPayload?.realm_access?.roles || idTokenPayload?.realm_access?.roles || []; const accountRoles = accessTokenPayload?.resource_access?.account?.roles || idTokenPayload?.resource_access?.account?.roles || []; // Extract resource roles (from all resources in resource_access) const resourceRoles: string[] = []; if (accessTokenPayload?.resource_access) { Object.keys(accessTokenPayload.resource_access).forEach(resourceName => { if (resourceName !== 'account') { // Exclude account, already handled const resourceRolesArray = accessTokenPayload.resource_access[resourceName]?.roles || []; resourceRoles.push(...resourceRolesArray.map((role: string) => `${resourceName}:${role}`)); } }); } if (idTokenPayload?.resource_access) { Object.keys(idTokenPayload.resource_access).forEach(resourceName => { if (resourceName !== 'account') { // Exclude account, already handled const resourceRolesArray = idTokenPayload.resource_access[resourceName]?.roles || []; resourceRoles.push(...resourceRolesArray.map((role: string) => `${resourceName}:${role}`)); } }); } // Legacy: Combined roles (for backward compatibility) const roles = [...realmRoles, ...accountRoles, ...resourceRoles]; // Keycloak uses 'groups_join' in access token, not 'groups' const groups = accessTokenPayload?.groups_join || accessTokenPayload?.groups || idTokenPayload?.groups_join || idTokenPayload?.groups || []; // Determine tipeUser from groups or roles if possible // Extract from groups path (e.g., "/Instalasi STIM/Devops/Superadmin" -> "Superadmin") let tipeUser = ''; if (Array.isArray(groups) && groups.length > 0) { // Get the last group path and extract the last segment const lastGroup = groups[groups.length - 1]; if (typeof lastGroup === 'string') { const parts = lastGroup.split('/').filter(Boolean); if (parts.length > 0) { tipeUser = parts[parts.length - 1]; // Get last part of path } } } // If no tipeUser from groups, you can also check roles or leave empty for manual entry // Use provided loginTime or current time (convert to seconds if in milliseconds) const lastLoginTimestamp = loginTime ? (loginTime > 10000000000 ? Math.floor(loginTime / 1000) : loginTime) // Convert ms to seconds if needed : Math.floor(Date.now() / 1000); // Initialize database const db = initDb(); // Check if user exists const existingUser = db.prepare('SELECT * FROM users WHERE id = ?').get(userId) as any; const rolesJson = JSON.stringify(Array.isArray(roles) ? roles : []); const realmRolesJson = JSON.stringify(Array.isArray(realmRoles) ? realmRoles : []); const accountRolesJson = JSON.stringify(Array.isArray(accountRoles) ? accountRoles : []); const resourceRolesJson = JSON.stringify(Array.isArray(resourceRoles) ? resourceRoles : []); const groupsJson = JSON.stringify(Array.isArray(groups) ? groups : []); if (existingUser) { // User exists - check if data needs updating // Note: tipeUser is only updated if it's empty in database (to preserve manual edits) const needsUpdate = existingUser.namaLengkap !== namaLengkap || existingUser.namaUser !== namaUser || existingUser.email !== email || existingUser.roles !== rolesJson || existingUser.realmRoles !== realmRolesJson || existingUser.accountRoles !== accountRolesJson || existingUser.resourceRoles !== resourceRolesJson || existingUser.groups !== groupsJson || existingUser.given_name !== (idTokenPayload.given_name || null) || existingUser.family_name !== (idTokenPayload.family_name || null) || (existingUser.tipeUser === '' && tipeUser !== ''); // Only update if empty // Always update lastLogin when loginTime is provided (user is logging in) // Check if lastLogin needs updating (if loginTime provided or new timestamp is newer) const existingLastLogin = existingUser.lastLogin || 0; const needsLastLoginUpdate = loginTime !== undefined ? true // Always update on login when loginTime is provided : (lastLoginTimestamp > existingLastLogin); // Only update if newer when not a login event // Update if any data changed OR if lastLogin needs updating if (needsUpdate || needsLastLoginUpdate) { // Update user data // Only update tipeUser if it's currently empty (preserve manual edits) const updateTipeUser = existingUser.tipeUser === '' ? tipeUser : existingUser.tipeUser; db.prepare(` UPDATE users SET namaLengkap = ?, namaUser = ?, email = ?, roles = ?, realmRoles = ?, accountRoles = ?, resourceRoles = ?, groups = ?, given_name = ?, family_name = ?, tipeUser = ?, lastLogin = ?, updatedAt = strftime('%s', 'now') WHERE id = ? `).run( namaLengkap, namaUser, email || null, rolesJson, realmRolesJson, accountRolesJson, resourceRolesJson, groupsJson, idTokenPayload.given_name || null, idTokenPayload.family_name || null, updateTipeUser, lastLoginTimestamp, // Update lastLogin timestamp from session userId ); if (needsLastLoginUpdate && !needsUpdate) { console.log("✅ User lastLogin updated:", userId, "new timestamp:", lastLoginTimestamp); } else { console.log("✅ User data updated:", userId); } db.close(); return { success: true, action: 'updated', message: 'User data updated successfully' }; } else { console.log("ℹ️ User data unchanged:", userId); db.close(); return { success: true, action: 'unchanged', message: 'User data is up to date' }; } } else { // New user - insert console.log("➕ Inserting new user:", { userId, namaLengkap, namaUser, email, lastLoginTimestamp }); db.prepare(` INSERT INTO users ( id, namaLengkap, namaUser, email, roles, realmRoles, accountRoles, resourceRoles, groups, given_name, family_name, tipeUser, lastLogin ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( userId, namaLengkap, namaUser, email || null, rolesJson, realmRolesJson, accountRolesJson, resourceRolesJson, groupsJson, idTokenPayload.given_name || null, idTokenPayload.family_name || null, tipeUser, // tipeUser - extracted from groups or empty lastLoginTimestamp // lastLogin - from session createdAt ); console.log("✅ New user saved to database:", { userId, namaUser, namaLengkap, lastLogin: lastLoginTimestamp ? new Date(lastLoginTimestamp * 1000).toISOString() : 'null' }); db.close(); return { success: true, action: 'created', message: `New user ${namaUser} saved successfully` }; } } catch (error: any) { console.error("❌ Error syncing user:", error); throw error; } };