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)からリダイレクトで戻る時にCookieを送らないため、SAML SSOと互換性がない。'lax'はtop-level navigation GETリクエストでCookieを送るため、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
}
すべての管理者アクションを記録する。ノンブロッキング方式でエラーが発生しても元の作業に影響を与えない。
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 設定