NAVER WORKS IdP SAML SSO 적용 가이드의 하위 문서. JWE 기반 세션 관리와 보안 레이어 설계를 다룬다.
| 비교 | JWT (JWS) | JWE |
|---|---|---|
| 토큰 내용 | Base64 디코딩으로 누구나 읽기 가능 | 암호화되어 키 없이 읽기 불가 |
| 적합한 경우 | 공개 가능한 정보 (예: 공개 프로필) | 민감한 정보 (이메일, 세션 데이터) |
| 세션 토큰 용도 | 이메일, NameID가 노출됨 | 이메일, NameID가 보호됨 |
SAML SSO 세션에는 사용자 이메일, NameID 등 민감 정보가 포함되므로 **JWE(암호화)**가 적합하다.
import { EncryptJWT, jwtDecrypt } from 'jose'
interface SessionUser {
email: string
nameID: string
sessionIndex?: string
attributes?: Record<string, string>
}
// SHA-256으로 256-bit 암호화 키 유도
async function getEncryptionKey(): Promise<Uint8Array> {
const data = new TextEncoder().encode(sessionConfig.secret)
const hash = await globalThis.crypto.subtle.digest('SHA-256', data)
return new Uint8Array(hash)
}
// 세션 토큰 생성 (JWE 암호화)
export async function createSessionToken(
user: SessionUser
): Promise<{ token: string; exp: number }> {
const key = await getEncryptionKey()
const iat = Math.floor(Date.now() / 1000)
const exp = iat + sessionConfig.maxAge
const token = await new EncryptJWT({ user })
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
.setIssuedAt(iat)
.setExpirationTime(exp)
.encrypt(key)
return { token, exp }
}
암호화 설정 선택:
| 선택 | 이유 |
|---|---|
alg: 'dir' (Direct Encryption) |
키 합의 과정 없이 직접 암호화. 단일 서버에 적합 |
enc: 'A256GCM' |
AES-256-GCM. 인증된 암호화로 무결성 + 기밀성 동시 보장 |
| SHA-256으로 키 유도 | 사용자 입력(AUTH_SECRET)을 정확히 256비트로 변환 |
import { cookies } from 'next/headers'
// 세션 읽기
export async function getSession(): Promise<SessionUser | null> {
const cookieStore = await cookies()
const token = cookieStore.get(sessionConfig.cookieName)?.value
if (!token) return null
try {
const key = await getEncryptionKey()
const { payload } = await jwtDecrypt(token, key)
return (payload as any).user
} catch {
return null // 복호화 실패 → 세션 무효
}
}
// 세션 삭제
export async function deleteSession(): Promise<void> {
const cookieStore = await cookies()
cookieStore.delete(sessionConfig.cookieName)
}
// 세션 갱신 (만료 시간 연장)
export async function refreshSession(): Promise<void> {
const user = await getSession()
if (user) await createSession(user)
}
{
httpOnly: true, // JavaScript에서 접근 불가 (XSS 방지)
secure: true, // HTTPS 전용 (프로덕션)
sameSite: 'lax', // CSRF 기본 방어 + SAML 리다이렉트 호환
path: '/', // 전체 경로에서 유효
expires: new Date(), // 명시적 만료 시간
}
sameSite: 'lax'를 사용하는 이유:'strict'는 외부 사이트(IdP)에서 리다이렉트로 돌아올 때 쿠키를 보내지 않아 SAML SSO와 호환되지 않는다.'lax'는 top-level navigation GET 요청에서 쿠키를 보내므로 SAML Redirect Binding과 호환된다.
NAVER WORKS 조직의 모든 구성원이 SAML 인증을 통과할 수 있으므로, 이메일 화이트리스트로 실제 관리자만 접근을 허용한다.
export function isEmailAllowed(
email: string,
allowedEmails: string[]
): boolean {
if (allowedEmails.length === 0) {
// Fail-closed: 목록이 비어있으면 모든 접근 차단
return false
}
return allowedEmails.includes(email.toLowerCase())
}
Fail-closed 원칙: 화이트리스트가 비어있으면 전원 차단. 실수로 환경 변수를 빠뜨려도 보안이 유지된다.
SAML SSO로 인증된 세션에서 관리자 API를 보호하기 위해 Origin 헤더를 검증한다.
export function checkCSRFOrigin(request: NextRequest): boolean {
const method = request.method
if (['GET', 'HEAD', 'OPTIONS'].includes(method)) return true
const origin = request.headers.get('origin')
const expectedOrigin = new URL(request.url).origin
return origin === expectedOrigin
}
관리자 API에 IP 기반 요청 제한을 적용한다.
const rateLimitMap = new Map<string, { count: number; resetTime: number }>()
export function checkRateLimit(
ip: string,
maxRequests = 100,
windowMs = 60000
): boolean {
const now = Date.now()
const record = rateLimitMap.get(ip)
if (!record || now > record.resetTime) {
rateLimitMap.set(ip, { count: 1, resetTime: now + windowMs })
return true
}
record.count++
return record.count <= maxRequests
}
모든 관리자 액션을 기록한다. 비차단(non-blocking) 방식으로 에러가 발생해도 원래 작업에 영향을 주지 않는다.
interface AuditLog {
userId: string // 세션의 이메일
action: 'CREATE' | 'UPDATE' | 'DELETE'
entityType: string // 'press_release', 'insight', etc.
entityId: string
ip: string
userAgent: string
timestamp: Date
}
export async function createAuditLog(log: AuditLog): Promise<void> {
try {
// DB 저장 또는 외부 로깅 서비스로 전송
await saveToDatabase(log)
} catch {
// Non-blocking: 로그 실패가 원래 작업에 영향 없음
console.error('Audit log failed', log)
}
}
certs/
└── saml-idp.pem # NAVER WORKS IdP 공개 인증서
인증서를 소스 코드 저장소에 커밋할지는 보안 정책에 따라 결정한다. IdP 공개 인증서이므로 민감하지 않지만,
.gitignore에 추가하고 배포 시 별도 관리하는 것이 일반적이다.
validateConfig)response.cookies.set() 패턴ALLOW_DEV_AUTH_BYPASS가 프로덕션에 없는지 확인allowedDevOrigins 설정