summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.env.development5
-rw-r--r--app/api/auth/[...nextauth]/saml/provider.ts159
-rw-r--r--app/api/auth/[...nextauth]/saml/utils.ts175
-rw-r--r--app/api/auth/saml/authn-request/route.ts76
-rw-r--r--app/api/auth/saml/mock-idp/route.ts137
-rw-r--r--app/api/saml/callback/route.ts124
-rw-r--r--components/login/saml-login-button.tsx19
-rw-r--r--lib/debug-utils.ts72
8 files changed, 639 insertions, 128 deletions
diff --git a/.env.development b/.env.development
index 07b67a22..5135e63e 100644
--- a/.env.development
+++ b/.env.development
@@ -62,8 +62,11 @@ OCR_SECRET_KEY=QVZzbkFtVFV1UWl2THNCY01lYVVGUUxpWmdyUkxHYVA=
# === [시작] SSO 설정 ===
+# ! IdP와 통신 불가능한 상황에서 테스트를 위한 모킹 처리 지원하기
+SAML_MOCKING_IDP=true
+
# ! SSO Redirect 주소로 활용되며, 상단에서 적절한 URL을 쓴다면 이 변수는 주석처리할 것
-# NEXTAUTH_URL="http://60.101.108.100"
+# NEXTAUTH_URL="http://localhost:3000"
# SAML 2.0 SP로서 신청할 때 기입하는 사항
# 메타데이터 XML에서 추출 가능하나, 개발 편의성을 위해 추출로직 제거하고 환경변수에 하드코딩함
diff --git a/app/api/auth/[...nextauth]/saml/provider.ts b/app/api/auth/[...nextauth]/saml/provider.ts
index 92099be0..1f891661 100644
--- a/app/api/auth/[...nextauth]/saml/provider.ts
+++ b/app/api/auth/[...nextauth]/saml/provider.ts
@@ -1,5 +1,9 @@
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
@@ -28,59 +32,80 @@ export function SAMLProvider(options: SAMLProviderOptions) {
}
},
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) {
- console.error('No user data provided')
+ debugError('No user data provided in credentials')
return null
}
- console.log('🔐 SAML Provider: Processing user data')
+ debugProcess('SAML Provider: Processing user data')
// 사용자 데이터 파싱 (UTF-8 처리 개선)
const userDataString = credentials.user
- console.log('🔤 Raw user data string:', userDataString.substring(0, 200) + '...')
+ debugLog('🔤 Raw user data string:', userDataString.substring(0, 200) + '...')
- const userData = JSON.parse(userDataString)
+ 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 확인
- console.log('🔤 Parsed user data UTF-8 check:', {
+ 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) {
- console.error('Invalid SAML user data:', userData)
+ debugError('Invalid SAML user data:', userData)
return null
}
- console.log('✅ SAML Provider: User authenticated successfully', {
+ 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) {
- console.error('Invalid SAML user data structure:', userData)
+ debugError('Invalid SAML user data structure:', userData)
return null
}
// 🔥 JIT (Just-In-Time) 사용자 생성 또는 조회
- const dbUser = await getOrCreateSAMLUser({
+ debugProcess('Creating/getting SAML user from database...');
+ const userCreateData = {
email: userData.email,
name: userData.name,
- // companyId: userData.companyId,
- // techCompanyId: userData.techCompanyId,
- // ! domain = evcp 이면 vendor가 갖는 companyId, techCompanyId는 null
companyId: undefined,
techCompanyId: undefined,
domain: userData.domain
- })
+ };
+ debugLog('User create data:', userCreateData);
+
+ const dbUser = await getOrCreateSAMLUser(userCreateData)
+ debugLog('Database user result:', dbUser);
if (!dbUser) {
- console.error('Failed to get or create SAML user')
+ debugError('Failed to get or create SAML user')
return null
}
@@ -95,10 +120,15 @@ export function SAMLProvider(options: SAMLProviderOptions) {
imageUrl: dbUser.imageUrl, // DB의 실제 이미지 URL
}
- console.log('✅ SAML Provider: Returning user data to NextAuth:', userResult)
+ debugSuccess('SAML Provider: Returning user data to NextAuth:', userResult)
return userResult
} catch (error) {
- console.error('❌ SAML Provider: Authentication failed', 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
}
}
@@ -125,4 +155,101 @@ export function validateSAMLOptions(options: SAMLProviderOptions): boolean {
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 {
+ return process.env.NODE_ENV === 'production'
+ ? '__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
index 7dfe9581..73c00bf6 100644
--- a/app/api/auth/[...nextauth]/saml/utils.ts
+++ b/app/api/auth/[...nextauth]/saml/utils.ts
@@ -6,11 +6,12 @@ import {
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[]>;
+ attributes?: Record<string, string | string[]>; // 문자열 또는 배열 모두 지원
[key: string]: unknown;
}
@@ -100,6 +101,12 @@ export async function createAuthnRequest(): Promise<string> {
"use server";
console.log("SSO STEP 2: Create AuthnRequest");
+
+ // Mock IdP 모드 체크
+ if (process.env.SAML_MOCKING_IDP === 'true') {
+ debugMock("Mock IdP mode enabled - simulating SAML response");
+ return createMockSAMLFlow();
+ }
try {
const config = createSAMLConfig();
@@ -170,7 +177,7 @@ export async function createAuthnRequest(): Promise<string> {
);
try {
- const zlib = require("zlib");
+ const zlib = await import("zlib");
const decompressed = zlib
.inflateRawSync(base64DecodedBuffer)
.toString("utf-8");
@@ -182,9 +189,9 @@ export async function createAuthnRequest(): Promise<string> {
// XML 구조 분석
const xmlLines = decompressed
.split("\n")
- .filter((line) => line.trim());
+ .filter((line: string) => line.trim());
console.log("XML 구조 요약:");
- xmlLines.forEach((line, index) => {
+ xmlLines.forEach((line: string, index: number) => {
const trimmed = line.trim();
if (
trimmed.includes("<saml") ||
@@ -224,7 +231,7 @@ export async function createAuthnRequest(): Promise<string> {
` Callback URL: ${acsMatch ? acsMatch[1] : "없음"}`
);
} catch (inflateError) {
- console.log("❌ Deflate 압축 해제 실패:", inflateError.message);
+ console.log("❌ Deflate 압축 해제 실패:", (inflateError as Error).message);
console.log(
" 원본 바이너리 데이터 (hex):",
base64DecodedBuffer.toString("hex").substring(0, 100) + "..."
@@ -232,11 +239,11 @@ export async function createAuthnRequest(): Promise<string> {
}
}
} catch (decodeError) {
- console.log("❌ Base64 디코딩 실패:", decodeError.message);
+ console.log("❌ Base64 디코딩 실패:", (decodeError as Error).message);
}
}
} catch (analysisError) {
- console.log("⚠️ SAML AuthnRequest 분석 중 오류:", analysisError.message);
+ console.log("⚠️ SAML AuthnRequest 분석 중 오류:", (analysisError as Error).message);
}
console.log("✅ SAML AuthnRequest URL generated:", {
@@ -271,9 +278,15 @@ export async function validateSAMLResponse(
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_USE_MOCKUP=false or not set)"
+ "🔐 Using Real SAML validation (SAML_MOCKING_IDP=false or not set)"
);
try {
@@ -293,11 +306,11 @@ export async function validateSAMLResponse(
throw new Error("No profile returned from SAML validation");
}
- // SAMLProfile 형태로 변환
+ // SAMLProfile 형태로 변환 (타입 안전성 확보)
const samlProfile: SAMLProfile = {
- nameID: profile.nameID,
- nameIDFormat: profile.nameIDFormat,
- attributes: profile.attributes || {},
+ 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:", {
@@ -332,71 +345,133 @@ export function mapSAMLProfileToUser(profile: SAMLProfile): SAMLUser {
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 ||
- profile.attributes?.uid?.[0] ||
- profile.attributes?.employeeNumber?.[0] ||
- "";
- const email =
- profile.attributes?.email?.[0] ||
- profile.attributes?.mail?.[0] ||
- profile.nameID ||
- "";
- // UTF-8 이름 처리 개선
- let name =
- profile.attributes?.displayName?.[0] ||
- profile.attributes?.cn?.[0] ||
- profile.attributes?.name?.[0] ||
- (profile.attributes?.givenName?.[0] && profile.attributes?.sn?.[0]
- ? profile.attributes.givenName[0] + " " + profile.attributes.sn[0]
- : "") ||
- "";
+ 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 문자열 정규화 및 검증
- if (name && typeof name === "string") {
- name = name.normalize("NFC").trim();
-
- // 한글이 깨진 경우 감지 및 로그
- const hasInvalidChars = /[\uFFFD\x00-\x1F\x7F-\x9F]/.test(name);
- if (hasInvalidChars) {
- console.warn("⚠️ Invalid UTF-8 characters detected in name:", {
- originalName: name,
- charCodes: [...name].map((c) => c.charCodeAt(0)),
- hexDump: [...name]
- .map((c) => "\\x" + c.charCodeAt(0).toString(16).padStart(2, "0"))
- .join(""),
- });
- }
+ 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 로그인 시 없음
+ // 회사 정보는 SSO 로그인 시 없음 (evcp 도메인)
const companyId = undefined;
const techCompanyId = undefined;
const domain = 'evcp';
- const user = {
+ const user: SAMLUser = {
id,
email,
- name: name.trim(),
+ name: normalizedName,
companyId,
techCompanyId,
domain,
};
- console.log("👤 Mapped user object:", user);
+ console.log("👤 Mapped user object:", JSON.stringify(user));
return user;
}
+// Mock SAML 플로우 생성 (테스트용)
+function createMockSAMLFlow(): string {
+ debugMock("Creating mock SAML flow...");
+
+ // Mock 모드에서는 Mock IdP 엔드포인트로 리다이렉션
+ const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
+ const mockIdpUrl = `${baseUrl}/api/auth/saml/mock-idp`;
+
+ 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(
- nameID,
+ 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
index e3cb8a47..f079aea0 100644
--- a/app/api/auth/saml/authn-request/route.ts
+++ b/app/api/auth/saml/authn-request/route.ts
@@ -1,45 +1,83 @@
-import { NextRequest, NextResponse } from 'next/server'
+/**
+ * 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'
-const samlEnvironment = {
- NODE_ENV: process.env.NODE_ENV,
- SAML_USE_MOCKUP: process.env.SAML_USE_MOCKUP,
- NEXTAUTH_URL: process.env.NEXTAUTH_URL,
-}
+// 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')
-// 환경변수 체크
-function checkEnvironment() {
- console.log('📊 Environment check:', JSON.stringify(samlEnvironment, null, 2))
+ // 키 없어도 구현 가능해서 주석 처리함.
+ // 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() {
- console.log('🚀 SAML AuthnRequest API started')
- checkEnvironment()
-
+ debugProcess('🚀 SAML AuthnRequest API started')
+
try {
- console.log('SSO STEP 1: Create AuthnRequest')
+ // 환경변수 검증
+ const environment = validateSAMLEnvironment()
+
+ debugProcess('SSO STEP 1: Create AuthnRequest')
const startTime = Date.now()
const loginUrl = await createAuthnRequest()
const endTime = Date.now()
- console.log('SAML AuthnRequest created successfully:', {
- url: loginUrl,
+ 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,
- mode: 'real',
- message: 'Using real SAML IdP'
+ isThisMocking: environment.SAML_MOCKING_IDP === 'true'
})
} catch (error) {
- console.error('Failed to create SAML AuthnRequest:', {
+ 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()
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..45c670b0
--- /dev/null
+++ b/app/api/auth/saml/mock-idp/route.ts
@@ -0,0 +1,137 @@
+import { NextRequest, NextResponse } from 'next/server'
+
+// Mock IdP 엔드포인트 - SAML Response HTML 폼 반환
+export async function GET(request: NextRequest) {
+ try {
+ console.log('🎭 Mock IdP endpoint accessed');
+
+ // 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="mock_test" />
+ <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
index d13edc4a..cf9ea772 100644
--- a/app/api/saml/callback/route.ts
+++ b/app/api/saml/callback/route.ts
@@ -1,5 +1,11 @@
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로 날려준다.
// 이 라우트가 그 요청을 받아준다.
@@ -7,14 +13,25 @@ import { validateSAMLResponse, mapSAMLProfileToUser } from '../../auth/[...nexta
export async function POST(request: NextRequest) {
try {
- console.log('🔐 SAML Callback received at /api/saml/callback')
+ const isMockMode = process.env.SAML_MOCKING_IDP === 'true';
+ debugProcess(`SAML Callback received at /api/saml/callback ${isMockMode ? '(🎭 Mock Mode)' : ''}`)
+ debugLog('Request info:', {
+ url: request.url,
+ nextUrl: request.nextUrl?.toString(),
+ mockMode: isMockMode,
+ headers: {
+ host: request.headers.get('host'),
+ origin: request.headers.get('origin'),
+ referer: request.headers.get('referer')
+ }
+ })
// 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:', {
+ debugLog('SAML Response received:', {
hasResponse: !!samlResponse,
relayState: relayState || 'none',
responseLength: samlResponse?.length || 0
@@ -23,104 +40,129 @@ export async function POST(request: NextRequest) {
// 🔍 SAML Response 디코딩 및 분석
if (samlResponse) {
try {
- console.log('🔍 SAML Response 분석:')
- console.log('1️⃣ 원본 SAMLResponse (일부):', samlResponse.substring(0, 100) + '...')
+ debugLog('🔍 SAML Response 분석:')
+ debugLog('원본 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('───────────────────────────────────')
+ debugLog('Base64 디코딩된 XML:')
+ debugLog('───────────────────────────────────')
+ debugLog(base64Decoded)
+ debugLog('───────────────────────────────────')
// XML 구조 분석
const xmlLines = base64Decoded.split('\n').filter(line => line.trim())
- console.log('3️⃣ XML 구조 요약:')
+ 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')) {
- console.log(` ${index + 1}: ${trimmed}`)
+ debugLog(` ${index + 1}: ${trimmed}`)
}
})
} catch (decodeError) {
- console.log('2️⃣ Base64 디코딩 실패:', decodeError.message)
- console.log(' SAMLResponse가 다른 형식으로 인코딩되었을 수 있습니다.')
+ debugError('Base64 디코딩 실패:', (decodeError as Error).message)
+ debugLog('SAMLResponse가 다른 형식으로 인코딩되었을 수 있습니다.')
}
} catch (analysisError) {
- console.log('⚠️ SAML Response 분석 중 오류:', analysisError.message)
+ debugError('SAML Response 분석 중 오류:', (analysisError as Error).message)
}
}
if (!samlResponse) {
- console.error('❌ No SAML Response found in request')
+ debugError('No SAML Response found in request')
+ const baseUrl = request.url || process.env.NEXTAUTH_URL || 'http://localhost:3000'
return NextResponse.redirect(
- new URL('/ko/auth/error?error=missing_saml_response', request.url)
+ new URL('/ko/evcp', baseUrl)
)
}
// SAML Response 검증 및 파싱
let samlProfile
try {
- console.log('🔍 Starting SAML Response validation...')
+ debugProcess('Starting SAML Response validation...')
samlProfile = await validateSAMLResponse(samlResponse)
- console.log('✅ SAML Response validated successfully:', {
+ debugSuccess('SAML Response validated successfully:', {
nameId: samlProfile.nameID,
attributes: Object.keys(samlProfile.attributes || {})
})
} catch (error) {
- console.error('❌ SAML Response validation failed:', {
+ 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()
})
- // 더 상세한 에러 정보를 포함한 리다이렉트
- const errorMessage = error instanceof Error ? error.message : 'Unknown SAML validation error'
+ // SAML 검증 실패 시 evcp 페이지로 리다이렉트
+ const baseUrl = request.url || process.env.NEXTAUTH_URL || 'http://localhost:3000'
return NextResponse.redirect(
- new URL(`/ko/auth/error?error=invalid_saml_response&detail=${encodeURIComponent(errorMessage)}`, request.url)
+ new URL('/ko/evcp', baseUrl)
)
}
// SAML 프로필을 사용자 객체로 매핑
- const user = mapSAMLProfileToUser(samlProfile)
- console.log('👤 Mapped user:', {
- id: user.id,
- email: user.email,
- name: user.name,
- companyId: user.companyId
+ const mappedUser = mapSAMLProfileToUser(samlProfile)
+ debugLog('Mapped user:', {
+ id: mappedUser.id,
+ email: mappedUser.email,
+ name: mappedUser.name,
})
- // NextAuth SAML Provider를 통한 직접 로그인 처리
- // 서버 사이드에서 NextAuth signIn 호출은 불가능하므로
- // NextAuth 콜백 엔드포인트로 리다이렉트
- const nextAuthCallbackUrl = new URL('/api/auth/callback/credentials-saml', request.url)
+ // SAMLProvider를 통한 사용자 인증 (JIT 포함)
+ debugProcess('Authenticating user through SAMLProvider...')
+ const authenticatedUser = await authenticateSAMLUser(mappedUser)
+
+ debugLog('Authenticated user:', authenticatedUser)
- // 사용자 데이터를 query parameter로 전달
- nextAuthCallbackUrl.searchParams.set('user', JSON.stringify(user))
- if (relayState) {
- nextAuthCallbackUrl.searchParams.set('state', relayState)
+ if (!authenticatedUser) {
+ debugError('SAML user authentication failed')
+ const baseUrl = request.url || process.env.NEXTAUTH_URL || 'http://localhost:3000'
+ return NextResponse.redirect(new URL('/ko/evcp', baseUrl))
}
- console.log('🔄 Redirecting to NextAuth callback:', nextAuthCallbackUrl.toString())
- return NextResponse.redirect(nextAuthCallbackUrl)
+ debugSuccess('User authenticated successfully:', {
+ id: authenticatedUser.id,
+ email: authenticatedUser.email,
+ name: authenticatedUser.name
+ })
+
+ // NextAuth JWT 토큰 생성
+ const encodedToken = await createNextAuthToken(authenticatedUser)
+
+ // NextAuth 세션 쿠키 설정
+ const cookieName = getSessionCookieName()
+
+ const response = NextResponse.redirect(new URL('/ko/evcp', request.url || process.env.NEXTAUTH_URL || 'http://localhost:3000'))
+
+ response.cookies.set(cookieName, encodedToken, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ 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) {
- console.error('💥 SAML Callback processing failed:', error)
+ debugError('SAML Callback processing failed:', error)
+ const baseUrl = request.url || process.env.NEXTAUTH_URL || 'http://localhost:3000'
return NextResponse.redirect(
- new URL('/ko/auth/error?error=callback_processing_failed', request.url)
+ new URL('/ko/evcp', baseUrl)
)
}
}
// GET 요청에 대한 메타데이터 반환
// 필요 없으면 굳이 노출할 필요 없음. 다만 SOAP WDSL과 유사하게 이렇게 스펙을 제공하는 건 SAML 2.0 구현 표준임. 필요하다면 IdP에게만 제공되도록 추가 처리하자.
-export async function GET(_request: NextRequest) {
+export async function GET() {
try {
// GET 요청은 SP 메타데이터 반환 (정적 파일)
const fs = await import('fs/promises')
@@ -136,7 +178,7 @@ export async function GET(_request: NextRequest) {
}
})
} catch (error) {
- console.error('Error reading SP metadata file:', 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'
diff --git a/components/login/saml-login-button.tsx b/components/login/saml-login-button.tsx
index 2f23bedf..c0aae0f1 100644
--- a/components/login/saml-login-button.tsx
+++ b/components/login/saml-login-button.tsx
@@ -52,7 +52,24 @@ export function SAMLLoginButton({
console.log('SAML Login URL:', data.loginUrl)
- // IdP로 리다이렉트
+ // data URL인지 확인 (브라우저 보안 정책으로 차단될 수 있음)
+ if (data.loginUrl.startsWith('data:')) {
+ console.warn('⚠️ Data URL detected - this may be blocked by browser security policies')
+ toast({
+ title: '테스트 모드 감지',
+ description: 'Mock SAML IdP 모드가 활성화되어 있습니다. 프로덕션에서는 SAML_MOCKING_IDP=false로 설정하세요.',
+ variant: 'default',
+ })
+
+ // data URL 대신 Mock IdP 페이지로 직접 리다이렉트
+ const baseUrl = window.location.origin
+ const mockIdpUrl = `${baseUrl}/api/auth/saml/mock-idp`
+ console.log('🎭 Redirecting to Mock IdP instead:', mockIdpUrl)
+ window.location.href = mockIdpUrl
+ return
+ }
+
+ // 일반적인 URL로 리다이렉트
window.location.href = data.loginUrl
} catch (error) {
diff --git a/lib/debug-utils.ts b/lib/debug-utils.ts
new file mode 100644
index 00000000..1d417ba5
--- /dev/null
+++ b/lib/debug-utils.ts
@@ -0,0 +1,72 @@
+// 개발 환경 디버그 유틸리티
+
+const isDev = process.env.NODE_ENV === 'development';
+const isDebugEnabled = process.env.DEBUG === 'true' || isDev;
+
+/**
+ * 개발 환경에서만 console.log 출력
+ */
+export function debugLog(message: string, ...args: any[]) {
+ if (isDebugEnabled) {
+ console.log(`🔍 ${message}`, ...args);
+ }
+}
+
+/**
+ * 개발 환경에서만 console.error 출력
+ */
+export function debugError(message: string, ...args: any[]) {
+ if (isDebugEnabled) {
+ console.error(`❌ ${message}`, ...args);
+ }
+}
+
+/**
+ * 개발 환경에서만 console.warn 출력
+ */
+export function debugWarn(message: string, ...args: any[]) {
+ if (isDebugEnabled) {
+ console.warn(`⚠️ ${message}`, ...args);
+ }
+}
+
+/**
+ * 개발 환경에서만 성공 로그 출력
+ */
+export function debugSuccess(message: string, ...args: any[]) {
+ if (isDebugEnabled) {
+ console.log(`✅ ${message}`, ...args);
+ }
+}
+
+/**
+ * 개발 환경에서만 프로세스 로그 출력
+ */
+export function debugProcess(message: string, ...args: any[]) {
+ if (isDebugEnabled) {
+ console.log(`🔐 ${message}`, ...args);
+ }
+}
+
+/**
+ * 개발 환경에서만 Mock 모드 로그 출력
+ */
+export function debugMock(message: string, ...args: any[]) {
+ if (isDebugEnabled) {
+ console.log(`🎭 ${message}`, ...args);
+ }
+}
+
+/**
+ * 개발 환경 여부 확인
+ */
+export function isDevMode(): boolean {
+ return isDev;
+}
+
+/**
+ * 디버그 모드 여부 확인 (DEBUG=true 또는 NODE_ENV=development)
+ */
+export function isDebugMode(): boolean {
+ return isDebugEnabled;
+} \ No newline at end of file