diff options
| author | joonhoekim <26rote@gmail.com> | 2025-06-20 11:47:15 +0000 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-06-20 11:47:15 +0000 |
| commit | abd9f950bbd95b9ad713a26d3fd8a7e0282b7c51 (patch) | |
| tree | aafc71d5ff23962c2d6d5e902c66ee070b7ac068 /app/api/saml/callback/route.ts | |
| parent | 994defd6446ce20c4b4e0d6cc91688b0e64230a4 (diff) | |
(김준회) SAML 2.0 SSO (Knox Portal) 추가
Diffstat (limited to 'app/api/saml/callback/route.ts')
| -rw-r--r-- | app/api/saml/callback/route.ts | 145 |
1 files changed, 145 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..d13edc4a --- /dev/null +++ b/app/api/saml/callback/route.ts @@ -0,0 +1,145 @@ +import { NextRequest, NextResponse } from 'next/server' +import { validateSAMLResponse, mapSAMLProfileToUser } from '../../auth/[...nextauth]/saml/utils' + +// IdP가 인증처리 후 POST 요청을 콜백 URL로 날려준다. +// 이 라우트가 그 요청을 받아준다. +// GET 요청시 SP 메타데이터를 반환해주는데, 이건 필요 없으면 지우면 된다. + +export async function POST(request: NextRequest) { + try { + console.log('🔐 SAML Callback received at /api/saml/callback') + + // FormData에서 SAML Response 추출 + const formData = await request.formData() + const samlResponse = formData.get('SAMLResponse') as string + const relayState = formData.get('RelayState') as string + + console.log('📨 SAML Response received:', { + hasResponse: !!samlResponse, + relayState: relayState || 'none', + responseLength: samlResponse?.length || 0 + }) + + // 🔍 SAML Response 디코딩 및 분석 + if (samlResponse) { + try { + console.log('🔍 SAML Response 분석:') + console.log('1️⃣ 원본 SAMLResponse (일부):', samlResponse.substring(0, 100) + '...') + + try { + // Base64 디코딩 시도 + const base64Decoded = Buffer.from(samlResponse, 'base64').toString('utf-8') + console.log('2️⃣ Base64 디코딩된 XML:') + console.log('───────────────────────────────────') + console.log(base64Decoded) + console.log('───────────────────────────────────') + + // XML 구조 분석 + const xmlLines = base64Decoded.split('\n').filter(line => line.trim()) + console.log('3️⃣ 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')) { + console.log(` ${index + 1}: ${trimmed}`) + } + }) + + } catch (decodeError) { + console.log('2️⃣ Base64 디코딩 실패:', decodeError.message) + console.log(' SAMLResponse가 다른 형식으로 인코딩되었을 수 있습니다.') + } + } catch (analysisError) { + console.log('⚠️ SAML Response 분석 중 오류:', analysisError.message) + } + } + + if (!samlResponse) { + console.error('❌ No SAML Response found in request') + return NextResponse.redirect( + new URL('/ko/auth/error?error=missing_saml_response', request.url) + ) + } + + // SAML Response 검증 및 파싱 + let samlProfile + try { + console.log('🔍 Starting SAML Response validation...') + samlProfile = await validateSAMLResponse(samlResponse) + console.log('✅ SAML Response validated successfully:', { + nameId: samlProfile.nameID, + attributes: Object.keys(samlProfile.attributes || {}) + }) + } catch (error) { + console.error('❌ 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() + }) + + // 더 상세한 에러 정보를 포함한 리다이렉트 + const errorMessage = error instanceof Error ? error.message : 'Unknown SAML validation error' + return NextResponse.redirect( + new URL(`/ko/auth/error?error=invalid_saml_response&detail=${encodeURIComponent(errorMessage)}`, request.url) + ) + } + + // SAML 프로필을 사용자 객체로 매핑 + const user = mapSAMLProfileToUser(samlProfile) + console.log('👤 Mapped user:', { + id: user.id, + email: user.email, + name: user.name, + companyId: user.companyId + }) + + // NextAuth SAML Provider를 통한 직접 로그인 처리 + // 서버 사이드에서 NextAuth signIn 호출은 불가능하므로 + // NextAuth 콜백 엔드포인트로 리다이렉트 + const nextAuthCallbackUrl = new URL('/api/auth/callback/credentials-saml', request.url) + + // 사용자 데이터를 query parameter로 전달 + nextAuthCallbackUrl.searchParams.set('user', JSON.stringify(user)) + if (relayState) { + nextAuthCallbackUrl.searchParams.set('state', relayState) + } + + console.log('🔄 Redirecting to NextAuth callback:', nextAuthCallbackUrl.toString()) + return NextResponse.redirect(nextAuthCallbackUrl) + + } catch (error) { + console.error('💥 SAML Callback processing failed:', error) + return NextResponse.redirect( + new URL('/ko/auth/error?error=callback_processing_failed', request.url) + ) + } +} + +// GET 요청에 대한 메타데이터 반환 +// 필요 없으면 굳이 노출할 필요 없음. 다만 SOAP WDSL과 유사하게 이렇게 스펙을 제공하는 건 SAML 2.0 구현 표준임. 필요하다면 IdP에게만 제공되도록 추가 처리하자. +export async function GET(_request: NextRequest) { + 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) { + console.error('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 |
