summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.env.development27
-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.ts128
-rw-r--r--app/api/auth/[...nextauth]/saml/utils.ts405
-rw-r--r--app/api/auth/saml/authn-request/route.ts54
-rw-r--r--app/api/saml/callback/route.ts145
-rw-r--r--components/login/login-form-shi.tsx18
-rw-r--r--components/login/saml-login-button.tsx98
-rw-r--r--config/saml/idp_metadata.xml26
-rw-r--r--config/saml/sp_metadata.xml24
-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.json176
-rw-r--r--package.json1
18 files changed, 1505 insertions, 32 deletions
diff --git a/.env.development b/.env.development
index d65646f4..07b67a22 100644
--- a/.env.development
+++ b/.env.development
@@ -58,4 +58,29 @@ 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/.env.production b/.env.production
index 43310d5a..ebfaed71 100644
--- a/.env.production
+++ b/.env.production
@@ -56,4 +56,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..92099be0
--- /dev/null
+++ b/app/api/auth/[...nextauth]/saml/provider.ts
@@ -0,0 +1,128 @@
+import CredentialsProvider from "next-auth/providers/credentials"
+import { getOrCreateSAMLUser, validateSAMLUserData } from '@/lib/users/saml-service'
+
+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) {
+ try {
+ if (!credentials?.user) {
+ console.error('No user data provided')
+ return null
+ }
+
+ console.log('🔐 SAML Provider: Processing user data')
+
+ // 사용자 데이터 파싱 (UTF-8 처리 개선)
+ const userDataString = credentials.user
+ console.log('🔤 Raw user data string:', userDataString.substring(0, 200) + '...')
+
+ const userData = JSON.parse(userDataString)
+
+ // 파싱된 데이터의 UTF-8 확인
+ console.log('🔤 Parsed user data UTF-8 check:', {
+ name: userData.name,
+ nameLength: userData.name?.length,
+ charCodes: userData.name ? [...userData.name].map(c => c.charCodeAt(0)) : []
+ })
+
+ if (!userData.id || !userData.email) {
+ console.error('Invalid SAML user data:', userData)
+ return null
+ }
+
+ console.log('✅ SAML Provider: User authenticated successfully', {
+ id: userData.id,
+ email: userData.email,
+ name: userData.name
+ })
+
+ // 🔥 SAML 사용자 데이터 검증
+ const isValidData = await validateSAMLUserData(userData)
+ if (!isValidData) {
+ console.error('Invalid SAML user data structure:', userData)
+ return null
+ }
+
+ // 🔥 JIT (Just-In-Time) 사용자 생성 또는 조회
+ const dbUser = await getOrCreateSAMLUser({
+ email: userData.email,
+ name: userData.name,
+ // companyId: userData.companyId,
+ // techCompanyId: userData.techCompanyId,
+ // ! domain = evcp 이면 vendor가 갖는 companyId, techCompanyId는 null
+ companyId: undefined,
+ techCompanyId: undefined,
+ domain: userData.domain
+ })
+
+ if (!dbUser) {
+ console.error('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
+ }
+
+ console.log('✅ SAML Provider: Returning user data to NextAuth:', userResult)
+ return userResult
+ } catch (error) {
+ console.error('❌ SAML Provider: Authentication failed', error)
+ 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)
+}
+ \ 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..7dfe9581
--- /dev/null
+++ b/app/api/auth/[...nextauth]/saml/utils.ts
@@ -0,0 +1,405 @@
+import { SAML, ValidateInResponseTo } from "@node-saml/node-saml";
+import {
+ getIDPMetadata,
+ normalizeCertificate,
+} from "@/lib/saml/idp-metadata";
+import {
+ getSPMetadata,
+} from "@/lib/saml/sp-metadata";
+
+export interface SAMLProfile {
+ nameID?: string;
+ nameIDFormat?: string;
+ attributes?: Record<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(): Promise<string> {
+ "use server";
+
+ console.log("SSO STEP 2: Create AuthnRequest");
+
+ 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
+ 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 = require("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) => 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=") ||
+ 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.message);
+ console.log(
+ " 원본 바이너리 데이터 (hex):",
+ base64DecodedBuffer.toString("hex").substring(0, 100) + "..."
+ );
+ }
+ }
+ } catch (decodeError) {
+ console.log("❌ Base64 디코딩 실패:", decodeError.message);
+ }
+ }
+ } catch (analysisError) {
+ console.log("⚠️ SAML AuthnRequest 분석 중 오류:", analysisError.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(),
+ });
+
+ // 실제 SAML 검증 수행 (기본값)
+ console.log(
+ "🔐 Using Real SAML validation (SAML_USE_MOCKUP=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,
+ nameIDFormat: profile.nameIDFormat,
+ attributes: profile.attributes || {},
+ };
+
+ 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,
+ });
+
+ // 기본적으로 nameID를 사용하거나 attributes에서 추출
+ const id =
+ profile.nameID ||
+ profile.attributes?.uid?.[0] ||
+ profile.attributes?.employeeNumber?.[0] ||
+ "";
+ const email =
+ profile.attributes?.email?.[0] ||
+ profile.attributes?.mail?.[0] ||
+ profile.nameID ||
+ "";
+ // UTF-8 이름 처리 개선
+ let name =
+ profile.attributes?.displayName?.[0] ||
+ profile.attributes?.cn?.[0] ||
+ profile.attributes?.name?.[0] ||
+ (profile.attributes?.givenName?.[0] && profile.attributes?.sn?.[0]
+ ? profile.attributes.givenName[0] + " " + profile.attributes.sn[0]
+ : "") ||
+ "";
+
+ // UTF-8 문자열 정규화 및 검증
+ if (name && typeof name === "string") {
+ name = name.normalize("NFC").trim();
+
+ // 한글이 깨진 경우 감지 및 로그
+ const hasInvalidChars = /[\uFFFD\x00-\x1F\x7F-\x9F]/.test(name);
+ if (hasInvalidChars) {
+ console.warn("⚠️ Invalid UTF-8 characters detected in name:", {
+ originalName: name,
+ charCodes: [...name].map((c) => c.charCodeAt(0)),
+ hexDump: [...name]
+ .map((c) => "\\x" + c.charCodeAt(0).toString(16).padStart(2, "0"))
+ .join(""),
+ });
+ }
+ }
+
+ // 회사 정보는 SSO 로그인 시 없음
+ const companyId = undefined;
+ const techCompanyId = undefined;
+ const domain = 'evcp';
+
+ const user = {
+ id,
+ email,
+ name: name.trim(),
+ companyId,
+ techCompanyId,
+ domain,
+ };
+
+ console.log("👤 Mapped user object:", user);
+
+ return user;
+}
+
+// SAML 로그아웃 URL 생성 (서버 액션)
+// 로그아웃 지원 안함. 일단 구조만 유사하게 작성해둠.
+export async function createLogoutRequest(nameID: string): Promise<string> {
+ "use server";
+
+ const saml = new SAML(createSAMLConfig());
+ return await saml.getLogoutUrlAsync(
+ nameID,
+ "", // 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..e3cb8a47
--- /dev/null
+++ b/app/api/auth/saml/authn-request/route.ts
@@ -0,0 +1,54 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { createAuthnRequest } from '../../[...nextauth]/saml/utils'
+
+const samlEnvironment = {
+ NODE_ENV: process.env.NODE_ENV,
+ SAML_USE_MOCKUP: process.env.SAML_USE_MOCKUP,
+ NEXTAUTH_URL: process.env.NEXTAUTH_URL,
+}
+
+// 환경변수 체크
+function checkEnvironment() {
+ console.log('📊 Environment check:', JSON.stringify(samlEnvironment, null, 2))
+
+}
+
+// 요청 받으면 따로 파싱할 것 없이 동일하게 행동하므로 아규먼트 없음
+export async function GET() {
+ console.log('🚀 SAML AuthnRequest API started')
+ checkEnvironment()
+
+ try {
+ console.log('SSO STEP 1: Create AuthnRequest')
+
+ const startTime = Date.now()
+ const loginUrl = await createAuthnRequest()
+ const endTime = Date.now()
+
+ console.log('SAML AuthnRequest created successfully:', {
+ url: loginUrl,
+ urlLength: loginUrl.length,
+ processingTime: `${endTime - startTime}ms`,
+ timestamp: new Date().toISOString()
+ })
+
+ return NextResponse.json({
+ loginUrl,
+ success: true,
+ mode: 'real',
+ message: 'Using real SAML IdP'
+ })
+ } 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()
+ })
+
+ 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/saml/callback/route.ts b/app/api/saml/callback/route.ts
new file mode 100644
index 00000000..d13edc4a
--- /dev/null
+++ b/app/api/saml/callback/route.ts
@@ -0,0 +1,145 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { validateSAMLResponse, mapSAMLProfileToUser } from '../../auth/[...nextauth]/saml/utils'
+
+// IdP가 인증처리 후 POST 요청을 콜백 URL로 날려준다.
+// 이 라우트가 그 요청을 받아준다.
+// GET 요청시 SP 메타데이터를 반환해주는데, 이건 필요 없으면 지우면 된다.
+
+export async function POST(request: NextRequest) {
+ try {
+ console.log('🔐 SAML Callback received at /api/saml/callback')
+
+ // FormData에서 SAML Response 추출
+ const formData = await request.formData()
+ const samlResponse = formData.get('SAMLResponse') as string
+ const relayState = formData.get('RelayState') as string
+
+ console.log('📨 SAML Response received:', {
+ hasResponse: !!samlResponse,
+ relayState: relayState || 'none',
+ responseLength: samlResponse?.length || 0
+ })
+
+ // 🔍 SAML Response 디코딩 및 분석
+ if (samlResponse) {
+ try {
+ console.log('🔍 SAML Response 분석:')
+ console.log('1️⃣ 원본 SAMLResponse (일부):', samlResponse.substring(0, 100) + '...')
+
+ try {
+ // Base64 디코딩 시도
+ const base64Decoded = Buffer.from(samlResponse, 'base64').toString('utf-8')
+ console.log('2️⃣ Base64 디코딩된 XML:')
+ console.log('───────────────────────────────────')
+ console.log(base64Decoded)
+ console.log('───────────────────────────────────')
+
+ // XML 구조 분석
+ const xmlLines = base64Decoded.split('\n').filter(line => line.trim())
+ console.log('3️⃣ XML 구조 요약:')
+ xmlLines.forEach((line, index) => {
+ const trimmed = line.trim()
+ if (trimmed.includes('<saml') || trimmed.includes('<samlp') ||
+ trimmed.includes('ID=') || trimmed.includes('InResponseTo=') ||
+ trimmed.includes('<saml:Assertion') || trimmed.includes('<saml:Subject') ||
+ trimmed.includes('<saml:NameID') || trimmed.includes('<saml:Attribute')) {
+ console.log(` ${index + 1}: ${trimmed}`)
+ }
+ })
+
+ } catch (decodeError) {
+ console.log('2️⃣ Base64 디코딩 실패:', decodeError.message)
+ console.log(' SAMLResponse가 다른 형식으로 인코딩되었을 수 있습니다.')
+ }
+ } catch (analysisError) {
+ console.log('⚠️ SAML Response 분석 중 오류:', analysisError.message)
+ }
+ }
+
+ if (!samlResponse) {
+ console.error('❌ No SAML Response found in request')
+ return NextResponse.redirect(
+ new URL('/ko/auth/error?error=missing_saml_response', request.url)
+ )
+ }
+
+ // SAML Response 검증 및 파싱
+ let samlProfile
+ try {
+ console.log('🔍 Starting SAML Response validation...')
+ samlProfile = await validateSAMLResponse(samlResponse)
+ console.log('✅ SAML Response validated successfully:', {
+ nameId: samlProfile.nameID,
+ attributes: Object.keys(samlProfile.attributes || {})
+ })
+ } catch (error) {
+ console.error('❌ SAML Response validation failed:', {
+ error: error instanceof Error ? error.message : String(error),
+ stack: error instanceof Error ? error.stack : undefined,
+ samlResponseLength: samlResponse.length,
+ timestamp: new Date().toISOString()
+ })
+
+ // 더 상세한 에러 정보를 포함한 리다이렉트
+ const errorMessage = error instanceof Error ? error.message : 'Unknown SAML validation error'
+ return NextResponse.redirect(
+ new URL(`/ko/auth/error?error=invalid_saml_response&detail=${encodeURIComponent(errorMessage)}`, request.url)
+ )
+ }
+
+ // SAML 프로필을 사용자 객체로 매핑
+ const user = mapSAMLProfileToUser(samlProfile)
+ console.log('👤 Mapped user:', {
+ id: user.id,
+ email: user.email,
+ name: user.name,
+ companyId: user.companyId
+ })
+
+ // NextAuth SAML Provider를 통한 직접 로그인 처리
+ // 서버 사이드에서 NextAuth signIn 호출은 불가능하므로
+ // NextAuth 콜백 엔드포인트로 리다이렉트
+ const nextAuthCallbackUrl = new URL('/api/auth/callback/credentials-saml', request.url)
+
+ // 사용자 데이터를 query parameter로 전달
+ nextAuthCallbackUrl.searchParams.set('user', JSON.stringify(user))
+ if (relayState) {
+ nextAuthCallbackUrl.searchParams.set('state', relayState)
+ }
+
+ console.log('🔄 Redirecting to NextAuth callback:', nextAuthCallbackUrl.toString())
+ return NextResponse.redirect(nextAuthCallbackUrl)
+
+ } catch (error) {
+ console.error('💥 SAML Callback processing failed:', error)
+ return NextResponse.redirect(
+ new URL('/ko/auth/error?error=callback_processing_failed', request.url)
+ )
+ }
+}
+
+// GET 요청에 대한 메타데이터 반환
+// 필요 없으면 굳이 노출할 필요 없음. 다만 SOAP WDSL과 유사하게 이렇게 스펙을 제공하는 건 SAML 2.0 구현 표준임. 필요하다면 IdP에게만 제공되도록 추가 처리하자.
+export async function GET(_request: NextRequest) {
+ try {
+ // GET 요청은 SP 메타데이터 반환 (정적 파일)
+ const fs = await import('fs/promises')
+ const path = await import('path')
+
+ const metadataPath = path.join(process.cwd(), 'config/saml/sp_metadata.xml')
+ const metadata = await fs.readFile(metadataPath, 'utf-8')
+
+ return new NextResponse(metadata, {
+ headers: {
+ 'Content-Type': 'application/xml',
+ 'Cache-Control': 'public, max-age=36000' // 10시간 캐시
+ }
+ })
+ } catch (error) {
+ console.error('Error reading SP metadata file:', error)
+ return NextResponse.json({
+ error: 'Failed to read SP metadata file',
+ details: error instanceof Error ? error.message : 'Unknown error'
+ }, { status: 500 })
+ }
+} \ 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..2f23bedf
--- /dev/null
+++ b/components/login/saml-login-button.tsx
@@ -0,0 +1,98 @@
+'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)
+
+ // API 엔드포인트를 통해 SAML AuthnRequest URL 생성
+ const response = await fetch('/api/auth/saml/authn-request', {
+ 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)
+
+ // IdP로 리다이렉트
+ 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/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 2c222b07..9b5d3861 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -34,6 +34,7 @@
"@mui/material": "^6.2.1",
"@mui/x-data-grid-premium": "^7.23.3",
"@mui/x-tree-view": "^7.23.6",
+ "@node-saml/node-saml": "^5.0.1",
"@pdftron/pdfnet-node": "^11.3.0",
"@pdftron/webviewer": "^11.3.0",
"@radix-ui/primitive": "^1.1.1",
@@ -2942,6 +2943,118 @@
"node": ">= 10"
}
},
+ "node_modules/@node-saml/node-saml": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/@node-saml/node-saml/-/node-saml-5.0.1.tgz",
+ "integrity": "sha512-YQzFPEC+CnsfO9AFYnwfYZKIzOLx3kITaC1HrjHVLTo6hxcQhc+LgHODOMvW4VCV95Gwrz1MshRUWCPzkDqmnA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/debug": "^4.1.12",
+ "@types/qs": "^6.9.11",
+ "@types/xml-encryption": "^1.2.4",
+ "@types/xml2js": "^0.4.14",
+ "@xmldom/is-dom-node": "^1.0.1",
+ "@xmldom/xmldom": "^0.8.10",
+ "debug": "^4.3.4",
+ "xml-crypto": "^6.0.1",
+ "xml-encryption": "^3.0.2",
+ "xml2js": "^0.6.2",
+ "xmlbuilder": "^15.1.1",
+ "xpath": "^0.0.34"
+ },
+ "engines": {
+ "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",
@@ -4543,6 +4656,15 @@
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
+ "node_modules/@types/debug": {
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
+ "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*"
+ }
+ },
"node_modules/@types/docusign-esign": {
"version": "5.19.8",
"resolved": "https://registry.npmjs.org/@types/docusign-esign/-/docusign-esign-5.19.8.tgz",
@@ -4614,6 +4736,12 @@
"@types/node": "*"
}
},
+ "node_modules/@types/ms": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
+ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
+ "license": "MIT"
+ },
"node_modules/@types/node": {
"version": "20.17.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.10.tgz",
@@ -4719,6 +4847,12 @@
"integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==",
"license": "MIT"
},
+ "node_modules/@types/qs": {
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
+ "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
+ "license": "MIT"
+ },
"node_modules/@types/react": {
"version": "19.0.1",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.1.tgz",
@@ -4757,6 +4891,24 @@
"@types/node": "*"
}
},
+ "node_modules/@types/xml-encryption": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@types/xml-encryption/-/xml-encryption-1.2.4.tgz",
+ "integrity": "sha512-I69K/WW1Dv7j6O3jh13z0X8sLWJRXbu5xnHDl9yHzUNDUBtUoBY058eb5s+x/WG6yZC1h8aKdI2EoyEPjyEh+Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/xml2js": {
+ "version": "0.4.14",
+ "resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.14.tgz",
+ "integrity": "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.18.1.tgz",
@@ -4993,6 +5145,15 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
+ "node_modules/@xmldom/is-dom-node": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@xmldom/is-dom-node/-/is-dom-node-1.0.1.tgz",
+ "integrity": "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ }
+ },
"node_modules/abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
@@ -13020,6 +13181,12 @@
"node": ">=10"
}
},
+ "node_modules/sax": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz",
+ "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==",
+ "license": "ISC"
+ },
"node_modules/saxes": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz",
@@ -15411,6 +15578,15 @@
"node": ">= 6"
}
},
+ "node_modules/xmlbuilder": {
+ "version": "15.1.1",
+ "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
+ "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
diff --git a/package.json b/package.json
index 02c7a3c6..2010d408 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,7 @@
"@mui/material": "^6.2.1",
"@mui/x-data-grid-premium": "^7.23.3",
"@mui/x-tree-view": "^7.23.6",
+ "@node-saml/node-saml": "^5.0.1",
"@pdftron/pdfnet-node": "^11.3.0",
"@pdftron/webviewer": "^11.3.0",
"@radix-ui/primitive": "^1.1.1",