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 + Cookieをリダイレクト応答に直接設定
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コールバックで307リダイレクトするとブラウザが次のページにもPOSTリクエスト |
response.cookies.set() |
cookies().set() + NextResponse.redirect()の組み合わせはCookie欠落のリスク |
307 Temporary RedirectはHTTPメソッドを保持する。SAML POSTコールバックで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>
)
}