diff options
Diffstat (limited to 'app/api/saml/callback')
| -rw-r--r-- | app/api/saml/callback/route.ts | 209 |
1 files changed, 209 insertions, 0 deletions
diff --git a/app/api/saml/callback/route.ts b/app/api/saml/callback/route.ts new file mode 100644 index 00000000..c0290e71 --- /dev/null +++ b/app/api/saml/callback/route.ts @@ -0,0 +1,209 @@ +import { NextRequest, NextResponse } from 'next/server' +import { validateSAMLResponse, mapSAMLProfileToUser } from '../../auth/[...nextauth]/saml/utils' +import { + authenticateSAMLUser, + createNextAuthToken, + getSessionCookieName +} from '../../auth/[...nextauth]/saml/provider' +import { debugLog, debugError, debugSuccess, debugProcess } from '@/lib/debug-utils' + +// IdP가 인증처리 후 POST 요청을 콜백 URL로 날려준다. +// 이 라우트가 그 요청을 받아준다. +// GET 요청시 SP 메타데이터를 반환해주는데, 이건 필요 없으면 지우면 된다. + +export async function POST(request: NextRequest) { + // 안전한 baseUrl - 모든 리다이렉트에서 사용 + const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'; + + try { + const isMockMode = process.env.SAML_MOCKING_IDP === 'true'; + debugProcess(`SAML Callback received at /api/saml/callback ${isMockMode ? '(🎭 Mock Mode)' : ''}`) + debugLog('Request info:', { + nextUrl: request.nextUrl?.toString(), + mockMode: isMockMode, + baseUrl: baseUrl + }) + + // FormData에서 SAML Response 추출 + const formData = await request.formData() + const samlResponse = formData.get('SAMLResponse') as string + const relayState = formData.get('RelayState') as string + + debugLog('SAML Response received:', { + hasResponse: !!samlResponse, + relayState: relayState || 'none', + responseLength: samlResponse?.length || 0 + }) + + // 🔍 SAML Response 디코딩 및 분석 + if (samlResponse) { + try { + debugLog('🔍 SAML Response 분석:') + debugLog('원본 SAMLResponse (일부):', samlResponse.substring(0, 100) + '...') + + try { + // Base64 디코딩 시도 + const base64Decoded = Buffer.from(samlResponse, 'base64').toString('utf-8') + debugLog('Base64 디코딩된 XML:') + debugLog('───────────────────────────────────') + debugLog(base64Decoded) + debugLog('───────────────────────────────────') + + // XML 구조 분석 + const xmlLines = base64Decoded.split('\n').filter(line => line.trim()) + debugLog('XML 구조 요약:') + xmlLines.forEach((line, index) => { + const trimmed = line.trim() + if (trimmed.includes('<saml') || trimmed.includes('<samlp') || + trimmed.includes('ID=') || trimmed.includes('InResponseTo=') || + trimmed.includes('<saml:Assertion') || trimmed.includes('<saml:Subject') || + trimmed.includes('<saml:NameID') || trimmed.includes('<saml:Attribute')) { + debugLog(` ${index + 1}: ${trimmed}`) + } + }) + + } catch (decodeError) { + debugError('Base64 디코딩 실패:', (decodeError as Error).message) + debugLog('SAMLResponse가 다른 형식으로 인코딩되었을 수 있습니다.') + } + } catch (analysisError) { + debugError('SAML Response 분석 중 오류:', (analysisError as Error).message) + } + } + + if (!samlResponse) { + debugError('No SAML Response found in request') + return NextResponse.redirect(new URL('/ko/evcp', baseUrl), 303) + } + + // SAML Response 검증 및 파싱 + let samlProfile + try { + debugProcess('Starting SAML Response validation...') + samlProfile = await validateSAMLResponse(samlResponse) + debugSuccess('SAML Response validated successfully:', { + nameId: samlProfile.nameID, + attributes: Object.keys(samlProfile.attributes || {}) + }) + } catch (error) { + debugError('SAML Response validation failed:', { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + samlResponseLength: samlResponse.length, + timestamp: new Date().toISOString() + }) + + // SAML 검증 실패 시 evcp 페이지로 리다이렉트 + return NextResponse.redirect(new URL('/ko/evcp', baseUrl), 303) + } + + // SAML 프로필을 사용자 객체로 매핑 + const mappedUser = mapSAMLProfileToUser(samlProfile) + debugLog('Mapped user:', { + id: mappedUser.id, + email: mappedUser.email, + name: mappedUser.name, + }) + + // SAMLProvider를 통한 사용자 인증 (JIT 포함) + debugProcess('Authenticating user through SAMLProvider...') + const authenticatedUser = await authenticateSAMLUser(mappedUser) + + debugLog('Authenticated user:', authenticatedUser) + + if (!authenticatedUser) { + debugError('SAML user authentication failed') + return NextResponse.redirect(new URL('/ko/evcp', baseUrl), 303) + } + + debugSuccess('User authenticated successfully:', { + id: authenticatedUser.id, + email: authenticatedUser.email, + name: authenticatedUser.name + }) + + // NextAuth JWT 토큰 생성 + const encodedToken = await createNextAuthToken(authenticatedUser) + + // NextAuth 세션 쿠키 설정 + const cookieName = getSessionCookieName() + + // RelayState를 활용한 스마트 리다이렉트 + let redirectPath = '/ko/evcp' // 기본값 + + // RelayState 안전 처리 - null, 'null', undefined, 빈 문자열 모두 처리 + const isValidRelayState = relayState && + relayState !== 'null' && + relayState !== 'undefined' && + relayState.trim() !== '' && + typeof relayState === 'string'; + + if (isValidRelayState) { + debugLog('Using RelayState for redirect:', relayState) + // RelayState가 유효한 경로인지 확인 + if (relayState.startsWith('/') && !relayState.includes('//')) { + redirectPath = relayState + } else { + debugLog('Invalid RelayState format, using default:', relayState) + } + } else { + debugLog('No valid RelayState, using default path. RelayState value:', relayState) + } + + // URL 생성 전 최종 안전성 검사 + if (!redirectPath || typeof redirectPath !== 'string' || redirectPath.trim() === '') { + redirectPath = '/ko/evcp' // 안전한 기본값으로 재설정 + debugLog('redirectPath was invalid, reset to default:', redirectPath) + } + + debugLog('Final redirect path:', redirectPath) + + // POST 요청에 대한 응답으로는 303 See Other를 사용하여 GET으로 강제 변환 + const response = NextResponse.redirect(new URL(redirectPath, baseUrl), 303) + + // NEXTAUTH_URL이 HTTPS인 경우에만 secure 쿠키 사용 + const isHttps = baseUrl.startsWith('https://'); + + response.cookies.set(cookieName, encodedToken, { + httpOnly: true, + secure: isHttps, + sameSite: 'lax', + path: '/', + maxAge: 30 * 24 * 60 * 60 // 30일 + }) + + debugSuccess('SAML login successful, session created for:', authenticatedUser.email) + debugProcess('Authentication flow completed through NextAuth standard pipeline') + return response + + } catch (error) { + debugError('SAML Callback processing failed:', error) + return NextResponse.redirect(new URL('/ko/evcp', baseUrl), 303) + } +} + +// GET 요청에 대한 메타데이터 반환 +// 필요 없으면 굳이 노출할 필요 없음. 다만 SOAP WDSL과 유사하게 이렇게 스펙을 제공하는 건 SAML 2.0 구현 표준임. 필요하다면 IdP에게만 제공되도록 추가 처리하자. +export async function GET() { + try { + // GET 요청은 SP 메타데이터 반환 (정적 파일) + const fs = await import('fs/promises') + const path = await import('path') + + const metadataPath = path.join(process.cwd(), 'config/saml/sp_metadata.xml') + const metadata = await fs.readFile(metadataPath, 'utf-8') + + return new NextResponse(metadata, { + headers: { + 'Content-Type': 'application/xml', + 'Cache-Control': 'public, max-age=36000' // 10시간 캐시 + } + }) + } catch (error) { + debugError('Error reading SP metadata file:', error) + return NextResponse.json({ + error: 'Failed to read SP metadata file', + details: error instanceof Error ? error.message : 'Unknown error' + }, { status: 500 }) + } +}
\ No newline at end of file |
