NAVER WORKS IdP SAML SSO 적용 가이드의 하위 문서. 설정 모듈부터 ACS 엔드포인트까지 단계별 구현을 다룬다.
IdP SSO를 사용하려면 먼저 NAVER WORKS Developer Console에서 SAML App을 등록해야 한다.
Developer Console → Apps → SAML Apps → 추가
| 설정 항목 | 값 | 설명 |
|---|---|---|
| Service Provider Name | 앱 이름 | 사용자에게 표시되는 이름 |
| SP Issuer (Entity ID) | https://your-domain.com |
SP 고유 식별자 |
| ACS URL | https://your-domain.com/auth/callback |
SAML Response 수신 URL |
| NameID Format | Unspecified | NAVER WORKS 고정값 |
등록 후 발급되는 정보:
| 발급 항목 | 환경 변수 |
|---|---|
| SSO URL | SAML_ENTRY_POINT |
| IdP 인증서 (.pem) | SAML_CERT_PATH로 파일 경로 지정 |
| Response Issuer | SAML_RESPONSE_ISSUER |
# SAML 설정
SAML_ENTRY_POINT=https://auth.worksmobile.com/saml2/idp/{your-domain}
SAML_ISSUER=https://your-domain.com
SAML_CALLBACK_URL=https://your-domain.com/auth/callback
SAML_CERT_PATH=./certs/saml-idp.pem
SAML_RESPONSE_ISSUER=https://auth.worksmobile.com/saml2/{your-domain}
# 세션 설정
AUTH_SECRET={최소 32자 랜덤 문자열}
AUTH_SESSION_MAX_AGE=86400
# 접근 제어
ADMIN_ALLOWED_EMAILS=admin@your-domain.com,manager@your-domain.com
# 로컬 개발 전용
ALLOW_DEV_AUTH_BYPASS=true # 프로덕션에서는 절대 사용 금지
AUTH_SECRET은 최소 32자 이상의 랜덤 문자열이어야 한다.openssl rand -base64 48등으로 생성 가능.
config.ts)설정을 중앙에서 관리하고, 서버 시작 시 필수 값을 검증한다.
import fs from 'fs'
export const samlConfig = {
// NAVER WORKS SSO 엔드포인트
entryPoint: process.env.SAML_ENTRY_POINT || '',
// SP Entity ID (우리 앱 식별자)
issuer: process.env.SAML_ISSUER || '',
// ACS URL (Assertion Consumer Service)
callbackUrl: process.env.SAML_CALLBACK_URL || '',
// NAVER WORKS IdP 인증서 (서명 검증용)
cert: process.env.SAML_CERT_PATH
? fs.readFileSync(process.env.SAML_CERT_PATH, 'utf-8')
: '',
// SAML Response에서 기대하는 Issuer
responseIssuer: process.env.SAML_RESPONSE_ISSUER || '',
// NAVER WORKS는 unspecified 고정
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified',
// Assertion 서명 검증 활성화
wantAssertionsSigned: true,
// Response 레벨 서명 검증 (Assertion만 서명될 경우 false)
wantAuthnResponseSigned: false,
// 서버 간 시간 차이 허용 (5초)
acceptedClockSkewMs: 5000,
// Single Logout URL (선택사항)
logoutUrl: process.env.SAML_LOGOUT_URL || undefined,
}
export const sessionConfig = {
secret: process.env.AUTH_SECRET || '',
maxAge: parseInt(process.env.AUTH_SESSION_MAX_AGE || '86400', 10),
cookieName: 'auth_session',
cookieOptions: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
},
}
export const accessConfig = {
allowedEmails: process.env.ADMIN_ALLOWED_EMAILS
?.split(',').map(e => e.trim()) || [],
protectedPaths: ['/admin'],
}
// 서버 시작 시 호출하여 필수 환경 변수 검증
export function validateConfig() {
const required = [
'SAML_ENTRY_POINT', 'SAML_ISSUER', 'SAML_CALLBACK_URL',
'SAML_CERT_PATH', 'SAML_RESPONSE_ISSUER', 'AUTH_SECRET',
]
const missing = required.filter(key => !process.env[key])
if (missing.length > 0) {
throw new Error(`Missing required environment variables: ${missing.join(', ')}`)
}
if (!samlConfig.cert) {
throw new Error('SAML certificate could not be loaded from SAML_CERT_PATH')
}
if (sessionConfig.secret.length < 32) {
throw new Error('AUTH_SECRET must be at least 32 characters')
}
}
설계 포인트:
cert는 파일 경로에서 읽어오므로 환경 변수에 인증서 내용을 직접 넣지 않는다 (줄바꿈 문제 방지)wantAuthnResponseSigned: false — NAVER WORKS는 Response 레벨 서명만 사용하고 Assertion 개별 서명은 없을 수 있다acceptedClockSkewMs: 5000 — 서버 시간 동기화 오차 5초 허용saml.ts)import { SAML } from '@node-saml/node-saml'
import { SignedXml } from 'xml-crypto'
import { DOMParser } from '@xmldom/xmldom'
import { samlConfig } from './config'
let samlInstance: SAML | null = null
export function getSAMLClient(): SAML {
if (!samlInstance) {
samlInstance = new SAML({
entryPoint: samlConfig.entryPoint,
issuer: samlConfig.issuer,
callbackUrl: samlConfig.callbackUrl,
idpCert: samlConfig.cert,
identifierFormat: samlConfig.identifierFormat,
wantAssertionsSigned: samlConfig.wantAssertionsSigned,
wantAuthnResponseSigned: samlConfig.wantAuthnResponseSigned,
acceptedClockSkewMs: samlConfig.acceptedClockSkewMs,
signatureAlgorithm: 'sha256',
})
}
return samlInstance
}
export async function createAuthnRequest(
callbackUrl?: string
): Promise<{ url: string; relayState?: string }> {
const saml = getSAMLClient()
const relayState = callbackUrl || '/admin'
const url = await saml.getAuthorizeUrlAsync(relayState, '', {})
return { url, relayState }
}
Best Practice:
RelayState에 원래 요청 URL을 담아 로그인 후 원래 페이지로 복귀signatureAlgorithm: 'sha256' — SP 측 서명(AuthnRequest)에는 SHA-256 사용NAVER WORKS의 URI="" 이슈 때문에 node-saml 검증이 실패할 수 있다. 이를 대비한 2단계 Fallback 패턴을 적용한다.
export interface SAMLProfile {
nameID: string
email: string
sessionIndex?: string
attributes?: Record<string, string>
}
export async function validateSAMLResponse(
body: Record<string, string>
): Promise<SAMLProfile> {
const saml = getSAMLClient()
try {
// 1차: node-saml 표준 검증
const { profile } = await saml.validatePostResponseAsync(body)
if (!profile) throw new Error('No profile returned from SAML response')
const nameID = profile.nameID
|| profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier']
const email = profile.email
|| profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']
|| nameID
if (!nameID) throw new Error('NameID not found in SAML response')
return {
nameID: String(nameID),
email: String(email || nameID),
sessionIndex: profile.sessionIndex as string | undefined,
attributes: profile as Record<string, string>,
}
} catch (error) {
// NAVER WORKS IdP: <Reference URI=""> → node-saml 미지원
if (error instanceof Error
&& error.message === 'signature reference uri not found') {
return validateWithEmptyURI(body.SAMLResponse)
}
throw new Error('SAML validation failed')
}
}
node-saml이 URI=""를 처리하지 못할 때, 하위 레벨의 xml-crypto로 직접 서명을 검증하고, DOM 파싱으로 프로필을 수동 추출한다.
function validateWithEmptyURI(base64Response: string): SAMLProfile {
const xml = Buffer.from(base64Response, 'base64').toString('utf-8')
const doc = new DOMParser().parseFromString(xml, 'text/xml')
// 1. 서명 검증 (xml-crypto는 URI="" 정상 처리)
const signatureNodes = doc.getElementsByTagNameNS(
'http://www.w3.org/2000/09/xmldsig#', 'Signature'
)
if (signatureNodes.length === 0) {
throw new Error('No signature found in SAML response')
}
const cert = samlConfig.cert
.replace(/-----BEGIN CERTIFICATE-----/g, '')
.replace(/-----END CERTIFICATE-----/g, '')
.replace(/\s/g, '')
const pem = `-----BEGIN CERTIFICATE-----\n${cert}\n-----END CERTIFICATE-----`
const sig = new SignedXml({ publicCert: pem })
sig.loadSignature(signatureNodes[0])
if (!sig.checkSignature(xml)) {
throw new Error('SAML signature verification failed')
}
// 2. Status 확인
const statusCode = doc.getElementsByTagNameNS(
'urn:oasis:names:tc:SAML:2.0:protocol', 'StatusCode'
)[0]
const statusValue = statusCode?.getAttribute('Value')
if (statusValue !== 'urn:oasis:names:tc:SAML:2.0:status:Success') {
throw new Error(`SAML Response status: ${statusValue}`)
}
// 3. Conditions 검증 (시간 기반)
const conditions = doc.getElementsByTagNameNS(
'urn:oasis:names:tc:SAML:2.0:assertion', 'Conditions'
)[0]
if (conditions) {
const now = Date.now()
const clockSkew = samlConfig.acceptedClockSkewMs || 0
const notBefore = conditions.getAttribute('NotBefore')
const notOnOrAfter = conditions.getAttribute('NotOnOrAfter')
if (notBefore && new Date(notBefore).getTime() - clockSkew > now) {
throw new Error('SAML assertion not yet valid')
}
if (notOnOrAfter && new Date(notOnOrAfter).getTime() + clockSkew < now) {
throw new Error('SAML assertion expired')
}
}
// 4. Audience 검증
const audience = doc.getElementsByTagNameNS(
'urn:oasis:names:tc:SAML:2.0:assertion', 'Audience'
)[0]
if (audience?.textContent && audience.textContent !== samlConfig.issuer) {
throw new Error(
`Audience mismatch: expected ${samlConfig.issuer}, got ${audience.textContent}`
)
}
// 5. 프로필 추출
const nameIDNode = doc.getElementsByTagNameNS(
'urn:oasis:names:tc:SAML:2.0:assertion', 'NameID'
)[0]
const nameID = nameIDNode?.textContent || ''
if (!nameID) throw new Error('NameID not found in SAML response')
const authnStatement = doc.getElementsByTagNameNS(
'urn:oasis:names:tc:SAML:2.0:assertion', 'AuthnStatement'
)[0]
const sessionIndex =
authnStatement?.getAttribute('SessionIndex') || undefined
return { nameID, email: nameID, sessionIndex }
}
Fallback 경로에서는
node-saml이 자동으로 수행하던 검증(Status, Conditions, Audience)을 모두 직접 구현해야 한다. 하나라도 빠지면 보안 취약점이 된다.
export async function createLogoutRequest(
nameID: string,
sessionIndex?: string
): Promise<string | null> {
if (!samlConfig.logoutUrl) return null
const saml = getSAMLClient()
try {
const profile = {
issuer: samlConfig.responseIssuer,
nameID,
nameIDFormat: samlConfig.identifierFormat,
sessionIndex: sessionIndex || '',
}
return await saml.getLogoutUrlAsync(profile, '', {})
} catch {
return null
}
}
callback/route.ts)ACS(Assertion Consumer Service)는 IdP로부터 SAML Response를 받는 핵심 엔드포인트다.
function getSafeCallbackUrl(relayState: string | null): string {
if (!relayState) return '/admin'
try {
const url = new URL(relayState, 'http://placeholder.local')
if (url.hostname !== 'placeholder.local') {
return '/admin' // 절대 URL → 외부 리다이렉트 차단
}
if (!url.pathname.startsWith('/admin')) {
return '/admin' // 허용 경로 외 차단
}
return url.pathname + url.search
} catch {
return '/admin'
}
}
RelayState는 사용자가 조작할 수 있는 값이므로 반드시 검증해야 한다. 외부 도메인으로의 Open Redirect 공격을 방지.
import { NextRequest, NextResponse } from 'next/server'
import { validateSAMLResponse } from '@/lib/auth/saml'
import { createSessionToken } from '@/lib/auth/session'
import { sessionConfig } from '@/lib/auth/config'
function getBaseUrl(request: NextRequest): string {
const serverUrl = process.env.SERVER_URL || process.env.NEXT_PUBLIC_SERVER_URL
if (serverUrl) return serverUrl
try {
return new URL(request.url).origin
} catch {
return 'http://localhost:3000'
}
}
async function handleSAMLCallback(
request: NextRequest,
samlResponse: string,
relayState: string | null
) {
const body: Record<string, string> = { SAMLResponse: samlResponse }
if (relayState) body.RelayState = relayState
const profile = await validateSAMLResponse(body)
// JWE 세션 토큰 생성
const { token, exp } = await createSessionToken({
email: profile.email,
nameID: profile.nameID,
sessionIndex: profile.sessionIndex,
})
// 303 See Other + 쿠키를 리다이렉트 응답에 직접 설정
const callbackUrl = getSafeCallbackUrl(relayState)
const response = NextResponse.redirect(
new URL(callbackUrl, getBaseUrl(request)),
303 // ← 핵심: POST → GET 전환
)
response.cookies.set(sessionConfig.cookieName, token, {
...sessionConfig.cookieOptions,
expires: new Date(exp * 1000),
})
return response
}
export async function GET(request: NextRequest) {
try {
const samlResponse = request.nextUrl.searchParams.get('SAMLResponse')
const relayState = request.nextUrl.searchParams.get('RelayState')
if (!samlResponse) {
return NextResponse.redirect(
new URL('/auth/error?code=missing_response', getBaseUrl(request))
)
}
return await handleSAMLCallback(request, samlResponse, relayState)
} catch {
return NextResponse.redirect(
new URL('/auth/error?code=validation_failed', getBaseUrl(request))
)
}
}
export async function POST(request: NextRequest) {
try {
const formData = await request.formData()
const samlResponse = formData.get('SAMLResponse')
const relayState = formData.get('RelayState')
if (!samlResponse || typeof samlResponse !== 'string') {
return NextResponse.redirect(
new URL('/auth/error?code=missing_response', getBaseUrl(request))
)
}
return await handleSAMLCallback(
request,
samlResponse,
relayState && typeof relayState === 'string' ? relayState : null
)
} catch {
return NextResponse.redirect(
new URL('/auth/error?code=validation_failed', getBaseUrl(request))
)
}
}
| 패턴 | 이유 |
|---|---|
| GET + POST 모두 처리 | NAVER WORKS가 Binding에 따라 GET/POST를 달리 사용 |
| 303 See Other | POST callback에서 307로 리다이렉트하면 브라우저가 다음 페이지에도 POST 요청 |
response.cookies.set() |
cookies().set() + NextResponse.redirect() 조합은 쿠키 누락 위험 |
307 Temporary Redirect는 HTTP 메서드를 보존한다. SAML POST callback에서 307을 사용하면 보호된 페이지에도 POST로 요청이 가서 예상치 못한 동작을 유발한다. 반드시 303 See Other를 사용할 것.
login/page.tsx)import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth/session'
import { createAuthnRequest } from '@/lib/auth/saml'
function getSafeCallbackUrl(callbackUrl: string | null): string {
if (!callbackUrl) return '/admin'
try {
const url = new URL(callbackUrl, 'http://placeholder.local')
if (url.hostname !== 'placeholder.local') return '/admin'
if (!url.pathname.startsWith('/admin')) return '/admin'
return url.pathname + url.search
} catch {
return '/admin'
}
}
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ callbackUrl?: string }>
}) {
const params = await searchParams
const safeCallbackUrl = getSafeCallbackUrl(params.callbackUrl ?? null)
// 이미 세션 있으면 바로 리다이렉트
const session = await getSession()
if (session) redirect(safeCallbackUrl)
// 개발환경 SSO 우회 (ALLOW_DEV_AUTH_BYPASS=true일 때만)
if (process.env.NODE_ENV === 'development'
&& process.env.ALLOW_DEV_AUTH_BYPASS === 'true') {
redirect(safeCallbackUrl)
}
// SAML AuthnRequest 생성 → IdP로 리다이렉트
const { url } = await createAuthnRequest(safeCallbackUrl)
redirect(url)
}
설계 포인트:
NODE_ENV + ALLOW_DEV_AUTH_BYPASS 이중 조건logout/route.ts)import { NextRequest, NextResponse } from 'next/server'
import { getSession, deleteSession } from '@/lib/auth/session'
import { createLogoutRequest } from '@/lib/auth/saml'
export async function GET(request: NextRequest) {
const session = await getSession()
await deleteSession()
// SLO URL이 있으면 IdP로 리다이렉트
if (session?.nameID) {
try {
const logoutUrl = await createLogoutRequest(
session.nameID,
session.sessionIndex
)
if (logoutUrl) {
return NextResponse.redirect(logoutUrl)
}
} catch {
// SLO 실패 시 무시하고 로컬 로그아웃만 수행
}
}
// SLO URL 없거나 실패 시 홈으로
return NextResponse.redirect(new URL('/', request.url))
}
보호된 페이지의 Layout에서 세션을 확인하여 미인증 사용자를 차단한다.
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth/session'
export default async function ProtectedLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await getSession()
if (!session) {
// 개발 환경에서만 인증 우회 허용
if (process.env.NODE_ENV !== 'development'
|| process.env.ALLOW_DEV_AUTH_BYPASS !== 'true') {
redirect('/auth/login')
}
}
return (
<html lang="ko">
<body>
{children}
</body>
</html>
)
}