summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.env.development30
-rw-r--r--.env.production28
-rw-r--r--README.md27
-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
-rw-r--r--components/login/login-form copy 2.tsx470
-rw-r--r--components/login/login-form copy.tsx468
-rw-r--r--components/login/login-form-shi.tsx18
-rw-r--r--components/login/saml-login-button.tsx121
-rw-r--r--config/saml/idp_metadata.xml26
-rw-r--r--config/saml/sp_metadata.xml24
-rw-r--r--lib/debug-utils.ts72
-rw-r--r--lib/saml/idp-metadata.ts86
-rw-r--r--lib/saml/sp-metadata.ts82
-rw-r--r--lib/users/repository.ts15
-rw-r--r--lib/users/saml-service.ts134
-rw-r--r--package-lock.json178
21 files changed, 1975 insertions, 1059 deletions
diff --git a/.env.development b/.env.development
index 526cf9cc..d44520fe 100644
--- a/.env.development
+++ b/.env.development
@@ -61,4 +61,32 @@ DOLCE_API_URL=http://60.100.99.217:1111
DOLCE_UPLOAD_URL=http://60.100.99.217:1111/PWPUploadService.ashx
-OCR_SECRET_KEY=QVZzbkFtVFV1UWl2THNCY01lYVVGUUxpWmdyUkxHYVA= \ No newline at end of file
+OCR_SECRET_KEY=QVZzbkFtVFV1UWl2THNCY01lYVVGUUxpWmdyUkxHYVA=
+
+# === [시작] SSO 설정 ===
+
+# ! IdP와 통신 불가능한 상황에서 테스트를 위한 모킹 처리 지원하기
+SAML_MOCKING_IDP=true
+
+# ! SSO Redirect 주소로 활용되며, 상단에서 적절한 URL을 쓴다면 이 변수는 주석처리할 것
+# NEXTAUTH_URL="http://localhost:3000"
+
+# SAML 2.0 SP로서 신청할 때 기입하는 사항
+# 메타데이터 XML에서 추출 가능하나, 개발 편의성을 위해 추출로직 제거하고 환경변수에 하드코딩함
+
+### sp_metadata.xml ###
+SAML_SP_ENTITY_ID="http://60.101.108.100"
+SAML_SP_CALLBACK_URL="http://60.101.108.100/api/saml/callback"
+# POST
+SAML_SP_ACS_BINDING_PRIMARY="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
+# Redirect
+SAML_SP_ACS_BINDING_SECONDARY="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
+SAML_SP_AUTHN_REQUESTS_SIGNED=false
+SAML_SP_WANT_ASSERTIONS_SIGNED=false
+
+### idp_metadata.xml ###
+SAML_IDP_ENTITY_ID="www.stage1.samsung.net"
+SAML_IDP_SSO_URL="https://epsso.stage.samsung.net/sso/saml/SingleSignOnService"
+SAML_IDP_CERT="MIID2zCCAsOgAwIBAgIJAKUgkCmmclHOMA0GCSqGSIb3DQEBCwUAMIGDMQswCQYDVQQGEwJLUjEPMA0GA1UECAwGSmFtc2lsMQ4wDAYDVQQHDAVTZW91bDETMBEGA1UECgwKU2Ftc3VuZ1NEUzEdMBsGA1UECwwUSW50cmFuZXRCdXNpbmVzc1RlYW0xHzAdBgNVBAMMFm5ldC5zYW1zdW5nLmtub3hwb3J0YWwwHhcNMTcwOTA2MDQxNDAzWhcNMjcwOTA0MDQxNDAzWjCBgzELMAkGA1UEBhMCS1IxDzANBgNVBAgMBkphbXNpbDEOMAwGA1UEBwwFU2VvdWwxEzARBgNVBAoMClNhbXN1bmdTRFMxHTAbBgNVBAsMFEludHJhbmV0QnVzaW5lc3NUZWFtMR8wHQYDVQQDDBZuZXQuc2Ftc3VuZy5rbm94cG9ydGFsMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1nT5VRgS/PGj7iL8l4kpyEqs04BocOrIPf9mn+Ky/pA3BkgfxItkAfxqKjrzZ2J/0yB1jkjpHYxQQSpah5f/FrxK/G3lCMlpQzFgT9qfX/VJqhJLU3JF4hhxTVp77rF5Sqz2CWdTzrKgEhVhQupfANL67uw1GrR2AoPWsmGqr/ybdEcjr0w3lYrnCb9LYvvT+KOmZg1nVEbMAJ66xFuiuc4IGAot+IIHY86ZjSXRfMBkJaisEpStXXja0PD8SHDu31DdLomaRYrv9eyoh3q/LONejfgd8IrAJO3Om8zNmfF2Q665Ab4oPFoRznjvR74/pszIxqQTYoVgKkDKRmTOjQIDAQABo1AwTjAdBgNVHQ4EFgQUiolG//FttT/5g3IBaoRvjNWNCt0wHwYDVR0jBBgwFoAUiolG//FttT/5g3IBaoRvjNWNCt0wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAQVBxmGnZHo3dMLKFgAf8oLevA1TuA03p6jj2MVLwFMjw0S74bFpgS4ZXEzsliGAQprVwTzo06XtTxQENxddbFMRfKroKvpyM20uBt2JI5nBmE/kzrb4AOguRRTNKfb9o4zk2yO7Ra31dWHrvZ3usV8A0KLIHef6iUPv4mBMXY5e7gEUjoZxbZQucyHOrYvuj/TISd7n6r37cotf5ldUD5B+ADP05AgTTP1vKzyfOsb+zRqTTi8WFOc2SlbTktXPvfiQmHs6OoCbNNYXfQT+YO0x3y8M4TevvoeKvTjQp1E+Q+J8hAh7xTIemb6wP460ObUD9w+wyqUk44XJGdibtgQ=="
+
+# === [끝] SSO 설정 === \ No newline at end of file
diff --git a/.env.production b/.env.production
index f10df9a0..a48ffb63 100644
--- a/.env.production
+++ b/.env.production
@@ -58,4 +58,30 @@ DOLCE_DOC_LIST_API_URL=http://60.100.99.217:1111/Services/VDCSWebService.svc/Dwg
DOLCE_API_URL=http://60.100.99.217:1111
DOLCE_UPLOAD_URL=http://60.100.99.217:1111/PWPUploadService.ashx
-OCR_SECRET_KEY=QVZzbkFtVFV1UWl2THNCY01lYVVGUUxpWmdyUkxHYVA= \ No newline at end of file
+OCR_SECRET_KEY=QVZzbkFtVFV1UWl2THNCY01lYVVGUUxpWmdyUkxHYVA=
+
+
+# === [시작] SSO 설정 ===
+
+# ! SSO Redirect 주소로 활용되며, 상단에서 적절한 URL을 쓴다면 이 변수는 주석처리할 것
+# NEXTAUTH_URL="http://60.101.108.100"
+
+# SAML 2.0 SP로서 신청할 때 기입하는 사항
+# 메타데이터 XML에서 추출 가능하나, 개발 편의성을 위해 추출로직 제거하고 환경변수에 하드코딩함
+
+### sp_metadata.xml ###
+SAML_SP_ENTITY_ID="http://60.101.108.100"
+SAML_SP_CALLBACK_URL="http://60.101.108.100/api/saml/callback"
+# POST
+SAML_SP_ACS_BINDING_PRIMARY="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
+# Redirect
+SAML_SP_ACS_BINDING_SECONDARY="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
+SAML_SP_AUTHN_REQUESTS_SIGNED=false
+SAML_SP_WANT_ASSERTIONS_SIGNED=false
+
+### idp_metadata.xml ###
+SAML_IDP_ENTITY_ID="www.stage1.samsung.net"
+SAML_IDP_SSO_URL="https://epsso.stage.samsung.net/sso/saml/SingleSignOnService"
+SAML_IDP_CERT="MIID2zCCAsOgAwIBAgIJAKUgkCmmclHOMA0GCSqGSIb3DQEBCwUAMIGDMQswCQYDVQQGEwJLUjEPMA0GA1UECAwGSmFtc2lsMQ4wDAYDVQQHDAVTZW91bDETMBEGA1UECgwKU2Ftc3VuZ1NEUzEdMBsGA1UECwwUSW50cmFuZXRCdXNpbmVzc1RlYW0xHzAdBgNVBAMMFm5ldC5zYW1zdW5nLmtub3hwb3J0YWwwHhcNMTcwOTA2MDQxNDAzWhcNMjcwOTA0MDQxNDAzWjCBgzELMAkGA1UEBhMCS1IxDzANBgNVBAgMBkphbXNpbDEOMAwGA1UEBwwFU2VvdWwxEzARBgNVBAoMClNhbXN1bmdTRFMxHTAbBgNVBAsMFEludHJhbmV0QnVzaW5lc3NUZWFtMR8wHQYDVQQDDBZuZXQuc2Ftc3VuZy5rbm94cG9ydGFsMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1nT5VRgS/PGj7iL8l4kpyEqs04BocOrIPf9mn+Ky/pA3BkgfxItkAfxqKjrzZ2J/0yB1jkjpHYxQQSpah5f/FrxK/G3lCMlpQzFgT9qfX/VJqhJLU3JF4hhxTVp77rF5Sqz2CWdTzrKgEhVhQupfANL67uw1GrR2AoPWsmGqr/ybdEcjr0w3lYrnCb9LYvvT+KOmZg1nVEbMAJ66xFuiuc4IGAot+IIHY86ZjSXRfMBkJaisEpStXXja0PD8SHDu31DdLomaRYrv9eyoh3q/LONejfgd8IrAJO3Om8zNmfF2Q665Ab4oPFoRznjvR74/pszIxqQTYoVgKkDKRmTOjQIDAQABo1AwTjAdBgNVHQ4EFgQUiolG//FttT/5g3IBaoRvjNWNCt0wHwYDVR0jBBgwFoAUiolG//FttT/5g3IBaoRvjNWNCt0wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAQVBxmGnZHo3dMLKFgAf8oLevA1TuA03p6jj2MVLwFMjw0S74bFpgS4ZXEzsliGAQprVwTzo06XtTxQENxddbFMRfKroKvpyM20uBt2JI5nBmE/kzrb4AOguRRTNKfb9o4zk2yO7Ra31dWHrvZ3usV8A0KLIHef6iUPv4mBMXY5e7gEUjoZxbZQucyHOrYvuj/TISd7n6r37cotf5ldUD5B+ADP05AgTTP1vKzyfOsb+zRqTTi8WFOc2SlbTktXPvfiQmHs6OoCbNNYXfQT+YO0x3y8M4TevvoeKvTjQp1E+Q+J8hAh7xTIemb6wP460ObUD9w+wyqUk44XJGdibtgQ=="
+
+# === [끝] SSO 설정 === \ No newline at end of file
diff --git a/README.md b/README.md
index 8b87e2f2..8c769862 100644
--- a/README.md
+++ b/README.md
@@ -117,3 +117,30 @@ npx dotenv -e .env.development -- npx drizzle-kit push
> ```
[gh cli에 대해 알아보기](https://github.com/cli/cli)
+
+## SAML 2.0 SSO (For Knox Portal)
+# === [시작] SSO 설정 ===
+
+# ! SSO Redirect 주소로 활용되며, 상단에서 적절한 URL을 쓴다면 이 변수는 주석처리할 것
+# NEXTAUTH_URL="http://60.101.108.100"
+
+# SAML 2.0 SP로서 신청할 때 기입하는 사항
+# 메타데이터 XML에서 추출 가능하나, 개발 편의성을 위해 추출로직 제거하고 환경변수에 하드코딩함
+
+### sp_metadata.xml ###
+```bash
+SAML_SP_ENTITY_ID="SP 측 ID(사전 정의됨)"
+SAML_SP_CALLBACK_URL="SP 측이 콜백을 받을 주소(사전 정의됨)"
+# POST
+SAML_SP_ACS_BINDING_PRIMARY="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
+# Redirect
+SAML_SP_ACS_BINDING_SECONDARY="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
+SAML_SP_AUTHN_REQUESTS_SIGNED=false #(사전 정의됨)
+SAML_SP_WANT_ASSERTIONS_SIGNED=false #(사전 정의됨)
+
+### idp_metadata.xml ###
+SAML_IDP_ENTITY_ID="(수신한 메타데이터 기준으로 추출)"
+SAML_IDP_SSO_URL="(수신한 메타데이터 기준으로 추출. 이 위치로 브라우저는 사용자를 리다이렉트함)"
+SAML_IDP_CERT="BASE64로 인코딩된 IDP의 CERT"
+SAML_ENCRYPTION_METHOD="CERT 암호화 기법"
+``` \ No newline at end of file
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
diff --git a/components/login/login-form copy 2.tsx b/components/login/login-form copy 2.tsx
deleted file mode 100644
index d5ac01b9..00000000
--- a/components/login/login-form copy 2.tsx
+++ /dev/null
@@ -1,470 +0,0 @@
-'use client';
-
-import { useState, useEffect } from "react";
-import { cn } from "@/lib/utils"
-import { Button } from "@/components/ui/button"
-import { Card, CardContent } from "@/components/ui/card"
-import { Input } from "@/components/ui/input"
-import { SendIcon, Loader2, GlobeIcon, ChevronDownIcon, Ship, InfoIcon, ArrowRight } from "lucide-react";
-import { useToast } from "@/hooks/use-toast";
-import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem } from "@/components/ui/dropdown-menu"
-import { useTranslation } from '@/i18n/client'
-import { useRouter, useParams, usePathname, useSearchParams } from 'next/navigation';
-import {
- InputOTP,
- InputOTPGroup,
- InputOTPSlot,
-} from "@/components/ui/input-otp"
-import { signIn } from 'next-auth/react';
-import { sendOtpAction } from "@/lib/users/send-otp";
-import { verifyTokenAction } from "@/lib/users/verifyToken";
-import { buttonVariants } from "@/components/ui/button"
-import Link from "next/link"
-import Image from 'next/image';
-import {
- Alert,
- AlertDescription,
- AlertTitle,
-} from "@/components/ui/alert";
-
-export function LoginForm({
- className,
- ...props
-}: React.ComponentProps<"div">) {
-
- const params = useParams() || {};
- const pathname = usePathname() || '';
- const router = useRouter();
- const searchParams = useSearchParams();
- const token = searchParams?.get('token') || null;
- const [showCredentialsForm, setShowCredentialsForm] = useState(false);
- // 새로운 상태: 업체 등록 안내 표시 여부
- const [showVendorRegistrationInfo, setShowVendorRegistrationInfo] = useState(false);
-
- const lng = params.lng as string;
- const { t, i18n } = useTranslation(lng, 'login');
-
- const { toast } = useToast();
-
- const handleChangeLanguage = (lang: string) => {
- const segments = pathname.split('/');
- segments[1] = lang;
- router.push(segments.join('/'));
- };
-
- const currentLanguageText = i18n.language === 'ko' ? t('languages.korean') : t('languages.english');
-
- const [email, setEmail] = useState('');
- const [otpSent, setOtpSent] = useState(false);
- const [isLoading, setIsLoading] = useState(false);
- const [otp, setOtp] = useState('');
- const [username, setUsername] = useState('');
- const [password, setPassword] = useState('');
-
- // 업체 등록 페이지로 이동하는 함수
- const goToVendorRegistration = () => {
- router.push(`/${lng}/partners/repository`);
- };
-
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
- setIsLoading(true);
- try {
- const result = await sendOtpAction(email, lng);
-
- if (result.success) {
- setOtpSent(true);
- toast({
- title: t('otpSentTitle'),
- description: t('otpSentMessage'),
- });
- } else {
- // Handle specific error types
- let errorMessage = t('defaultErrorMessage');
-
- // 업체 미등록 사용자 에러 처리
- if (result.error === 'userNotFound' || result.error === 'vendorNotRegistered') {
- setShowVendorRegistrationInfo(true);
- errorMessage = t('vendorNotRegistered') || '등록된 업체가 아닙니다. 먼저 업체 등록 신청을 해주세요.';
- }
-
- toast({
- title: t('errorTitle'),
- description: result.message || errorMessage,
- variant: 'destructive',
- });
- }
- } catch (error) {
- // This will catch network errors or other unexpected issues
- console.error(error);
- toast({
- title: t('errorTitle'),
- description: t('networkErrorMessage'),
- variant: 'destructive',
- });
- } finally {
- setIsLoading(false);
- }
- };
-
- async function handleOtpSubmit(e: React.FormEvent) {
- e.preventDefault();
- setIsLoading(true);
-
- try {
- // next-auth의 Credentials Provider로 로그인 시도
- const result = await signIn('credentials', {
- email,
- code: otp,
- redirect: false, // 커스텀 처리 위해 redirect: false
- });
-
- if (result?.ok) {
- // 토스트 메시지 표시
- toast({
- title: t('loginSuccess'),
- description: t('youAreLoggedIn'),
- });
-
- router.push(`/${lng}/partners/dashboard`);
- } else {
- // 로그인 실패 시 에러 메시지에 업체 등록 관련 정보 포함
- if (result?.error === 'vendorNotRegistered') {
- setShowVendorRegistrationInfo(true);
- toast({
- title: t('errorTitle'),
- description: t('vendorNotRegistered') || '등록된 업체가 아닙니다. 먼저 업체 등록 신청을 해주세요.',
- variant: 'destructive',
- });
- } else {
- toast({
- title: t('errorTitle'),
- description: t('defaultErrorMessage'),
- variant: 'destructive',
- });
- }
- }
- } catch (error) {
- console.error('Login error:', error);
- toast({
- title: t('errorTitle'),
- description: t('defaultErrorMessage'),
- variant: 'destructive',
- });
- } finally {
- setIsLoading(false);
- }
- }
-
- // 새로운 로그인 처리 함수 추가
- const handleCredentialsLogin = async () => {
- if (!username || !password) {
- toast({
- title: t('errorTitle'),
- description: t('credentialsRequired'),
- variant: 'destructive',
- });
- return;
- }
-
- setIsLoading(true);
-
- try {
- // next-auth의 다른 credentials provider로 로그인 시도
- const result = await signIn('credentials-password', {
- username,
- password,
- redirect: false,
- });
-
- if (result?.ok) {
- toast({
- title: t('loginSuccess'),
- description: t('youAreLoggedIn'),
- });
-
- router.push(`/${lng}/partners/dashboard`);
- } else {
- // 로그인 실패 시 업체 등록 관련 정보 표시 여부 결정
- if (result?.error === 'vendorNotRegistered') {
- setShowVendorRegistrationInfo(true);
- toast({
- title: t('errorTitle'),
- description: t('vendorNotRegistered') || '등록된 업체가 아닙니다. 먼저 업체 등록 신청을 해주세요.',
- variant: 'destructive',
- });
- } else {
- toast({
- title: t('errorTitle'),
- description: t('invalidCredentials'),
- variant: 'destructive',
- });
- }
- }
- } catch (error) {
- console.error('Login error:', error);
- toast({
- title: t('errorTitle'),
- description: t('defaultErrorMessage'),
- variant: 'destructive',
- });
- } finally {
- setIsLoading(false);
- }
- };
-
- useEffect(() => {
- const verifyToken = async () => {
- if (!token) return;
- setIsLoading(true);
-
- try {
- const data = await verifyTokenAction(token);
-
- if (data.valid) {
- setOtpSent(true);
- setEmail(data.email ?? '');
- } else {
- toast({
- title: t('errorTitle'),
- description: t('invalidToken'),
- variant: 'destructive',
- });
- }
- } catch (error) {
- toast({
- title: t('errorTitle'),
- description: t('defaultErrorMessage'),
- variant: 'destructive',
- });
- } finally {
- setIsLoading(false);
- }
- };
- verifyToken();
- }, [token, toast, t]);
-
- return (
- <div className="container relative flex h-screen flex-col items-center justify-center md:grid lg:max-w-none lg:grid-cols-2 lg:px-0">
- {/* Left Content */}
- <div className="flex flex-col w-full h-screen lg:p-2">
- {/* Top bar with Logo + eVCP (left) and "Request Vendor Repository" (right) */}
- <div className="flex items-center justify-between p-4">
- <div className="flex items-center space-x-2">
- <Ship className="w-4 h-4" />
- <span className="text-md font-bold">eVCP</span>
- </div>
-
- {/* 업체 등록 신청 버튼 - 가시성 향상을 위해 variant 변경 */}
- <Link
- href={`/${lng}/partners/repository`}
- className={cn(buttonVariants({ variant: "outline" }), "border-blue-500 text-blue-600 hover:bg-blue-50")}
- >
- <InfoIcon className="w-4 h-4 mr-2" />
- {t('registerVendor') || '업체 등록 신청'}
- </Link>
- </div>
-
- {/* Content section that occupies remaining space, centered vertically */}
- <div className="flex-1 flex items-center justify-center">
- {/* Your form container */}
- <div className="mx-auto w-full flex flex-col space-y-6 sm:w-[350px]">
- {/* 업체 등록 안내 알림 - 특정 상황에서만 표시 */}
- {showVendorRegistrationInfo && (
- <Alert className="border-blue-500 bg-blue-50">
- <InfoIcon className="h-4 w-4 text-blue-600" />
- <AlertTitle className="text-blue-700">
- {t('vendorRegistrationRequired') || '업체 등록이 필요합니다'}
- </AlertTitle>
- <AlertDescription className="text-blue-600">
- {t('vendorRegistrationMessage') || '로그인하시려면 먼저 업체 등록이 필요합니다. 아래 버튼을 클릭하여 등록을 진행해주세요.'}
- </AlertDescription>
- <Button
- onClick={goToVendorRegistration}
- className="mt-2 w-full bg-blue-600 hover:bg-blue-700 text-white"
- >
- {t('goToVendorRegistration') || '업체 등록 신청하기'}
- <ArrowRight className="ml-2 h-4 w-4" />
- </Button>
- </Alert>
- )}
-
- <form onSubmit={handleOtpSubmit} className="p-6 md:p-8">
- <div className="flex flex-col gap-6">
- <div className="flex flex-col items-center text-center">
- <h1 className="text-2xl font-bold">{t('loginMessage')}</h1>
-
- {/* 설명 텍스트 추가 - 업체 등록 관련 안내 */}
- <p className="text-sm text-muted-foreground mt-2">
- {t('loginDescription') || '등록된 업체만 로그인하실 수 있습니다. 아직 등록되지 않은 업체라면 상단의 업체 등록 신청 버튼을 이용해주세요.'}
- </p>
- </div>
-
- {/* S-chips 로그인 폼이 표시되지 않을 때만 이메일 입력 필드 표시 */}
- {!showCredentialsForm && (
- <>
- <div className="grid gap-2">
- <Input
- id="email"
- type="email"
- placeholder={t('email')}
- required
- className="h-10"
- value={email}
- onChange={(e) => setEmail(e.target.value)}
- />
- </div>
- <Button
- type="submit"
- className="w-full"
- variant="samsung"
- disabled={isLoading}
- onClick={handleSubmit}
- >
- {isLoading ? t('sending') : t('ContinueWithEmail')}
- </Button>
-
- {/* 구분선과 "Or continue with" 섹션 추가 */}
- <div className="relative">
- <div className="absolute inset-0 flex items-center">
- <span className="w-full border-t"></span>
- </div>
- <div className="relative flex justify-center text-xs uppercase">
- <span className="bg-background px-2 text-muted-foreground">
- {t('orContinueWith')}
- </span>
- </div>
- </div>
-
- {/* S-chips 로그인 버튼 */}
- <Button
- type="button"
- className="w-full"
- onClick={() => setShowCredentialsForm(true)}
- >
- S-Gips로 로그인하기
- </Button>
-
- {/* 업체 등록 안내 링크 추가 */}
- <Button
- type="button"
- variant="link"
- className="text-blue-600 hover:text-blue-800"
- onClick={goToVendorRegistration}
- >
- {t('newVendor') || '신규 업체이신가요? 여기서 등록하세요'}
- </Button>
- </>
- )}
-
- {/* ID/비밀번호 로그인 폼 - 버튼 클릭 시에만 표시 */}
- {showCredentialsForm && (
- <>
- <div className="grid gap-4">
- <Input
- id="username"
- type="text"
- placeholder="S-chips ID"
- className="h-10"
- value={username}
- onChange={(e) => setUsername(e.target.value)}
- />
- <Input
- id="password"
- type="password"
- placeholder="비밀번호"
- className="h-10"
- value={password}
- onChange={(e) => setPassword(e.target.value)}
- />
- <Button
- type="button"
- className="w-full"
- variant="samsung"
- onClick={handleCredentialsLogin}
- disabled={isLoading}
- >
- {isLoading ? "로그인 중..." : "로그인"}
- </Button>
-
- {/* 뒤로 가기 버튼 */}
- <Button
- type="button"
- variant="ghost"
- className="w-full text-sm"
- onClick={() => setShowCredentialsForm(false)}
- >
- 이메일로 로그인하기
- </Button>
-
- {/* 업체 등록 안내 링크 추가 */}
- <Button
- type="button"
- variant="link"
- className="text-blue-600 hover:text-blue-800"
- onClick={goToVendorRegistration}
- >
- {t('newVendor') || '신규 업체이신가요? 여기서 등록하세요'}
- </Button>
- </div>
- </>
- )}
-
- <div className="text-center text-sm mx-auto">
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button variant="ghost" className="flex items-center gap-2">
- <GlobeIcon className="h-4 w-4" />
- <span>{currentLanguageText}</span>
- <ChevronDownIcon className="h-4 w-4" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuRadioGroup
- value={i18n.language}
- onValueChange={(value) => handleChangeLanguage(value)}
- >
- <DropdownMenuRadioItem value="en">
- {t('languages.english')}
- </DropdownMenuRadioItem>
- <DropdownMenuRadioItem value="ko">
- {t('languages.korean')}
- </DropdownMenuRadioItem>
- </DropdownMenuRadioGroup>
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
- </div>
- </form>
-
- <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary">
- {t('termsMessage')} <a href="#">{t('termsOfService')}</a> {t('and')}
- <a href="#">{t('privacyPolicy')}</a>.
- </div>
- </div>
- </div>
- </div>
-
- {/* Right BG 이미지 영역 - Image 컴포넌트로 수정 */}
- <div className="relative hidden h-full flex-col p-10 text-white dark:border-r md:flex">
- {/* Image 컴포넌트로 대체 */}
- <div className="absolute inset-0">
- <Image
- src="/images/02.jpg"
- alt="Background image"
- fill
- priority
- sizes="(max-width: 1024px) 100vw, 50vw"
- className="object-cover"
- />
- </div>
- <div className="relative z-10 mt-auto">
- <blockquote className="space-y-2">
- <p className="text-sm">&ldquo;{t("blockquote")}&rdquo;</p>
- {/* <footer className="text-sm">SHI</footer> */}
- </blockquote>
- </div>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/components/login/login-form copy.tsx b/components/login/login-form copy.tsx
deleted file mode 100644
index ef9eba10..00000000
--- a/components/login/login-form copy.tsx
+++ /dev/null
@@ -1,468 +0,0 @@
-'use client';
-
-import { useState, useEffect } from "react";
-import { cn } from "@/lib/utils"
-import { Button } from "@/components/ui/button"
-import { Card, CardContent } from "@/components/ui/card"
-import { Input } from "@/components/ui/input"
-import { SendIcon, Loader2, GlobeIcon, ChevronDownIcon, Ship, InfoIcon } from "lucide-react";
-import { useToast } from "@/hooks/use-toast";
-import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem } from "@/components/ui/dropdown-menu"
-import { useTranslation } from '@/i18n/client'
-import { useRouter, useParams, usePathname, useSearchParams } from 'next/navigation';
-import {
- InputOTP,
- InputOTPGroup,
- InputOTPSlot,
-} from "@/components/ui/input-otp"
-import { signIn } from 'next-auth/react';
-import { sendOtpAction } from "@/lib/users/send-otp";
-import { verifyTokenAction } from "@/lib/users/verifyToken";
-import { buttonVariants } from "@/components/ui/button"
-import Link from "next/link"
-import Image from 'next/image'; // 추가: Image 컴포넌트 import
-
-export function LoginForm({
- className,
- ...props
-}: React.ComponentProps<"div">) {
-
- const params = useParams() || {};
- const pathname = usePathname() || '';
- const router = useRouter();
- const searchParams = useSearchParams();
- const token = searchParams?.get('token') || null;
- const [showCredentialsForm, setShowCredentialsForm] = useState(false);
-
-
- const lng = params.lng as string;
- const { t, i18n } = useTranslation(lng, 'login');
-
- const { toast } = useToast();
-
- const handleChangeLanguage = (lang: string) => {
- const segments = pathname.split('/');
- segments[1] = lang;
- router.push(segments.join('/'));
- };
-
- const currentLanguageText = i18n.language === 'ko' ? t('languages.korean') : t('languages.english');
-
- const [email, setEmail] = useState('');
- const [otpSent, setOtpSent] = useState(false);
- const [isLoading, setIsLoading] = useState(false);
- const [otp, setOtp] = useState('');
- const [username, setUsername] = useState('');
- const [password, setPassword] = useState('');
-
- const goToVendorRegistration = () => {
- router.push(`/${lng}/partners/repository`);
- };
-
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
- setIsLoading(true);
- try {
- const result = await sendOtpAction(email, lng);
-
- if (result.success) {
- setOtpSent(true);
- toast({
- title: t('otpSentTitle'),
- description: t('otpSentMessage'),
- });
- } else {
- // Handle specific error types
- let errorMessage = t('defaultErrorMessage');
-
- // You can handle different error types differently
- if (result.error === 'userNotFound') {
- errorMessage = t('userNotFoundMessage');
- }
-
- toast({
- title: t('errorTitle'),
- description: result.message || errorMessage,
- variant: 'destructive',
- });
- }
- } catch (error) {
- // This will catch network errors or other unexpected issues
- console.error(error);
- toast({
- title: t('errorTitle'),
- description: t('networkErrorMessage'),
- variant: 'destructive',
- });
- } finally {
- setIsLoading(false);
- }
- };
-
- async function handleOtpSubmit(e: React.FormEvent) {
- e.preventDefault();
- setIsLoading(true);
-
- try {
- // next-auth의 Credentials Provider로 로그인 시도
- const result = await signIn('credentials', {
- email,
- code: otp,
- redirect: false, // 커스텀 처리 위해 redirect: false
- });
-
- if (result?.ok) {
- // 토스트 메시지 표시
- toast({
- title: t('loginSuccess'),
- description: t('youAreLoggedIn'),
- });
-
- router.push(`/${lng}/partners/dashboard`);
-
- } else {
- toast({
- title: t('errorTitle'),
- description: t('defaultErrorMessage'),
- variant: 'destructive',
- });
- }
- } catch (error) {
- console.error('Login error:', error);
- toast({
- title: t('errorTitle'),
- description: t('defaultErrorMessage'),
- variant: 'destructive',
- });
- } finally {
- setIsLoading(false);
- }
- }
-
- // 새로운 로그인 처리 함수 추가
- const handleCredentialsLogin = async () => {
- if (!username || !password) {
- toast({
- title: t('errorTitle'),
- description: t('credentialsRequired'),
- variant: 'destructive',
- });
- return;
- }
-
- setIsLoading(true);
-
- try {
- // next-auth의 다른 credentials provider로 로그인 시도
- const result = await signIn('credentials-password', {
- username,
- password,
- redirect: false,
- });
-
- if (result?.ok) {
- toast({
- title: t('loginSuccess'),
- description: t('youAreLoggedIn'),
- });
-
- router.push(`/${lng}/partners/dashboard`);
- } else {
- toast({
- title: t('errorTitle'),
- description: t('invalidCredentials'),
- variant: 'destructive',
- });
- }
- } catch (error) {
- console.error('Login error:', error);
- toast({
- title: t('errorTitle'),
- description: t('defaultErrorMessage'),
- variant: 'destructive',
- });
- } finally {
- setIsLoading(false);
- }
- };
-
- useEffect(() => {
- const verifyToken = async () => {
- if (!token) return;
- setIsLoading(true);
-
- try {
- const data = await verifyTokenAction(token);
-
- if (data.valid) {
- setOtpSent(true);
- setEmail(data.email ?? '');
- } else {
- toast({
- title: t('errorTitle'),
- description: t('invalidToken'),
- variant: 'destructive',
- });
- }
- } catch (error) {
- toast({
- title: t('errorTitle'),
- description: t('defaultErrorMessage'),
- variant: 'destructive',
- });
- } finally {
- setIsLoading(false);
- }
- };
- verifyToken();
- }, [token, toast, t]);
-
- return (
- <div className="container relative flex h-screen flex-col items-center justify-center md:grid lg:max-w-none lg:grid-cols-2 lg:px-0">
- {/* Left Content */}
- <div className="flex flex-col w-full h-screen lg:p-2">
- {/* Top bar with Logo + eVCP (left) and "Request Vendor Repository" (right) */}
- <div className="flex items-center justify-between">
- <div className="flex items-center space-x-2">
- {/* <img
- src="/images/logo.png"
- alt="logo"
- className="h-8 w-auto"
- /> */}
- <Ship className="w-4 h-4" />
- <span className="text-md font-bold">eVCP</span>
- </div>
- <Link
- href="/partners/repository"
- className={cn(buttonVariants({ variant: "ghost" }))}
- >
- <InfoIcon className="w-4 h-4 mr-1" />
- {'업체 등록 신청'}
- </Link>
- </div>
-
- {/* Content section that occupies remaining space, centered vertically */}
- <div className="flex-1 flex items-center justify-center">
- {/* Your form container */}
- <div className="mx-auto w-full flex flex-col space-y-6 sm:w-[350px]">
-
- {/* Here's your existing login/OTP forms: */}
- {/* {!otpSent ? ( */}
-
- {/* <form onSubmit={handleSubmit} className="p-6 md:p-8"> */}
- <form onSubmit={handleOtpSubmit} className="p-6 md:p-8">
- <div className="flex flex-col gap-6">
- <div className="flex flex-col items-center text-center">
- <h1 className="text-2xl font-bold">{t('loginMessage')}</h1>
-
- {/* 설명 텍스트 추가 - 업체 등록 관련 안내 */}
- <p className="text-xs text-muted-foreground mt-2">
- {'등록된 업체만 로그인하실 수 있습니다. 아직 등록되지 않은 업체라면 상단의 업체 등록 신청 버튼을 이용해주세요.'}
- </p>
- </div>
-
- {/* S-Gips 로그인 폼이 표시되지 않을 때만 이메일 입력 필드 표시 */}
- {!showCredentialsForm && (
- <>
- <div className="grid gap-2">
- <Input
- id="email"
- type="email"
- placeholder={t('email')}
- required
- className="h-10"
- value={email}
- onChange={(e) => setEmail(e.target.value)}
- />
- </div>
- <Button type="submit" className="w-full" variant="samsung" disabled={isLoading}>
- {isLoading ? t('sending') : t('ContinueWithEmail')}
- </Button>
-
- {/* 구분선과 "Or continue with" 섹션 추가 */}
- <div className="relative">
- <div className="absolute inset-0 flex items-center">
- <span className="w-full border-t"></span>
- </div>
- <div className="relative flex justify-center text-xs uppercase">
- <span className="bg-background px-2 text-muted-foreground">
- {t('orContinueWith')}
- </span>
- </div>
- </div>
-
- {/* S-Gips 로그인 버튼 */}
- <Button
- type="button"
- className="w-full"
- // variant=""
- onClick={() => setShowCredentialsForm(true)}
- >
- S-Gips로 로그인하기
- </Button>
-
- {/* 업체 등록 안내 링크 추가 */}
- <Button
- type="button"
- variant="link"
- className="text-blue-600 hover:text-blue-800"
- onClick={goToVendorRegistration}
- >
- {'신규 업체이신가요? 여기서 등록하세요'}
- </Button>
- </>
- )}
-
- {/* ID/비밀번호 로그인 폼 - 버튼 클릭 시에만 표시 */}
- {showCredentialsForm && (
- <>
- <div className="grid gap-4">
- <Input
- id="username"
- type="text"
- placeholder="S-Gips ID"
- className="h-10"
- value={username}
- onChange={(e) => setUsername(e.target.value)}
- />
- <Input
- id="password"
- type="password"
- placeholder="비밀번호"
- className="h-10"
- value={password}
- onChange={(e) => setPassword(e.target.value)}
- />
- <Button
- type="button"
- className="w-full"
- variant="samsung"
- onClick={handleCredentialsLogin}
- disabled={isLoading}
- >
- {isLoading ? "로그인 중..." : "로그인"}
- </Button>
-
- {/* 뒤로 가기 버튼 */}
- <Button
- type="button"
- variant="ghost"
- className="w-full text-sm"
- onClick={() => setShowCredentialsForm(false)}
- >
- 이메일로 로그인하기
- </Button>
- </div>
- </>
- )}
-
- <div className="text-center text-sm mx-auto">
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button variant="ghost" className="flex items-center gap-2">
- <GlobeIcon className="h-4 w-4" />
- <span>{currentLanguageText}</span>
- <ChevronDownIcon className="h-4 w-4" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuRadioGroup
- value={i18n.language}
- onValueChange={(value) => handleChangeLanguage(value)}
- >
- <DropdownMenuRadioItem value="en">
- {t('languages.english')}
- </DropdownMenuRadioItem>
- <DropdownMenuRadioItem value="ko">
- {t('languages.korean')}
- </DropdownMenuRadioItem>
- </DropdownMenuRadioGroup>
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
- </div>
- </form>
- {/* ) : (
- <form onSubmit={handleOtpSubmit} className="flex flex-col gap-4 p-6 md:p-8">
- <div className="flex flex-col gap-6">
- <div className="flex flex-col items-center text-center">
- <h1 className="text-2xl font-bold">{t('loginMessage')}</h1>
- </div>
- <div className="grid gap-2 justify-center">
- <InputOTP
- maxLength={6}
- value={otp}
- onChange={(value) => setOtp(value)}
- >
- <InputOTPGroup>
- <InputOTPSlot index={0} />
- <InputOTPSlot index={1} />
- <InputOTPSlot index={2} />
- <InputOTPSlot index={3} />
- <InputOTPSlot index={4} />
- <InputOTPSlot index={5} />
- </InputOTPGroup>
- </InputOTP>
- </div>
- <Button type="submit" className="w-full" disabled={isLoading}>
- {isLoading ? t('verifying') : t('verifyOtp')}
- </Button>
- <div className="mx-auto">
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button variant="ghost" className="flex items-center gap-2">
- <GlobeIcon className="h-4 w-4" />
- <span>{currentLanguageText}</span>
- <ChevronDownIcon className="h-4 w-4" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuRadioGroup
- value={i18n.language}
- onValueChange={(value) => handleChangeLanguage(value)}
- >
- <DropdownMenuRadioItem value="en">
- {t('languages.english')}
- </DropdownMenuRadioItem>
- <DropdownMenuRadioItem value="ko">
- {t('languages.korean')}
- </DropdownMenuRadioItem>
- </DropdownMenuRadioGroup>
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
- </div>
- </form>
- )} */}
-
- <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary">
- {t('termsMessage')} <a href="#">{t('termsOfService')}</a> {t('and')}
- <a href="#">{t('privacyPolicy')}</a>.
- </div>
- </div>
- </div>
- </div>
-
- {/* Right BG 이미지 영역 - Image 컴포넌트로 수정 */}
- <div className="relative hidden h-full flex-col p-10 text-white dark:border-r md:flex">
- {/* Image 컴포넌트로 대체 */}
- <div className="absolute inset-0">
- <Image
- src="/images/02.jpg"
- alt="Background image"
- fill
- priority
- sizes="(max-width: 1024px) 100vw, 50vw"
- className="object-cover"
- />
- </div>
- <div className="relative z-10 mt-auto">
- <blockquote className="space-y-2">
- <p className="text-sm">&ldquo;{t("blockquote")}&rdquo;</p>
- {/* <footer className="text-sm">SHI</footer> */}
- </blockquote>
- </div>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/components/login/login-form-shi.tsx b/components/login/login-form-shi.tsx
index d22e15a8..6be8d5c8 100644
--- a/components/login/login-form-shi.tsx
+++ b/components/login/login-form-shi.tsx
@@ -21,6 +21,7 @@ import { verifyTokenAction } from "@/lib/users/verifyToken";
import { buttonVariants } from "@/components/ui/button"
import Link from "next/link"
import Image from 'next/image'; // 추가: Image 컴포넌트 import
+import { KnoxSSOButton } from './saml-login-button'; // SAML 로그인 버튼 import
export function LoginFormSHI({
className,
@@ -225,6 +226,23 @@ export function LoginFormSHI({
<Button type="submit" className="w-full" variant="samsung" disabled={isLoading}>
{isLoading ? t('sending') : t('ContinueWithEmail')}
</Button>
+
+ {/* 구분선과 "Or continue with" 섹션 추가 */}
+ <div className="relative">
+ <div className="absolute inset-0 flex items-center">
+ <span className="w-full border-t"></span>
+ </div>
+ <div className="relative flex justify-center text-xs uppercase">
+ <span className="bg-background px-2 text-muted-foreground">
+ {t('orContinueWith')}
+ </span>
+ </div>
+ </div>
+
+ {/* SAML 로그인 버튼 - 로직 분리 */}
+ <KnoxSSOButton />
+
+ {/* 언어 선택 드롭다운 */}
<div className="text-center text-sm mx-auto">
<DropdownMenu>
<DropdownMenuTrigger asChild>
diff --git a/components/login/saml-login-button.tsx b/components/login/saml-login-button.tsx
new file mode 100644
index 00000000..02825e2f
--- /dev/null
+++ b/components/login/saml-login-button.tsx
@@ -0,0 +1,121 @@
+'use client'
+
+/**
+ *
+ * SAML 2.0 기반 SSO 로그인 요청을 시작하는 버튼 컴포넌트
+ *
+ *
+ */
+
+import { useState } from 'react'
+import { Button } from '@/components/ui/button'
+import { toast } from '@/hooks/use-toast'
+import { Loader2, Shield } from 'lucide-react'
+import React from 'react'
+
+interface SAMLLoginButtonProps {
+ className?: string
+ children?: React.ReactNode
+ variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link' | 'samsung'
+ size?: 'default' | 'sm' | 'lg' | 'icon'
+}
+
+export function SAMLLoginButton({
+ className,
+ children = "Knox SSO로 로그인하기",
+ variant = "outline",
+ size = "default"
+}: SAMLLoginButtonProps) {
+ const [isLoading, setIsLoading] = useState(false)
+
+ const handleSAMLLogin = async () => {
+ try {
+ setIsLoading(true)
+
+ // 현재 페이지 경로를 RelayState로 설정 (로그인 후 이 페이지로 돌아옴)
+ const currentPath = window.location.pathname + window.location.search
+ const relayState = encodeURIComponent(currentPath)
+
+ console.log('Setting RelayState to:', currentPath)
+
+ // API 엔드포인트를 통해 SAML AuthnRequest URL 생성
+ const response = await fetch(`/api/auth/saml/authn-request?relayState=${relayState}`, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ })
+
+ if (!response.ok) {
+ throw new Error('Failed to create SAML AuthnRequest')
+ }
+
+ const data = await response.json()
+
+ if (!data.success || !data.loginUrl) {
+ throw new Error(data.error || 'Failed to get SAML login URL')
+ }
+
+ console.log('SAML Login URL:', data.loginUrl)
+
+ // 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) {
+ console.error('SAML Login Error:', error)
+ toast({
+ title: '로그인 오류',
+ description: 'SAML 로그인을 시작할 수 없습니다.',
+ variant: 'destructive',
+ })
+ setIsLoading(false)
+ }
+ }
+
+ return (
+ <Button
+ type="button"
+ variant={variant}
+ size={size}
+ className={className}
+ onClick={handleSAMLLogin}
+ disabled={isLoading}
+ >
+ {isLoading ? (
+ <Loader2 className="h-4 w-4 animate-spin mr-2" />
+ ) : (
+ <Shield className="h-4 w-4 mr-2" />
+ )}
+ {children}
+ </Button>
+ )
+}
+
+// 간단한 Knox SSO 버튼 (props 없이)
+export function KnoxSSOButton() {
+ return (
+ <SAMLLoginButton
+ className="w-full"
+ variant="outline"
+ >
+ Knox SSO (STAGE 단계)
+ </SAMLLoginButton>
+ )
+}
diff --git a/config/saml/idp_metadata.xml b/config/saml/idp_metadata.xml
new file mode 100644
index 00000000..bc448907
--- /dev/null
+++ b/config/saml/idp_metadata.xml
@@ -0,0 +1,26 @@
+<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="www.stage1.samsung.net">
+ <md:IDPSSODescriptor WantAuthnRequestsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
+ <md:KeyDescriptor use="signing">
+ <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
+ <ds:X509Data>
+ <ds:X509Certificate>MIID2zCCAsOgAwIBAgIJAKUgkCmmclHOMA0GCSqGSIb3DQEBCwUAMIGDMQswCQYDVQQGEwJLUjEPMA0GA1UECAwGSmFtc2lsMQ4wDAYDVQQHDAVTZW91bDETMBEGA1UECgwKU2Ftc3VuZ1NEUzEdMBsGA1UECwwUSW50cmFuZXRCdXNpbmVzc1RlYW0xHzAdBgNVBAMMFm5ldC5zYW1zdW5nLmtub3hwb3J0YWwwHhcNMTcwOTA2MDQxNDAzWhcNMjcwOTA0MDQxNDAzWjCBgzELMAkGA1UEBhMCS1IxDzANBgNVBAgMBkphbXNpbDEOMAwGA1UEBwwFU2VvdWwxEzARBgNVBAoMClNhbXN1bmdTRFMxHTAbBgNVBAsMFEludHJhbmV0QnVzaW5lc3NUZWFtMR8wHQYDVQQDDBZuZXQuc2Ftc3VuZy5rbm94cG9ydGFsMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1nT5VRgS/PGj7iL8l4kpyEqs04BocOrIPf9mn+Ky/pA3BkgfxItkAfxqKjrzZ2J/0yB1jkjpHYxQQSpah5f/FrxK/G3lCMlpQzFgT9qfX/VJqhJLU3JF4hhxTVp77rF5Sqz2CWdTzrKgEhVhQupfANL67uw1GrR2AoPWsmGqr/ybdEcjr0w3lYrnCb9LYvvT+KOmZg1nVEbMAJ66xFuiuc4IGAot+IIHY86ZjSXRfMBkJaisEpStXXja0PD8SHDu31DdLomaRYrv9eyoh3q/LONejfgd8IrAJO3Om8zNmfF2Q665Ab4oPFoRznjvR74/pszIxqQTYoVgKkDKRmTOjQIDAQABo1AwTjAdBgNVHQ4EFgQUiolG//FttT/5g3IBaoRvjNWNCt0wHwYDVR0jBBgwFoAUiolG//FttT/5g3IBaoRvjNWNCt0wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAQVBxmGnZHo3dMLKFgAf8oLevA1TuA03p6jj2MVLwFMjw0S74bFpgS4ZXEzsliGAQprVwTzo06XtTxQENxddbFMRfKroKvpyM20uBt2JI5nBmE/kzrb4AOguRRTNKfb9o4zk2yO7Ra31dWHrvZ3usV8A0KLIHef6iUPv4mBMXY5e7gEUjoZxbZQucyHOrYvuj/TISd7n6r37cotf5ldUD5B+ADP05AgTTP1vKzyfOsb+zRqTTi8WFOc2SlbTktXPvfiQmHs6OoCbNNYXfQT+YO0x3y8M4TevvoeKvTjQp1E+Q+J8hAh7xTIemb6wP460ObUD9w+wyqUk44XJGdibtgQ==</ds:X509Certificate>
+ </ds:X509Data>
+ </ds:KeyInfo>
+ </md:KeyDescriptor>
+ <md:KeyDescriptor use="encryption">
+ <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
+ <ds:X509Data>
+ <ds:X509Certificate>MIID2zCCAsOgAwIBAgIJAKUgkCmmclHOMA0GCSqGSIb3DQEBCwUAMIGDMQswCQYDVQQGEwJLUjEPMA0GA1UECAwGSmFtc2lsMQ4wDAYDVQQHDAVTZW91bDETMBEGA1UECgwKU2Ftc3VuZ1NEUzEdMBsGA1UECwwUSW50cmFuZXRCdXNpbmVzc1RlYW0xHzAdBgNVBAMMFm5ldC5zYW1zdW5nLmtub3hwb3J0YWwwHhcNMTcwOTA2MDQxNDAzWhcNMjcwOTA0MDQxNDAzWjCBgzELMAkGA1UEBhMCS1IxDzANBgNVBAgMBkphbXNpbDEOMAwGA1UEBwwFU2VvdWwxEzARBgNVBAoMClNhbXN1bmdTRFMxHTAbBgNVBAsMFEludHJhbmV0QnVzaW5lc3NUZWFtMR8wHQYDVQQDDBZuZXQuc2Ftc3VuZy5rbm94cG9ydGFsMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1nT5VRgS/PGj7iL8l4kpyEqs04BocOrIPf9mn+Ky/pA3BkgfxItkAfxqKjrzZ2J/0yB1jkjpHYxQQSpah5f/FrxK/G3lCMlpQzFgT9qfX/VJqhJLU3JF4hhxTVp77rF5Sqz2CWdTzrKgEhVhQupfANL67uw1GrR2AoPWsmGqr/ybdEcjr0w3lYrnCb9LYvvT+KOmZg1nVEbMAJ66xFuiuc4IGAot+IIHY86ZjSXRfMBkJaisEpStXXja0PD8SHDu31DdLomaRYrv9eyoh3q/LONejfgd8IrAJO3Om8zNmfF2Q665Ab4oPFoRznjvR74/pszIxqQTYoVgKkDKRmTOjQIDAQABo1AwTjAdBgNVHQ4EFgQUiolG//FttT/5g3IBaoRvjNWNCt0wHwYDVR0jBBgwFoAUiolG//FttT/5g3IBaoRvjNWNCt0wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAQVBxmGnZHo3dMLKFgAf8oLevA1TuA03p6jj2MVLwFMjw0S74bFpgS4ZXEzsliGAQprVwTzo06XtTxQENxddbFMRfKroKvpyM20uBt2JI5nBmE/kzrb4AOguRRTNKfb9o4zk2yO7Ra31dWHrvZ3usV8A0KLIHef6iUPv4mBMXY5e7gEUjoZxbZQucyHOrYvuj/TISd7n6r37cotf5ldUD5B+ADP05AgTTP1vKzyfOsb+zRqTTi8WFOc2SlbTktXPvfiQmHs6OoCbNNYXfQT+YO0x3y8M4TevvoeKvTjQp1E+Q+J8hAh7xTIemb6wP460ObUD9w+wyqUk44XJGdibtgQ==</ds:X509Certificate>
+ </ds:X509Data>
+ </ds:KeyInfo>
+ <md:EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes256-cbc">
+ <xenc:KeySize xmlns:xenc="http://www.w3.org/2001/04/xmlenc#">256</xenc:KeySize>
+ </md:EncryptionMethod>
+ </md:KeyDescriptor>
+ <md:Organization>
+ <md:OrganizationName xml:lang="en">Knox Portal</md:OrganizationName>
+ </md:Organization>
+ <md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:entity</md:NameIDFormat>
+ <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://epsso.stage.samsung.net/sso/saml/SingleSignOnService"/>
+ </md:IDPSSODescriptor>
+</md:EntityDescriptor> \ No newline at end of file
diff --git a/config/saml/sp_metadata.xml b/config/saml/sp_metadata.xml
new file mode 100644
index 00000000..9d0ff626
--- /dev/null
+++ b/config/saml/sp_metadata.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
+ entityID="http://60.101.108.100">
+
+ <md:SPSSODescriptor
+ protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"
+ AuthnRequestsSigned="false"
+ WantAssertionsSigned="false">
+
+ <!-- HTTP-POST 및 HTTP-Redirect 바인딩 둘 다 지원 -->
+ <md:AssertionConsumerService
+ Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
+ Location="http://60.101.108.100/api/saml/callback"
+ index="0"
+ isDefault="true"/>
+
+ <md:AssertionConsumerService
+ Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
+ Location="http://60.101.108.100/api/saml/callback"
+ index="1"/>
+
+ </md:SPSSODescriptor>
+
+</md:EntityDescriptor> \ No newline at end of file
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
diff --git a/lib/saml/idp-metadata.ts b/lib/saml/idp-metadata.ts
new file mode 100644
index 00000000..a33ecad6
--- /dev/null
+++ b/lib/saml/idp-metadata.ts
@@ -0,0 +1,86 @@
+// SAML Identity Provider (IdP) 메타데이터 유틸리티
+
+export interface IDPMetadata {
+ entityId: string
+ ssoUrl: string
+ sloUrl?: string
+ certificate: string
+ nameIdFormat: string
+ organization: string
+ wantAuthnRequestsSigned: boolean
+}
+
+// IdP 메타데이터 가져오기 (환경변수 기반)
+export function getIDPMetadata(): IDPMetadata {
+ console.log('🔍 Loading IdP metadata from environment variables...')
+
+ // 필수 환경변수 검증
+ const entityId = process.env.SAML_IDP_ENTITY_ID
+ const ssoUrl = process.env.SAML_IDP_SSO_URL
+ const certificate = process.env.SAML_IDP_CERT
+
+ if (!entityId || !ssoUrl || !certificate) {
+ throw new Error('Required SAML IdP environment variables are missing: SAML_IDP_ENTITY_ID, SAML_IDP_SSO_URL, SAML_IDP_CERT')
+ }
+
+ const metadata: IDPMetadata = {
+ entityId,
+ ssoUrl,
+ sloUrl: process.env.SAML_IDP_SLO_URL,
+ certificate,
+ nameIdFormat: process.env.SAML_IDP_NAME_ID_FORMAT || 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
+ organization: process.env.SAML_IDP_ORGANIZATION || 'Unknown Organization',
+ wantAuthnRequestsSigned: process.env.SAML_IDP_WANT_AUTHN_REQUESTS_SIGNED === 'true'
+ }
+
+ console.log('✅ IdP metadata loaded from environment variables:', {
+ entityId,
+ ssoUrl,
+ sloUrl: metadata.sloUrl,
+ organization: metadata.organization,
+ wantAuthnRequestsSigned: metadata.wantAuthnRequestsSigned
+ })
+
+ return metadata
+}
+
+// 인증서 형식 정규화 (PEM 형식으로 변환)
+export function normalizeCertificate(cert: string): string {
+ // 이미 PEM 형식인 경우 그대로 반환
+ if (cert.includes('-----BEGIN CERTIFICATE-----')) {
+ return cert
+ }
+
+ // Base64 인증서를 PEM 형식으로 변환 (64자마다 줄바꿈)
+ const cleanCert = cert.replace(/\s+/g, '')
+ const formattedCert = cleanCert.match(/.{1,64}/g)?.join('\n') || cleanCert
+ return `-----BEGIN CERTIFICATE-----\n${formattedCert}\n-----END CERTIFICATE-----`
+}
+
+// 특정 용도의 인증서 가져오기
+export function getCertificateByUse(metadata: IDPMetadata, use: 'signing' | 'encryption'): string {
+ const cert = metadata.certificates.find(c => c.use === use)
+ return cert ? normalizeCertificate(cert.certificate) : ''
+}
+
+// 모든 인증서를 PEM 형식으로 변환
+export function getAllCertificatesAsPEM(metadata: IDPMetadata): { use: string; pem: string }[] {
+ return metadata.certificates.map(cert => ({
+ use: cert.use,
+ pem: normalizeCertificate(cert.certificate)
+ }))
+}
+
+// 레거시 호환성을 위한 함수 - 첫 번째 인증서를 문자열로 반환
+export function getFirstCertificateAsString(metadata: IDPMetadata): string {
+ return metadata.certificates[0]?.certificate || ''
+}
+
+// SP 메타데이터 생성을 위한 헬퍼
+export function getSPEntityId(): string {
+ return process.env.SAML_SP_ENTITY_ID || `${process.env.NEXTAUTH_URL}/saml/metadata`
+}
+
+export function getSPCallbackUrl(): string {
+ return `${process.env.NEXTAUTH_URL}/api/saml/callback`
+} \ No newline at end of file
diff --git a/lib/saml/sp-metadata.ts b/lib/saml/sp-metadata.ts
new file mode 100644
index 00000000..7f25f602
--- /dev/null
+++ b/lib/saml/sp-metadata.ts
@@ -0,0 +1,82 @@
+// SAML Service Provider (SP) 메타데이터 유틸리티
+
+export interface SPAssertionConsumerService {
+ binding: string
+ location: string
+ index: number
+ isDefault?: boolean
+}
+
+export interface SPMetadata {
+ entityId: string
+ // SPSSODescriptor 속성들
+ protocolSupportEnumeration: string[]
+ authnRequestsSigned: boolean
+ wantAssertionsSigned: boolean
+ // 서비스 엔드포인트들
+ assertionConsumerServices: SPAssertionConsumerService[]
+ // 편의를 위한 기본값들
+ defaultACS: SPAssertionConsumerService
+ callbackUrl: string
+}
+
+// SP 메타데이터 가져오기 (환경변수 기반)
+export function getSPMetadata(): SPMetadata {
+ console.log('🔍 Loading SP metadata from environment variables...')
+
+ // 필수 환경변수 검증
+ const entityId = process.env.SAML_SP_ENTITY_ID
+ const callbackUrl = process.env.SAML_SP_CALLBACK_URL || `${process.env.NEXTAUTH_URL}/api/saml/callback`
+
+ if (!entityId) {
+ throw new Error('Required SAML environment variable is missing: SAML_SP_ENTITY_ID')
+ }
+
+ if (!callbackUrl) {
+ throw new Error('SAML_SP_CALLBACK_URL or NEXTAUTH_URL environment variable is required')
+ }
+
+ // AssertionConsumerService 구성
+ const assertionConsumerServices: SPAssertionConsumerService[] = [
+ // 기본 HTTP-POST 바인딩
+ {
+ binding: process.env.SAML_SP_ACS_BINDING_PRIMARY || 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
+ location: callbackUrl,
+ index: 0,
+ isDefault: true
+ }
+ ]
+
+ // 보조 HTTP-Redirect 바인딩 (선택사항)
+ const secondaryBinding = process.env.SAML_SP_ACS_BINDING_SECONDARY
+ if (secondaryBinding) {
+ assertionConsumerServices.push({
+ binding: secondaryBinding,
+ location: callbackUrl,
+ index: 1,
+ isDefault: false
+ })
+ }
+
+ const metadata: SPMetadata = {
+ entityId,
+ protocolSupportEnumeration: [
+ process.env.SAML_SP_PROTOCOL_SUPPORT || 'urn:oasis:names:tc:SAML:2.0:protocol'
+ ],
+ authnRequestsSigned: process.env.SAML_SP_AUTHN_REQUESTS_SIGNED === 'true',
+ wantAssertionsSigned: process.env.SAML_SP_WANT_ASSERTIONS_SIGNED === 'true',
+ assertionConsumerServices,
+ defaultACS: assertionConsumerServices[0],
+ callbackUrl
+ }
+
+ console.log('✅ SP metadata loaded from environment variables:', {
+ entityId,
+ callbackUrl,
+ authnRequestsSigned: metadata.authnRequestsSigned,
+ wantAssertionsSigned: metadata.wantAssertionsSigned,
+ acsCount: assertionConsumerServices.length
+ })
+
+ return metadata
+} \ No newline at end of file
diff --git a/lib/users/repository.ts b/lib/users/repository.ts
index 3a404bde..75981b03 100644
--- a/lib/users/repository.ts
+++ b/lib/users/repository.ts
@@ -44,6 +44,21 @@ export const createUser = async (name: string, email: string): Promise<User> =>
return user
};
+// SAML 사용자 생성 (domain과 추가 정보 포함)
+export const createSAMLUser = async (
+ name: string,
+ email: string,
+ domain: 'evcp' | 'partners' = 'evcp',
+): Promise<User> => {
+ const usersRes = await db.insert(users).values({
+ name,
+ email,
+ domain,
+ }).returning();
+ const user = usersRes[0];
+ return user
+};
+
// 사용자 업데이트
export const updateUser = async (id: number, data: Partial<User>): Promise<User | null> => {
const usersRes = await db.update(users).set(data).where(eq(users.id, id)).returning();
diff --git a/lib/users/saml-service.ts b/lib/users/saml-service.ts
new file mode 100644
index 00000000..cd3c604f
--- /dev/null
+++ b/lib/users/saml-service.ts
@@ -0,0 +1,134 @@
+"use server";
+
+import { getUserByEmail, createSAMLUser } from './repository';
+import { User } from '@/db/schema/users';
+import logger from '@/lib/logger';
+
+export interface SAMLUserData {
+ email: string;
+ name: string;
+ companyId?: number;
+ techCompanyId?: number;
+ domain?: string;
+}
+
+/**
+ * SAML JIT (Just-In-Time) 사용자 생성 또는 조회
+ *
+ * @param samlData SAML에서 받은 사용자 데이터
+ * @returns DB에 저장된 사용자 정보
+ */
+export async function getOrCreateSAMLUser(samlData: SAMLUserData): Promise<User | null> {
+ try {
+ logger.info({ email: samlData.email }, 'SAML JIT: Processing user');
+
+ // 1. 기존 사용자 조회
+ const existingUser = await getUserByEmail(samlData.email);
+
+ if (existingUser) {
+ logger.info({
+ userId: existingUser.id,
+ email: existingUser.email
+ }, 'SAML JIT: Existing user found');
+
+ // 기존 사용자가 있으면 그대로 반환
+ return existingUser;
+ }
+
+ // 2. 새 사용자 생성 (JIT)
+ logger.info({
+ email: samlData.email,
+ name: samlData.name
+ }, 'SAML JIT: Creating new user');
+
+ const newUser = await createSAMLUserFromData(samlData);
+
+ logger.info({
+ userId: newUser.id,
+ email: newUser.email
+ }, 'SAML JIT: New user created successfully');
+
+ return newUser;
+
+ } catch (error) {
+ logger.error({
+ error,
+ email: samlData.email
+ }, 'SAML JIT: Failed to get or create user');
+
+ return null;
+ }
+}
+
+/**
+ * SAML 데이터로 새 사용자 생성
+ *
+ * @param samlData SAML 사용자 데이터
+ * @returns 생성된 사용자
+ */
+async function createSAMLUserFromData(samlData: SAMLUserData): Promise<User> {
+ // 1. UTF-8 name 처리
+ let userName = samlData.name || samlData.email.split('@')[0];
+
+ // 길이 제한 확인 (DB varchar(255) 제한)
+ if (userName.length > 255) {
+ userName = userName.substring(0, 252) + '...';
+ }
+
+ // 빈 문자열 방지
+ if (!userName.trim()) {
+ userName = samlData.email.split('@')[0] || 'SAML User';
+ }
+
+ // 2. domain을 evcp로 고정
+ const domain = 'evcp';
+
+ // 3. SAML 사용자 생성 (Repository 함수 재사용하고 싶었으나 domain 정보 입력 불가로 새로운 함수 정의함)
+ const newUser = await createSAMLUser(
+ userName,
+ samlData.email,
+ domain,
+ );
+
+ // 4. 생성된 사용자 검증
+ if (newUser.domain !== 'evcp') {
+ logger.error({
+ userId: newUser.id,
+ expectedDomain: 'evcp',
+ actualDomain: newUser.domain
+ }, 'SAML JIT: Domain mismatch in created user');
+ throw new Error('Failed to create SAML user with correct domain');
+ }
+
+ logger.info({
+ userId: newUser.id,
+ email: newUser.email,
+ name: newUser.name,
+ nameLength: newUser.name.length,
+ domain: newUser.domain,
+ companyId: newUser.companyId,
+ techCompanyId: newUser.techCompanyId
+ }, 'SAML JIT: New user created with UTF-8 name and evcp domain');
+
+ return newUser;
+}
+
+/**
+ * SAML 사용자 데이터 검증
+ *
+ * @param samlData 검증할 SAML 데이터
+ * @returns 유효성 여부
+ */
+export async function validateSAMLUserData(samlData: unknown): Promise<boolean> {
+ return (
+ samlData !== null &&
+ samlData !== undefined &&
+ typeof samlData === 'object' &&
+ 'email' in samlData &&
+ 'name' in samlData &&
+ typeof (samlData as Record<string, unknown>).email === 'string' &&
+ typeof (samlData as Record<string, unknown>).name === 'string' &&
+ (samlData as Record<string, unknown>).email.includes('@') &&
+ (samlData as Record<string, unknown>).name.length > 0
+ );
+} \ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 3051c42b..e1ac4f40 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3015,6 +3015,95 @@
"node": ">= 18"
}
},
+ "node_modules/@node-saml/node-saml/node_modules/@xmldom/xmldom": {
+ "version": "0.8.10",
+ "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
+ "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/@node-saml/node-saml/node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/@node-saml/node-saml/node_modules/xml-crypto": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-6.1.2.tgz",
+ "integrity": "sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w==",
+ "license": "MIT",
+ "dependencies": {
+ "@xmldom/is-dom-node": "^1.0.1",
+ "@xmldom/xmldom": "^0.8.10",
+ "xpath": "^0.0.33"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@node-saml/node-saml/node_modules/xml-crypto/node_modules/xpath": {
+ "version": "0.0.33",
+ "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.33.tgz",
+ "integrity": "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6.0"
+ }
+ },
+ "node_modules/@node-saml/node-saml/node_modules/xml-encryption": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/xml-encryption/-/xml-encryption-3.1.0.tgz",
+ "integrity": "sha512-PV7qnYpoAMXbf1kvQkqMScLeQpjCMixddAKq9PtqVrho8HnYbBOWNfG0kA4R7zxQDo7w9kiYAyzS/ullAyO55Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@xmldom/xmldom": "^0.8.5",
+ "escape-html": "^1.0.3",
+ "xpath": "0.0.32"
+ }
+ },
+ "node_modules/@node-saml/node-saml/node_modules/xml-encryption/node_modules/xpath": {
+ "version": "0.0.32",
+ "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.32.tgz",
+ "integrity": "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6.0"
+ }
+ },
+ "node_modules/@node-saml/node-saml/node_modules/xml2js": {
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
+ "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
+ "license": "MIT",
+ "dependencies": {
+ "sax": ">=0.6.0",
+ "xmlbuilder": "~11.0.0"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/@node-saml/node-saml/node_modules/xml2js/node_modules/xmlbuilder": {
+ "version": "11.0.1",
+ "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
+ "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/@node-saml/node-saml/node_modules/xpath": {
+ "version": "0.0.34",
+ "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.34.tgz",
+ "integrity": "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6.0"
+ }
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -5114,15 +5203,6 @@
"node": ">= 16"
}
},
- "node_modules/@xmldom/xmldom": {
- "version": "0.8.10",
- "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
- "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==",
- "license": "MIT",
- "engines": {
- "node": ">=10.0.0"
- }
- },
"node_modules/abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
@@ -15547,77 +15627,6 @@
"node": ">= 6"
}
},
- "node_modules/xml-crypto": {
- "version": "6.1.2",
- "resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-6.1.2.tgz",
- "integrity": "sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w==",
- "license": "MIT",
- "dependencies": {
- "@xmldom/is-dom-node": "^1.0.1",
- "@xmldom/xmldom": "^0.8.10",
- "xpath": "^0.0.33"
- },
- "engines": {
- "node": ">=16"
- }
- },
- "node_modules/xml-crypto/node_modules/xpath": {
- "version": "0.0.33",
- "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.33.tgz",
- "integrity": "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA==",
- "license": "MIT",
- "engines": {
- "node": ">=0.6.0"
- }
- },
- "node_modules/xml-encryption": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/xml-encryption/-/xml-encryption-3.1.0.tgz",
- "integrity": "sha512-PV7qnYpoAMXbf1kvQkqMScLeQpjCMixddAKq9PtqVrho8HnYbBOWNfG0kA4R7zxQDo7w9kiYAyzS/ullAyO55Q==",
- "license": "MIT",
- "dependencies": {
- "@xmldom/xmldom": "^0.8.5",
- "escape-html": "^1.0.3",
- "xpath": "0.0.32"
- }
- },
- "node_modules/xml-encryption/node_modules/escape-html": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
- "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
- "license": "MIT"
- },
- "node_modules/xml-encryption/node_modules/xpath": {
- "version": "0.0.32",
- "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.32.tgz",
- "integrity": "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==",
- "license": "MIT",
- "engines": {
- "node": ">=0.6.0"
- }
- },
- "node_modules/xml2js": {
- "version": "0.6.2",
- "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
- "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
- "license": "MIT",
- "dependencies": {
- "sax": ">=0.6.0",
- "xmlbuilder": "~11.0.0"
- },
- "engines": {
- "node": ">=4.0.0"
- }
- },
- "node_modules/xml2js/node_modules/xmlbuilder": {
- "version": "11.0.1",
- "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
- "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
- "license": "MIT",
- "engines": {
- "node": ">=4.0"
- }
- },
"node_modules/xmlbuilder": {
"version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
@@ -15641,15 +15650,6 @@
"node": ">=0.4.0"
}
},
- "node_modules/xpath": {
- "version": "0.0.34",
- "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.34.tgz",
- "integrity": "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==",
- "license": "MIT",
- "engines": {
- "node": ">=0.6.0"
- }
- },
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",