summaryrefslogtreecommitdiff
path: root/app/api/saml/callback
diff options
context:
space:
mode:
Diffstat (limited to 'app/api/saml/callback')
-rw-r--r--app/api/saml/callback/route.ts209
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