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
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
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 })
}
}
|