1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
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 })
}
}
|