summaryrefslogtreecommitdiff
path: root/app/api
diff options
context:
space:
mode:
Diffstat (limited to 'app/api')
-rw-r--r--app/api/auth/[...nextauth]/route.ts63
-rw-r--r--app/api/auth/[...nextauth]/saml/provider.ts259
-rw-r--r--app/api/auth/[...nextauth]/saml/utils.ts485
-rw-r--r--app/api/auth/saml/authn-request/route.ts98
-rw-r--r--app/api/auth/saml/mock-idp/route.ts141
-rw-r--r--app/api/saml/callback/route.ts209
6 files changed, 1225 insertions, 30 deletions
diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts
index 4673d8ae..969263ea 100644
--- a/app/api/auth/[...nextauth]/route.ts
+++ b/app/api/auth/[...nextauth]/route.ts
@@ -9,6 +9,7 @@ import { JWT } from "next-auth/jwt"
import CredentialsProvider from 'next-auth/providers/credentials'
import { verifyExternalCredentials, verifyOtp, verifyOtpTemp } from '@/lib/users/verifyOtp'
+import { SAMLProvider } from './saml/provider'
// 1) 모듈 보강 선언
declare module "next-auth" {
@@ -44,6 +45,18 @@ declare module "next-auth" {
}
}
+// JWT 타입 확장
+declare module "next-auth/jwt" {
+ interface JWT {
+ id?: string
+ imageUrl?: string | null
+ companyId?: number | null
+ techCompanyId?: number | null
+ domain?: string | null
+ }
+}
+
+
// (2) authOptions에 NextAuthOptions 타입 지정
export const authOptions: NextAuthOptions = {
providers: [
@@ -68,36 +81,11 @@ export const authOptions: NextAuthOptions = {
imageUrl: user.imageUrl ?? null,
name: user.name, // DB에서 가져온 실제 이름
companyId: user.companyId, // DB에서 가져온 실제 이름
- techCompanyId: (user as any).techCompanyId, // techVendor ID
+ techCompanyId: user.techCompanyId as number | undefined, // techVendor ID
domain: user.domain, // DB에서 가져온 실제 이름
}
},
}),
- // CredentialsProvider({
- // name: 'Credentials',
- // credentials: {
- // email: { label: 'Email', type: 'text' },
- // code: { label: 'OTP code', type: 'text' },
- // },
- // async authorize(credentials, req) {
- // const { email, code } = credentials ?? {}
-
- // // OTP 검증
- // const user = await verifyOtp(email ?? '', code ?? '')
- // if (!user) {
- // return null
- // }
-
- // return {
- // id: String(user.id ?? email ?? "dts"),
- // email: user.email,
- // imageUrl: user.imageUrl ?? null,
- // name: user.name, // DB에서 가져온 실제 이름
- // companyId: user.companyId, // DB에서 가져온 실제 이름
- // domain: user.domain, // DB에서 가져온 실제 이름
- // }
- // },
- // }),
// 새로 추가할 ID/비밀번호 provider
CredentialsProvider({
id: 'credentials-password',
@@ -136,6 +124,22 @@ export const authOptions: NextAuthOptions = {
return null;
}
}
+ }),
+ // SAML Provider 추가 (CredentialsProvider 기반)
+ SAMLProvider({
+ id: "credentials-saml",
+ name: "SAML SSO",
+ idp: {
+ sso_login_url: process.env.SAML_IDP_SSO_URL!,
+ sso_logout_url: process.env.SAML_IDP_SLO_URL || '', // 선택적
+ certificates: [process.env.SAML_IDP_CERT!]
+ },
+ sp: {
+ entity_id: process.env.SAML_SP_ENTITY_ID!,
+ private_key: process.env.SAML_SP_PRIVATE_KEY || '',
+ certificate: process.env.SAML_SP_CERT || '',
+ assert_endpoint: process.env.SAML_SP_CALLBACK_URL || `${process.env.NEXTAUTH_URL}/api/saml/callback`
+ }
})
],
// (3) session.strategy는 'jwt'가 되도록 선언
@@ -155,7 +159,7 @@ export const authOptions: NextAuthOptions = {
token.companyId = user.companyId
token.techCompanyId = user.techCompanyId
token.domain = user.domain
- ; (token as any).imageUrl = (user as any).imageUrl
+ token.imageUrl = user.imageUrl
}
return token
},
@@ -168,7 +172,7 @@ export const authOptions: NextAuthOptions = {
domain: token.domain as string,
companyId: token.companyId as number,
techCompanyId: token.techCompanyId as number,
- image: (token as any).imageUrl ?? null
+ image: token.imageUrl ?? null
}
}
return session
@@ -185,8 +189,7 @@ export const authOptions: NextAuthOptions = {
}
// 그 외에는 baseUrl로 리다이렉트
return baseUrl;
- }
-
+ },
},
}
diff --git a/app/api/auth/[...nextauth]/saml/provider.ts b/app/api/auth/[...nextauth]/saml/provider.ts
new file mode 100644
index 00000000..8486a690
--- /dev/null
+++ b/app/api/auth/[...nextauth]/saml/provider.ts
@@ -0,0 +1,259 @@
+import CredentialsProvider from "next-auth/providers/credentials"
+import { getOrCreateSAMLUser, validateSAMLUserData } from '@/lib/users/saml-service'
+import { encode } from 'next-auth/jwt'
+import type { User } from 'next-auth'
+import type { SAMLUser } from './utils'
+import { debugLog, debugError, debugSuccess, debugProcess } from '@/lib/debug-utils'
+
+interface SAMLProviderOptions {
+ id: string
+ name: string
+ idp: {
+ sso_login_url: string
+ sso_logout_url: string
+ certificates: string[]
+ }
+ sp: {
+ entity_id: string
+ private_key: string
+ certificate: string
+ assert_endpoint: string
+ }
+}
+
+export function SAMLProvider(options: SAMLProviderOptions) {
+ return CredentialsProvider({
+ id: options.id,
+ name: options.name,
+ credentials: {
+ user: {
+ label: "User Data",
+ type: "text"
+ }
+ },
+ async authorize(credentials) {
+ debugLog('🔍 SAMLProvider.authorize called with credentials:', credentials);
+
+ try {
+ debugLog('🔍 Checking credentials.user:', {
+ hasCredentials: !!credentials,
+ hasUser: !!credentials?.user,
+ userType: typeof credentials?.user,
+ userValue: credentials?.user?.substring?.(0, 100) + '...'
+ });
+
+ if (!credentials?.user) {
+ debugError('No user data provided in credentials')
+ return null
+ }
+
+ debugProcess('SAML Provider: Processing user data')
+
+ // 사용자 데이터 파싱 (UTF-8 처리 개선)
+ const userDataString = credentials.user
+ debugLog('🔤 Raw user data string:', userDataString.substring(0, 200) + '...')
+
+ let userData;
+ try {
+ userData = JSON.parse(userDataString);
+ debugSuccess('JSON parsing successful:', userData);
+ } catch (parseError) {
+ debugError('JSON parsing failed:', parseError);
+ debugError('Raw string that failed to parse:', userDataString);
+ return null;
+ }
+
+ // 파싱된 데이터의 UTF-8 확인
+ debugLog('🔤 Parsed user data UTF-8 check:', {
+ name: userData.name,
+ nameLength: userData.name?.length,
+ charCodes: userData.name ? [...userData.name].map(c => c.charCodeAt(0)) : []
+ })
+
+ if (!userData.id || !userData.email) {
+ debugError('Invalid SAML user data:', userData)
+ return null
+ }
+
+ debugSuccess('SAML Provider: User authenticated successfully', {
+ id: userData.id,
+ email: userData.email,
+ name: userData.name
+ })
+
+ // 🔥 SAML 사용자 데이터 검증
+ debugProcess('Validating SAML user data structure...');
+ const isValidData = await validateSAMLUserData(userData)
+ debugLog('Validation result:', isValidData);
+ if (!isValidData) {
+ debugError('Invalid SAML user data structure:', userData)
+ return null
+ }
+
+ // 🔥 JIT (Just-In-Time) 사용자 생성 또는 조회
+ debugProcess('Creating/getting SAML user from database...');
+ const userCreateData = {
+ email: userData.email,
+ name: userData.name,
+ companyId: undefined,
+ techCompanyId: undefined,
+ domain: userData.domain
+ };
+ debugLog('User create data:', userCreateData);
+
+ const dbUser = await getOrCreateSAMLUser(userCreateData)
+ debugLog('Database user result:', dbUser);
+
+ if (!dbUser) {
+ debugError('Failed to get or create SAML user')
+ return null
+ }
+
+ // DB에서 가져온 실제 사용자 정보 반환
+ const userResult = {
+ id: String(dbUser.id), // DB의 실제 ID
+ name: dbUser.name, // DB의 실제 이름
+ email: dbUser.email, // DB의 실제 이메일
+ companyId: dbUser.companyId, // DB의 실제 회사 ID
+ techCompanyId: dbUser.techCompanyId, // DB의 실제 기술회사 ID
+ domain: dbUser.domain, // DB의 실제 도메인
+ imageUrl: dbUser.imageUrl, // DB의 실제 이미지 URL
+ }
+
+ debugSuccess('SAML Provider: Returning user data to NextAuth:', userResult)
+ return userResult
+ } catch (error) {
+ debugError('SAML Provider: Authentication failed', {
+ error: error instanceof Error ? error.message : String(error),
+ stack: error instanceof Error ? error.stack : undefined,
+ errorType: typeof error,
+ credentials: credentials
+ });
+ return null
+ }
+ }
+ })
+}
+
+// SAML 로그인 URL 생성 헬퍼 함수
+export function getSAMLLoginUrl(options: SAMLProviderOptions): string {
+ const params = new URLSearchParams({
+ SAMLRequest: 'placeholder', // 실제로는 createAuthnRequest()로 생성
+ RelayState: options.sp.assert_endpoint,
+ })
+
+ return `${options.idp.sso_login_url}?${params.toString()}`
+}
+
+// SAML 설정 검증
+export function validateSAMLOptions(options: SAMLProviderOptions): boolean {
+ const required = [
+ options.idp.sso_login_url,
+ options.sp.entity_id,
+ options.sp.assert_endpoint
+ ]
+
+ return required.every(field => field && field.length > 0)
+}
+
+// SAMLProvider의 authorize 함수를 직접 호출하기 위한 헬퍼
+export async function authenticateSAMLUser(userData: SAMLUser) {
+ debugLog('authenticateSAMLUser called with:', userData);
+
+ try {
+ // SAMLProvider 대신 직접 로직 실행 (Provider 래퍼 없이)
+ debugProcess('SAML User Authentication: Processing user data')
+
+ // 사용자 데이터 검증
+ if (!userData.id || !userData.email) {
+ debugError('Invalid SAML user data:', userData)
+ return null
+ }
+
+ debugSuccess('SAML User data validated successfully', {
+ id: userData.id,
+ email: userData.email,
+ name: userData.name
+ })
+
+ // 🔥 SAML 사용자 데이터 검증
+ debugLog('Validating SAML user data structure...');
+ const isValidData = await validateSAMLUserData(userData)
+ debugLog('Validation result:', isValidData);
+ if (!isValidData) {
+ debugError('Invalid SAML user data structure:', userData)
+ return null
+ }
+
+ // 🔥 JIT (Just-In-Time) 사용자 생성 또는 조회
+ debugLog('Creating/getting SAML user from database...');
+ const userCreateData = {
+ email: userData.email,
+ name: userData.name,
+ companyId: undefined,
+ techCompanyId: undefined,
+ domain: userData.domain
+ };
+ debugLog('User create data:', userCreateData);
+
+ const dbUser = await getOrCreateSAMLUser(userCreateData)
+ debugLog('Database user result:', dbUser);
+
+ if (!dbUser) {
+ debugError('Failed to get or create SAML user')
+ return null
+ }
+
+ // DB에서 가져온 실제 사용자 정보 반환
+ const userResult = {
+ id: String(dbUser.id), // DB의 실제 ID
+ name: dbUser.name, // DB의 실제 이름
+ email: dbUser.email, // DB의 실제 이메일
+ companyId: dbUser.companyId, // DB의 실제 회사 ID
+ techCompanyId: dbUser.techCompanyId, // DB의 실제 기술회사 ID
+ domain: dbUser.domain, // DB의 실제 도메인
+ imageUrl: dbUser.imageUrl, // DB의 실제 이미지 URL
+ }
+
+ debugSuccess('SAML User Authentication completed:', userResult)
+ return userResult;
+
+ } catch (error) {
+ debugError('authenticateSAMLUser error:', {
+ error: error instanceof Error ? error.message : String(error),
+ stack: error instanceof Error ? error.stack : undefined,
+ userData
+ });
+ return null;
+ }
+}
+
+// NextAuth JWT 토큰 생성 헬퍼
+export async function createNextAuthToken(user: User): Promise<string> {
+ const token = {
+ id: user.id,
+ email: user.email,
+ name: user.name,
+ companyId: user.companyId,
+ techCompanyId: user.techCompanyId,
+ domain: user.domain,
+ imageUrl: user.imageUrl,
+ iat: Math.floor(Date.now() / 1000),
+ exp: Math.floor(Date.now() / 1000) + (30 * 24 * 60 * 60) // 30일
+ };
+
+ const secret = process.env.NEXTAUTH_SECRET!;
+ return await encode({ token, secret });
+}
+
+// NextAuth 세션 쿠키 이름 가져오기
+export function getSessionCookieName(): string {
+ // NEXTAUTH_URL이 HTTPS인 경우에만 __Secure- 접두사 사용
+ const nextAuthUrl = process.env.NEXTAUTH_URL || '';
+ const isHttps = nextAuthUrl.startsWith('https://');
+
+ return isHttps
+ ? '__Secure-next-auth.session-token'
+ : 'next-auth.session-token';
+}
+ \ No newline at end of file
diff --git a/app/api/auth/[...nextauth]/saml/utils.ts b/app/api/auth/[...nextauth]/saml/utils.ts
new file mode 100644
index 00000000..a5bcfe7a
--- /dev/null
+++ b/app/api/auth/[...nextauth]/saml/utils.ts
@@ -0,0 +1,485 @@
+import { SAML, ValidateInResponseTo } from "@node-saml/node-saml";
+import {
+ getIDPMetadata,
+ normalizeCertificate,
+} from "@/lib/saml/idp-metadata";
+import {
+ getSPMetadata,
+} from "@/lib/saml/sp-metadata";
+import { debugLog, debugError, debugSuccess, debugProcess, debugMock } from '@/lib/debug-utils';
+
+export interface SAMLProfile {
+ nameID?: string;
+ nameIDFormat?: string;
+ attributes?: Record<string, string | string[]>; // 문자열 또는 배열 모두 지원
+ [key: string]: unknown;
+}
+
+export interface SAMLUser {
+ id: string;
+ email: string;
+ name: string;
+ companyId?: number;
+ techCompanyId?: number;
+ domain?: string;
+}
+
+// SAML 설정 생성 (sync 함수) - 환경변수 기반으로 변경했음
+export function createSAMLConfig() {
+ console.log("⚙️ Creating SAML configuration...");
+
+ try {
+ const idpMetadata = getIDPMetadata();
+ const spMetadata = getSPMetadata();
+
+ console.log("📋 IdP Metadata loaded:", {
+ entityId: idpMetadata.entityId,
+ ssoUrl: idpMetadata.ssoUrl,
+ organization: idpMetadata.organization,
+ wantAuthnRequestsSigned: idpMetadata.wantAuthnRequestsSigned,
+ });
+
+ console.log("📋 SP Metadata loaded:", {
+ entityId: spMetadata.entityId,
+ callbackUrl: spMetadata.callbackUrl,
+ authnRequestsSigned: spMetadata.authnRequestsSigned,
+ });
+
+ const config = {
+ callbackUrl: spMetadata.callbackUrl,
+ // IDP 메타데이터 기반 설정
+ entryPoint: idpMetadata.ssoUrl,
+ // SP Entity ID
+ issuer: spMetadata.entityId,
+ // IDP 인증서 (정규화된 PEM 형식)
+ idpCert: normalizeCertificate(idpMetadata.certificate),
+ privateKey: process.env.SAML_SP_PRIVATE_KEY,
+ // IdP에서 요구하는 설정
+ identifierFormat: idpMetadata.nameIdFormat,
+ signatureAlgorithm: "sha256" as const,
+ digestAlgorithm: "sha256",
+ // SP 메타데이터 설정
+ decryptionPvk: process.env.SAML_SP_PRIVATE_KEY,
+ publicCert: process.env.SAML_SP_CERT,
+ // IdP 메타데이터 기반 설정
+ wantAuthnResponseSigned: idpMetadata.wantAuthnRequestsSigned,
+ wantAssertionsSigned: spMetadata.wantAssertionsSigned,
+ validateInResponseTo: ValidateInResponseTo.never,
+ disableRequestedAuthnContext: true,
+ // HTTP-Redirect 바인딩 설정
+ authnRequestBinding: undefined, // HTTP-Redirect (GET) 사용 (기본값)
+ skipRequestCompression: false, // Deflate 압축 사용
+ // 추가 보안 설정
+ acceptedClockSkewMs: 5000, // 5초 클럭 차이 허용
+ forceAuthn: false,
+ // IDP Entity ID 설정
+ idpIssuer: idpMetadata.entityId,
+ };
+
+ console.log("✅ SAML Config created:", {
+ callbackUrl: config.callbackUrl,
+ entryPoint: config.entryPoint,
+ issuer: config.issuer,
+ idpIssuer: config.idpIssuer,
+ identifierFormat: config.identifierFormat,
+ hasIdpCert: !!config.idpCert,
+ hasPrivateKey: !!config.privateKey,
+ hasPublicCert: !!config.publicCert,
+ wantAuthnResponseSigned: config.wantAuthnResponseSigned,
+ wantAssertionsSigned: config.wantAssertionsSigned,
+ });
+
+ return config;
+ } catch (error) {
+ console.error("💥 Failed to create SAML Config:", error);
+ throw error;
+ }
+}
+
+// SAML AuthnRequest 생성 (서버 액션)
+export async function createAuthnRequest(relayState?: string): Promise<string> {
+ "use server";
+
+ console.log("SSO STEP 2: Create AuthnRequest", { relayState });
+
+ // Mock IdP 모드 체크
+ if (process.env.SAML_MOCKING_IDP === 'true') {
+ debugMock("Mock IdP mode enabled - simulating SAML response");
+ return createMockSAMLFlow(relayState);
+ }
+
+ try {
+ const config = createSAMLConfig();
+ console.log("SAML Config ready for AuthnRequest generation");
+
+ const saml = new SAML(config);
+ console.log("SAML instance created, generating authorize URL...");
+
+ const startTime = Date.now();
+ const authorizeUrl = await saml.getAuthorizeUrlAsync(
+ relayState || "", // RelayState - 원래 가려던 페이지
+ undefined, // host
+ {
+ additionalParams: {},
+ // additionalAuthorizeParams: {},
+ }
+ );
+ const endTime = Date.now();
+
+ // 🔍 SAML AuthnRequest 디코딩 및 분석
+ try {
+ const urlObj = new URL(authorizeUrl);
+ const samlRequest = urlObj.searchParams.get("SAMLRequest");
+
+ if (samlRequest) {
+ console.log("SAML AuthnRequest 분석:");
+ console.log("1️⃣ 원본 URL:", authorizeUrl);
+ console.log(
+ "2️⃣ URL 디코딩된 SAMLRequest:",
+ decodeURIComponent(samlRequest)
+ );
+
+ try {
+ // Base64 디코딩
+ const base64DecodedBuffer = Buffer.from(
+ decodeURIComponent(samlRequest),
+ "base64"
+ );
+ const base64DecodedString = base64DecodedBuffer.toString("utf-8");
+
+ // XML인지 확인 (XML은 '<'로 시작함)
+ if (base64DecodedString.trim().startsWith("<")) {
+ console.log("Base64 디코딩된 XML (압축 없음):");
+ console.log("───────────────────────────────────");
+ console.log(base64DecodedString);
+ console.log("───────────────────────────────────");
+
+ // XML 구조 분석
+ const xmlLines = base64DecodedString
+ .split("\n")
+ .filter((line) => line.trim());
+ console.log("XML 구조 요약:");
+ xmlLines.forEach((line, index) => {
+ const trimmed = line.trim();
+ if (
+ trimmed.includes("<saml") ||
+ trimmed.includes("<samlp") ||
+ trimmed.includes("ID=") ||
+ trimmed.includes("Destination=")
+ ) {
+ console.log(` ${index + 1}: ${trimmed}`);
+ }
+ });
+ } else {
+ // XML이 아니면 Deflate 압축된 것으로 간주
+ console.log(
+ "3️⃣ 압축된 바이너리 데이터 감지, Deflate 압축 해제 시도..."
+ );
+
+ try {
+ const zlib = await import("zlib");
+ const decompressed = zlib
+ .inflateRawSync(base64DecodedBuffer)
+ .toString("utf-8");
+ console.log("Deflate 압축 해제된 XML:");
+ console.log("───────────────────────────────────");
+ console.log(decompressed);
+ console.log("───────────────────────────────────");
+
+ // XML 구조 분석
+ const xmlLines = decompressed
+ .split("\n")
+ .filter((line: string) => line.trim());
+ console.log("XML 구조 요약:");
+ xmlLines.forEach((line: string, index: number) => {
+ const trimmed = line.trim();
+ if (
+ trimmed.includes("<saml") ||
+ trimmed.includes("<samlp") ||
+ trimmed.includes("ID=") ||
+ trimmed.includes("Destination=") ||
+ trimmed.includes("Issuer>") ||
+ trimmed.includes("AssertionConsumerServiceURL=")
+ ) {
+ console.log(` ${index + 1}: ${trimmed}`);
+ }
+ });
+
+ // 중요한 정보 추출
+ const idMatch = decompressed.match(/ID="([^"]+)"/);
+ const destinationMatch = decompressed.match(
+ /Destination="([^"]+)"/
+ );
+ const issuerMatch = decompressed.match(
+ /<saml:Issuer[^>]*>([^<]+)<\/saml:Issuer>/
+ );
+ const acsMatch = decompressed.match(
+ /AssertionConsumerServiceURL="([^"]+)"/
+ );
+
+ console.log("추출된 핵심 정보:");
+ console.log(` Request ID: ${idMatch ? idMatch[1] : "없음"}`);
+ console.log(
+ ` Destination: ${
+ destinationMatch ? destinationMatch[1] : "없음"
+ }`
+ );
+ console.log(
+ ` Issuer: ${issuerMatch ? issuerMatch[1] : "없음"}`
+ );
+ console.log(
+ ` Callback URL: ${acsMatch ? acsMatch[1] : "없음"}`
+ );
+ } catch (inflateError) {
+ console.log("❌ Deflate 압축 해제 실패:", (inflateError as Error).message);
+ console.log(
+ " 원본 바이너리 데이터 (hex):",
+ base64DecodedBuffer.toString("hex").substring(0, 100) + "..."
+ );
+ }
+ }
+ } catch (decodeError) {
+ console.log("❌ Base64 디코딩 실패:", (decodeError as Error).message);
+ }
+ }
+ } catch (analysisError) {
+ console.log("⚠️ SAML AuthnRequest 분석 중 오류:", (analysisError as Error).message);
+ }
+
+ console.log("✅ SAML AuthnRequest URL generated:", {
+ url: authorizeUrl.substring(0, 100) + "...",
+ fullUrlLength: authorizeUrl.length,
+ processingTime: `${endTime - startTime}ms`,
+ timestamp: new Date().toISOString(),
+ });
+
+ return authorizeUrl;
+ } catch (error) {
+ console.error("💥 Failed to create SAML AuthnRequest:", {
+ error: error instanceof Error ? error.message : "Unknown error",
+ stack: error instanceof Error ? error.stack : undefined,
+ timestamp: new Date().toISOString(),
+ });
+ throw error;
+ }
+}
+
+// SAML Response 검증 및 파싱 (서버 액션)
+export async function validateSAMLResponse(
+ samlResponse: string
+): Promise<SAMLProfile> {
+ "use server";
+
+ console.log("🔍 Starting SAML Response validation...");
+ console.log("📊 SAML Response info:", {
+ responseLength: samlResponse.length,
+ firstChars: samlResponse.substring(0, 50) + "...",
+ isBase64: /^[A-Za-z0-9+/]*={0,2}$/.test(samlResponse),
+ timestamp: new Date().toISOString(),
+ });
+
+ // Mock IdP 모드 체크
+ if (process.env.SAML_MOCKING_IDP === 'true') {
+ debugMock("Mock IdP mode - returning mock SAML profile");
+ return createMockSAMLProfile(samlResponse);
+ }
+
+ // 실제 SAML 검증 수행 (기본값)
+ console.log(
+ "🔐 Using Real SAML validation (SAML_MOCKING_IDP=false or not set)"
+ );
+
+ try {
+ console.log("⚙️ Creating SAML instance for validation...");
+ const saml = new SAML(createSAMLConfig());
+ console.log("✅ SAML instance created, starting validation...");
+
+ const startTime = Date.now();
+ const result = await saml.validatePostResponseAsync({
+ SAMLResponse: samlResponse,
+ });
+ const endTime = Date.now();
+
+ // node-saml 라이브러리는 { profile, loggedOut } 형태로 반환
+ const profile = result.profile;
+ if (!profile) {
+ throw new Error("No profile returned from SAML validation");
+ }
+
+ // SAMLProfile 형태로 변환 (타입 안전성 확보)
+ const samlProfile: SAMLProfile = {
+ nameID: profile.nameID as string | undefined,
+ nameIDFormat: profile.nameIDFormat as string | undefined,
+ attributes: profile.attributes as Record<string, string | string[]> | undefined,
+ };
+
+ console.log("✅ Real SAML Profile validated successfully:", {
+ nameID: samlProfile.nameID,
+ nameIDFormat: samlProfile.nameIDFormat,
+ attributeCount: Object.keys(samlProfile.attributes || {}).length,
+ attributes: Object.keys(samlProfile.attributes || {}),
+ processingTime: `${endTime - startTime}ms`,
+ timestamp: new Date().toISOString(),
+ });
+
+ return samlProfile;
+ } catch (error) {
+ console.error("❌ Real SAML validation error:", {
+ error: error instanceof Error ? error.message : "Unknown error",
+ stack: error instanceof Error ? error.stack : undefined,
+ samlResponseLength: samlResponse.length,
+ timestamp: new Date().toISOString(),
+ });
+ throw new Error(
+ `SAML validation failed: ${
+ error instanceof Error ? error.message : "Unknown error"
+ }`
+ );
+ }
+}
+
+// SAML Profile을 User 객체로 변환 (sync 함수)
+export function mapSAMLProfileToUser(profile: SAMLProfile): SAMLUser {
+ console.log("🔄 Mapping SAML profile to user:", {
+ nameID: profile.nameID,
+ attributes: profile.attributes,
+ });
+
+ // SAML attributes는 문자열 또는 배열 형태일 수 있음
+ const extractAttributeValue = (key: string): string | undefined => {
+ const value = profile.attributes?.[key];
+ if (Array.isArray(value)) {
+ return value.length > 0 ? value[0] : undefined;
+ }
+ return typeof value === 'string' ? value : undefined;
+ };
+
+ // 기본적으로 nameID를 사용하거나 attributes에서 추출
+ const id = profile.nameID || extractAttributeValue('id') || extractAttributeValue('sub');
+ const email = extractAttributeValue('email') || extractAttributeValue('emailAddress');
+ const name = extractAttributeValue('name') || extractAttributeValue('displayName') || extractAttributeValue('cn');
+
+ // 필수 필드 검증
+ if (!id) {
+ throw new Error('SAML profile missing required field: id (nameID)');
+ }
+ if (!email) {
+ throw new Error('SAML profile missing required field: email');
+ }
+ if (!name) {
+ throw new Error('SAML profile missing required field: name');
+ }
+
+ // UTF-8 문자열 정규화 및 검증
+ const normalizedName = name.normalize("NFC").trim();
+
+ // 한글이 깨진 경우 감지 및 로그
+ const hasInvalidChars = /[\uFFFD\x00-\x1F\x7F-\x9F]/.test(normalizedName);
+ if (hasInvalidChars) {
+ console.warn("⚠️ Invalid UTF-8 characters detected in name:", {
+ originalName: name,
+ normalizedName,
+ charCodes: [...normalizedName].map((c) => c.charCodeAt(0)),
+ hexDump: [...normalizedName]
+ .map((c) => "\\x" + c.charCodeAt(0).toString(16).padStart(2, "0"))
+ .join(""),
+ });
+ }
+
+ // 회사 정보는 SSO 로그인 시 없음 (evcp 도메인)
+ const companyId = undefined;
+ const techCompanyId = undefined;
+ const domain = 'evcp';
+
+ const user: SAMLUser = {
+ id,
+ email,
+ name: normalizedName,
+ companyId,
+ techCompanyId,
+ domain,
+ };
+
+ console.log("👤 Mapped user object:", JSON.stringify(user));
+
+ return user;
+}
+
+// Mock SAML 플로우 생성 (테스트용)
+function createMockSAMLFlow(relayState?: string): string {
+ debugMock("Creating mock SAML flow...", { relayState });
+
+ // Mock 모드에서는 Mock IdP 엔드포인트로 리다이렉션
+ const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
+ let mockIdpUrl = `${baseUrl}/api/auth/saml/mock-idp`;
+
+ // RelayState가 있으면 URL 파라미터로 전달
+ if (relayState) {
+ mockIdpUrl += `?RelayState=${encodeURIComponent(relayState)}`;
+ }
+
+ debugMock("Mock SAML Flow - redirecting to Mock IdP:", mockIdpUrl);
+
+ return mockIdpUrl;
+}
+
+// Mock SAML Profile 생성 (테스트용)
+function createMockSAMLProfile(samlResponse: string): SAMLProfile {
+ console.log("🎭 Creating mock SAML profile from response...");
+
+ try {
+ // SAML Response가 우리가 생성한 Mock인지 확인
+ const decodedXML = Buffer.from(samlResponse, 'base64').toString('utf-8');
+ const isMockResponse = decodedXML.includes('MockIdP');
+
+ if (!isMockResponse) {
+ console.warn("⚠️ Mock mode enabled but received non-mock SAML Response");
+ }
+
+ console.log("🎭 Mock SAML XML preview:", decodedXML.substring(0, 200) + "...");
+ } catch (error) {
+ console.warn("⚠️ Could not decode SAML Response for mock analysis:", (error as Error).message);
+ }
+
+ // Mock SAML Profile 반환 (실제 SAML Response와 일치하도록 문자열 형태)
+ const mockProfile: SAMLProfile = {
+ nameID: "testuser@samsung.com",
+ nameIDFormat: "urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress",
+ attributes: {
+ email: "testuser@samsung.com",
+ name: "테스트 사용자",
+ displayName: "Test User Samsung",
+ // 추가 테스트 속성들
+ department: "개발팀",
+ employeeId: "TEST001",
+ mobile: "010-1234-5678"
+ }
+ };
+
+ console.log("🎭 Mock SAML Profile created:", {
+ nameID: mockProfile.nameID,
+ nameIDFormat: mockProfile.nameIDFormat,
+ attributeCount: Object.keys(mockProfile.attributes || {}).length,
+ attributes: Object.keys(mockProfile.attributes || {}),
+ timestamp: new Date().toISOString(),
+ });
+
+ return mockProfile;
+}
+
+// SAML 로그아웃 URL 생성 (서버 액션)
+// 로그아웃 지원 안함. 일단 구조만 유사하게 작성해둠.
+export async function createLogoutRequest(nameID: string): Promise<string> {
+ "use server";
+
+ const saml = new SAML(createSAMLConfig());
+ // Profile 객체 형태로 전달
+ const profile = { nameID };
+ return await saml.getLogoutUrlAsync(
+ profile,
+ "", // RelayState
+ {
+ nameIDFormat: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
+ }
+ );
+}
diff --git a/app/api/auth/saml/authn-request/route.ts b/app/api/auth/saml/authn-request/route.ts
new file mode 100644
index 00000000..6544a765
--- /dev/null
+++ b/app/api/auth/saml/authn-request/route.ts
@@ -0,0 +1,98 @@
+/**
+ * SAML 2.0 SSO AuthnRequest 생성 API
+ *
+ * 역할:
+ * - 프론트엔드에서 SAML 로그인 URL을 요청할 때 사용
+ * - SAML AuthnRequest를 생성하고 IdP 로그인 URL 반환
+ * - Mock 모드 지원으로 개발/테스트 환경에서 시뮬레이션 가능
+ *
+ * 플로우:
+ * 1. 사용자가 "Knox SSO로 로그인" 버튼 클릭
+ * 2. 프론트엔드에서 이 API 호출
+ * 3. SAML AuthnRequest URL 생성 후 반환
+ * 4. 프론트엔드에서 해당 URL로 리다이렉트
+ * 5. IdP에서 인증 후 /api/saml/callback으로 SAML Response 전송
+ */
+
+import { NextResponse } from 'next/server'
+import { createAuthnRequest } from '../../[...nextauth]/saml/utils'
+import { debugLog, debugError, debugSuccess, debugProcess } from '@/lib/debug-utils'
+
+// SAML 환경변수 상태 체크
+function validateSAMLEnvironment() {
+ const samlEnvironment = {
+ NODE_ENV: process.env.NODE_ENV,
+ SAML_MOCKING_IDP: process.env.SAML_MOCKING_IDP,
+ NEXTAUTH_URL: process.env.NEXTAUTH_URL,
+ SAML_SP_PRIVATE_KEY: process.env.SAML_SP_PRIVATE_KEY ? '✅ Set' : '❌ Missing',
+ SAML_SP_CERT: process.env.SAML_SP_CERT ? '✅ Set' : '❌ Missing',
+ }
+
+ debugLog('📊 SAML Environment check:', JSON.stringify(samlEnvironment, null, 2))
+
+ // 필수 환경변수 검증
+ const missingVars = []
+ if (!process.env.NEXTAUTH_URL) missingVars.push('NEXTAUTH_URL')
+
+ // 키 없어도 구현 가능해서 주석 처리함.
+ // if (!process.env.SAML_SP_PRIVATE_KEY) missingVars.push('SAML_SP_PRIVATE_KEY')
+ // if (!process.env.SAML_SP_CERT) missingVars.push('SAML_SP_CERT')
+
+ if (missingVars.length > 0) {
+ throw new Error(`Missing required SAML environment variables: ${missingVars.join(', ')}`)
+ }
+
+ return samlEnvironment
+}
+
+/**
+ * SAML AuthnRequest URL 생성 엔드포인트
+ *
+ * @returns {JSON} { loginUrl: string, success: boolean, isThisMocking?: boolean }
+ */
+export async function GET(request: Request) {
+ debugProcess('🚀 SAML AuthnRequest API started')
+
+ try {
+ // URL에서 RelayState 매개변수 추출
+ const url = new URL(request.url)
+ const relayState = url.searchParams.get('relayState')
+
+ debugLog('RelayState parameter:', relayState)
+
+ // 환경변수 검증
+ const environment = validateSAMLEnvironment()
+
+ debugProcess('SSO STEP 1: Create AuthnRequest')
+
+ const startTime = Date.now()
+ const loginUrl = await createAuthnRequest(relayState || undefined)
+ const endTime = Date.now()
+
+ debugSuccess('SAML AuthnRequest created successfully:', {
+ url: loginUrl.substring(0, 100) + '...',
+ urlLength: loginUrl.length,
+ processingTime: `${endTime - startTime}ms`,
+ mockMode: environment.SAML_MOCKING_IDP === 'true',
+ timestamp: new Date().toISOString()
+ })
+
+ return NextResponse.json({
+ loginUrl,
+ success: true,
+ isThisMocking: environment.SAML_MOCKING_IDP === 'true'
+ })
+ } catch (error) {
+ debugError('Failed to create SAML AuthnRequest:', {
+ error: error instanceof Error ? error.message : 'Unknown error',
+ stack: error instanceof Error ? error.stack : undefined,
+ timestamp: new Date().toISOString()
+ })
+
+ return NextResponse.json({
+ error: 'Failed to create SAML AuthnRequest',
+ details: error instanceof Error ? error.message : 'Unknown error',
+ success: false
+ }, { status: 500 })
+ }
+}
diff --git a/app/api/auth/saml/mock-idp/route.ts b/app/api/auth/saml/mock-idp/route.ts
new file mode 100644
index 00000000..eccb6035
--- /dev/null
+++ b/app/api/auth/saml/mock-idp/route.ts
@@ -0,0 +1,141 @@
+import { NextRequest, NextResponse } from 'next/server'
+
+// Mock IdP 엔드포인트 - SAML Response HTML 폼 반환
+export async function GET(request: NextRequest) {
+ try {
+ // RelayState 파라미터 추출
+ const url = new URL(request.url)
+ const relayState = url.searchParams.get('RelayState') || 'mock_test'
+
+ console.log('🎭 Mock IdP endpoint accessed', { relayState });
+
+ // Mock SAML Response 데이터 (실제 형태와 일치하도록 문자열 형태)
+ const mockSAMLResponseData = {
+ nameID: "testuser@samsung.com",
+ nameIDFormat: "urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress",
+ attributes: {
+ email: "testuser@samsung.com",
+ name: "홍길동",
+ }
+ };
+
+ // Mock XML SAML Response 생성
+ const mockXML = `
+ <samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
+ ID="_mock_response_${Date.now()}"
+ Version="2.0"
+ IssueInstant="${new Date().toISOString()}">
+ <saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">MockIdP</saml:Issuer>
+ <samlp:Status>
+ <samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
+ </samlp:Status>
+ <saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
+ ID="_mock_assertion_${Date.now()}"
+ Version="2.0"
+ IssueInstant="${new Date().toISOString()}">
+ <saml:Issuer>MockIdP</saml:Issuer>
+ <saml:Subject>
+ <saml:NameID Format="${mockSAMLResponseData.nameIDFormat}">${mockSAMLResponseData.nameID}</saml:NameID>
+ </saml:Subject>
+ <saml:AttributeStatement>
+ <saml:Attribute Name="email">
+ <saml:AttributeValue>${mockSAMLResponseData.attributes.email}</saml:AttributeValue>
+ </saml:Attribute>
+ <saml:Attribute Name="name">
+ <saml:AttributeValue>${mockSAMLResponseData.attributes.name}</saml:AttributeValue>
+ </saml:Attribute>
+ </saml:AttributeStatement>
+ </saml:Assertion>
+ </samlp:Response>
+ `.trim();
+
+ // Base64 인코딩
+ const encodedSAMLResponse = Buffer.from(mockXML, 'utf-8').toString('base64');
+
+ console.log("🎭 Mock SAML Response created:", {
+ nameID: mockSAMLResponseData.nameID,
+ email: mockSAMLResponseData.attributes.email,
+ name: mockSAMLResponseData.attributes.name,
+ encodedLength: encodedSAMLResponse.length
+ });
+
+ // 콜백 URL로 POST 요청을 시뮬레이션하는 HTML 폼 반환
+ const callbackUrl = `${process.env.NEXTAUTH_URL}/api/saml/callback`; // process.env.SAML_SP_CALLBACK_URL
+
+ const mockFormHTML = `
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <title>Mock SAML IdP</title>
+ <style>
+ body { font-family: Arial, sans-serif; padding: 20px; background-color: #f5f5f5; }
+ .container { max-width: 600px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
+ .header { color: #e94560; text-align: center; margin-bottom: 20px; }
+ .info { background: #e3f2fd; padding: 15px; border-radius: 4px; margin-bottom: 20px; }
+ .button { background: #1976d2; color: white; padding: 12px 24px; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; width: 100%; }
+ .button:hover { background: #1565c0; }
+ .details { font-size: 14px; color: #666; margin-top: 15px; }
+ .countdown { font-weight: bold; color: #1976d2; }
+ </style>
+ </head>
+ <body>
+ <div class="container">
+ <h1 class="header">🎭 Mock SAML IdP</h1>
+ <div class="info">
+ <strong>테스트 모드:</strong> 실제 IdP 대신 Mock 응답을 사용합니다.<br>
+ <em>실제 데이터 형태와 일치하도록 attributes를 문자열로 전송합니다.</em>
+ </div>
+ <form id="mockForm" method="POST" action="${callbackUrl}">
+ <input type="hidden" name="SAMLResponse" value="${encodedSAMLResponse}" />
+ <input type="hidden" name="RelayState" value="${relayState}" />
+ <button type="submit" class="button">Continue with Mock Login</button>
+ </form>
+ <div class="details">
+ <p><strong>테스트 사용자 정보:</strong></p>
+ <ul>
+ <li>이메일: ${mockSAMLResponseData.attributes.email}</li>
+ <li>이름: ${mockSAMLResponseData.attributes.name}</li>
+ </ul>
+ <p><span class="countdown" id="countdown">5</span>초 후 자동으로 로그인을 진행합니다...</p>
+ <p><em>프로덕션 환경에서는 SAML_MOCKING_IDP=false로 설정하세요.</em></p>
+ </div>
+ </div>
+ <script>
+ let countdown = 5;
+ const countdownEl = document.getElementById('countdown');
+
+ const timer = setInterval(() => {
+ countdown--;
+ countdownEl.textContent = countdown;
+
+ if (countdown <= 0) {
+ clearInterval(timer);
+ console.log('🎭 Auto-submitting mock SAML form...');
+ document.getElementById('mockForm').submit();
+ }
+ }, 1000);
+
+ // 사용자가 버튼을 클릭하면 타이머 취소
+ document.getElementById('mockForm').addEventListener('submit', () => {
+ clearInterval(timer);
+ });
+ </script>
+ </body>
+ </html>
+ `;
+
+ return new NextResponse(mockFormHTML, {
+ headers: {
+ 'Content-Type': 'text/html; charset=utf-8',
+ 'Cache-Control': 'no-cache, no-store, must-revalidate'
+ }
+ });
+
+ } catch (error) {
+ console.error('💥 Mock IdP error:', error);
+ return NextResponse.json({
+ error: 'Mock IdP failed',
+ details: error instanceof Error ? error.message : 'Unknown error'
+ }, { status: 500 });
+ }
+} \ No newline at end of file
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