summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-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
4 files changed, 317 insertions, 0 deletions
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