/** * NextAuth 4 API Route Handler with Redis Session Storage * * 架構說明: * 1. Keycloak SSO - 統一認證入口 * 2. Redis - 集中式 session storage (解決 Cookie 4KB 限制) * 3. Cookie - 只存 session_key (64 bytes) * * 多系統共享: * - hr.ease.taipei, mail.ease.taipei, calendar.ease.taipei 等 * - 共用同一個 Redis session (透過 Keycloak user_id) * - Token refresh 自動同步到所有系統 */ import NextAuth, { NextAuthOptions } from 'next-auth' import KeycloakProvider from 'next-auth/providers/keycloak' import Redis from 'ioredis' // Redis 客戶端 (DB 0 專用於 Session Storage) const redis = new Redis({ host: process.env.REDIS_HOST || '10.1.0.20', port: parseInt(process.env.REDIS_PORT || '6379'), password: process.env.REDIS_PASSWORD, db: 0, // Session Storage 專用 retryStrategy: (times) => { const delay = Math.min(times * 50, 2000) return delay }, }) // Redis 連線錯誤處理 redis.on('error', (err) => { console.error('[Redis] Connection error:', err) }) redis.on('connect', () => { console.log('[Redis] Connected to session storage') }) const authOptions: NextAuthOptions = { providers: [ KeycloakProvider({ clientId: process.env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID!, clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!, issuer: `${process.env.NEXT_PUBLIC_KEYCLOAK_URL}/realms/${process.env.NEXT_PUBLIC_KEYCLOAK_REALM}`, }), ], session: { strategy: 'jwt', maxAge: 8 * 60 * 60, // 8 小時 updateAge: 60 * 60, // 每 1 小時檢查一次 }, events: { // 登出時清除 Redis session (所有系統同步登出) async signOut({ token }) { if (token?.sub) { const sessionKey = `session:${token.sub}` await redis.del(sessionKey) console.log('[EVENT] Signed out, Redis session deleted:', sessionKey) } }, }, callbacks: { async jwt({ token, account, profile, trigger }) { const userId = token.sub || profile?.sub if (!userId) return token console.log('[JWT CALLBACK]', { trigger, userId: userId.substring(0, 8) }) // 初次登入 - 儲存 Keycloak tokens 到 Redis if (account && profile) { const sessionData = { sub: profile.sub, email: profile.email, name: profile.name, accessToken: account.access_token, refreshToken: account.refresh_token, expiresAt: account.expires_at, createdAt: Math.floor(Date.now() / 1000), } // 存入 Redis (Key: session:{user_id}) const sessionKey = `session:${userId}` await redis.setex( sessionKey, 8 * 60 * 60, // TTL: 8 hours JSON.stringify(sessionData) ) console.log('[JWT CALLBACK] Stored session to Redis:', sessionKey) // Cookie 只存輕量資料 (user_id) return { sub: userId, email: profile.email, name: profile.name, sessionKey, // 引用 Redis 的 key } } // 後續請求 - 從 Redis 讀取 tokens const sessionKey = token.sessionKey as string || `session:${userId}` const sessionDataStr = await redis.get(sessionKey) if (!sessionDataStr) { console.error('[JWT CALLBACK] Session not found in Redis:', sessionKey) return { ...token, error: 'SessionNotFound' } } const sessionData = JSON.parse(sessionDataStr) // 檢查 access_token 是否即將過期 (提前 1 分鐘刷新) const now = Math.floor(Date.now() / 1000) const expiresAt = sessionData.expiresAt as number if (now < expiresAt - 60) { // Token 仍有效 - 更新 Redis TTL (Sliding Expiration) await redis.expire(sessionKey, 8 * 60 * 60) console.log('[JWT CALLBACK] Token valid, TTL refreshed') return token } // Token 即將過期 - 使用 refresh_token 更新 console.log('[JWT CALLBACK] Refreshing token...') try { const response = await fetch( `${process.env.NEXT_PUBLIC_KEYCLOAK_URL}/realms/${process.env.NEXT_PUBLIC_KEYCLOAK_REALM}/protocol/openid-connect/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: process.env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID!, client_secret: process.env.KEYCLOAK_CLIENT_SECRET!, grant_type: 'refresh_token', refresh_token: sessionData.refreshToken, }), } ) if (!response.ok) { console.error('[JWT CALLBACK] Refresh failed:', response.status) await redis.del(sessionKey) // 刪除無效 session return { ...token, error: 'RefreshTokenError' } } const refreshedTokens = await response.json() // 更新 Redis session const updatedSessionData = { ...sessionData, accessToken: refreshedTokens.access_token, refreshToken: refreshedTokens.refresh_token ?? sessionData.refreshToken, expiresAt: now + refreshedTokens.expires_in, } await redis.setex(sessionKey, 8 * 60 * 60, JSON.stringify(updatedSessionData)) console.log('[JWT CALLBACK] Token refreshed and updated in Redis') return token } catch (error) { console.error('[JWT CALLBACK] Refresh error:', error) await redis.del(sessionKey) return { ...token, error: 'RefreshTokenError' } } }, async session({ session, token }) { console.log('[SESSION CALLBACK] Called') // 處理錯誤狀態 if (token?.error) { console.error('[SESSION CALLBACK] Session error:', token.error) return { ...session, error: token.error } } if (!token.sub) { return { ...session, error: 'InvalidSession' } } // 從 Redis 讀取完整 session 資料 const sessionKey = token.sessionKey as string || `session:${token.sub}` const sessionDataStr = await redis.get(sessionKey) if (!sessionDataStr) { console.error('[SESSION CALLBACK] Session expired or not found') return { ...session, error: 'SessionExpired' } } const sessionData = JSON.parse(sessionDataStr) // 填充 session 物件 (前端可用) if (session.user) { session.user.id = sessionData.sub session.user.email = sessionData.email session.user.name = sessionData.name session.accessToken = sessionData.accessToken // ← 從 Redis 取得! // Tenant 資訊 (如果需要可以存在 Redis) if (sessionData.tenant) { ;(session.user as any).tenant = sessionData.tenant } console.log('[SESSION CALLBACK] Session loaded from Redis') } return session }, }, secret: process.env.NEXTAUTH_SECRET, debug: true, } const handler = NextAuth(authOptions) export { handler as GET, handler as POST }