LabHub

ブログ

SSO統合実践ガイド — OIDC/OAuth2 + Cookie/JWTハイブリッドアーキテクチャ、トークンローテーション完全攻略

한국어English日本語

📚 SSO Cookie/JWT 認証シリーズ > Next.js 編 ← 現在: 統合実践編 | シリーズインデックス

概要 — SSOとハイブリッド認証アーキテクチャ

SSOとは何か

Single Sign-On(SSO) は、ユーザーが 1 つの資格情報で複数のアプリケーションやサービスにアクセスできる認証の仕組みです。一度ログインすれば、同じ組織内のメール、Wiki、CI/CD、社内管理ツールなどへ別途のログインなしでアクセスできます。

[ユーザー] ──ログイン──▶ [IdP (Identity Provider)]
        ┌─────────────────┼─────────────────┐
        ▼                 ▼                 ▼
    [アプリ A]         [アプリ B]         [アプリ C]
   (メール)           (Wiki)           (CI/CD)
   再ログイン不要     再ログイン不要     再ログイン不要

OIDC vs OAuth2の違い

多くの開発者が混同する部分です。はっきり区別しておきます。

OAuth 2.0 = 「このアプリが君の Google Drive を読んでもいい? (認可)
OIDC      = 「ログインした人は youngjukim@example.com だよ」 (認証) + OAuth 2.0

なぜハイブリッド(Cookie+JWT)アーキテクチャが必要か

実際のプロダクション環境では、クッキーだけを使う方式も JWT だけを使う方式も、それぞれ限界があります。

ハイブリッドアーキテクチャは、ブラウザ ↔ BFF の区間では HttpOnly のセッションクッキーを、BFF ↔ バックエンドサービス の区間では JWT を使い、双方の長所を取ります。

マイクロサービス環境での認証の課題


OIDC/OAuth2プロトコル詳解

Authorization Code Flow + PKCE

最も安全な認証フローであり、すべてのクライアント種別 (Web、モバイル、SPA) に推奨されます。

┌──────────┐                           ┌──────────┐                    ┌──────────┐
Browser  │                           │   BFF    │                    │   IdP (User) (Server)(Keycloak)└────┬─────┘                           └────┬─────┘                    └────┬─────┘
1. /login をクリック                 │                               │
     │─────────────────────────────────────▶│                               │
     │                                      │  2. code_verifier を生成      │
     │                                      │     code_challenge =     │                                      │     SHA256(code_verifier)3. 302 Redirect to IdP             │                               │
     │◀─────────────────────────────────────│                               │
       (/authorize?response_type=code      │                               │
&client_id=...                     │                               │
&code_challenge=...                │                               │
&code_challenge_method=S256)       │                               │
     │──────────────────────────────────────────────────────────────────────▶│
     │                                      │     4. ユーザーのログイン + 同意 │
     │◀──────────────────────────────────────────────────────────────────────│
5. redirect_uri?code=AUTH_CODE      │                               │
     │─────────────────────────────────────▶│                               │
     │                                      │  6. POST /token               │
     │                                      │     code + code_verifier      │
     │                                      │─────────────────────────────▶│
     │                                      │  7. AT + RT + ID Token     │                                      │◀─────────────────────────────│
8. Set-Cookie: session_id           │                               │
     │◀─────────────────────────────────────│                               │

PKCE (Proof Key for Code Exchange, RFC 7636) は、Authorization Code を横取りする攻撃を防ぎます。

# Python: PKCE の code_verifier / code_challenge の生成
import secrets
import hashlib
import base64

# 1. code_verifier: 43~128 文字のランダム文字列
code_verifier = secrets.token_urlsafe(64)[:128]

# 2. code_challenge: SHA256 ハッシュ後に Base64url エンコード
code_challenge = base64.urlsafe_b64encode(
    hashlib.sha256(code_verifier.encode()).digest()
).rstrip(b'=').decode()

print(f"code_verifier:  {code_verifier}")
print(f"code_challenge: {code_challenge}")

ID Token vs Access Token vs Refresh Token

トークン目的寿命受信者形式
ID Tokenユーザー認証情報の伝達5~60 分クライアントアプリJWT (必須)
Access TokenAPIアクセス権限の付与5~60 分リソースサーバーJWT または Opaque
Refresh Token新AT/RTの発行数日~数ヶ月Authorization ServerのみOpaque 推奨

ID Token の主要なクレーム:

{
  "iss": "https://idp.example.com",
  "sub": "user-uuid-1234",
  "aud": "my-client-id",
  "exp": 1709913600,
  "iat": 1709910000,
  "nonce": "abc123xyz",
  "email": "youngjukim@example.com",
  "name": "キム・ヨンジュ",
  "email_verified": true
}

Access Token の scope の例:

scope: "openid profile email read:calendar write:calendar"

ハイブリッドアーキテクチャ設計

BFF(Backend For Frontend)パターン

BFF パターンはハイブリッド認証の中核です。トークンは BFF サーバーにのみ存在し、ブラウザにはセッションクッキーだけを渡します。

┌─────────────────────────────────────────────────────┐
Browserセッションクッキーのみ保持 (HttpOnly, Secure, SameSite=Lax)JavaScript からトークンにアクセス不可 → XSS から安全       │
└──────────────────────┬──────────────────────────────┘
Cookie: session_id=xxx
┌──────────────────────────────────────────────────────┐
BFF (Backend For Frontend)│  ┌──────────────────────────────────────────────┐    │
│  │ セッションストア (Redis)                       │    │
│  │  session_id → { access_token, refresh_token,  │    │
│  │                  id_token, user_info }         │    │
│  └──────────────────────────────────────────────┘    │
└──────────────────────┬──────────────────────────────┘
Authorization: Bearer <JWT>
┌──────────────────────────────────────────────────────┐
Backend Microservices[User API]  [Order API]  [Payment API]  [...]└──────────────────────────────────────────────────────┘

セキュリティ上の利点:

Token Relayパターン

API Gateway でセッションクッキーを JWT に変換してバックエンドへ渡します。

# Spring Cloud Gateway - Token Relay の設定
spring:
  cloud:
    gateway:
      routes:
        - id: user-service
          uri: lb://user-service
          predicates:
            - Path=/api/users/**
          filters:
            - TokenRelay= # Access Token を自動で Authorization ヘッダーに追加
            - RemoveRequestHeader=Cookie # クッキーはバックエンドへ渡さない

SSO実装実践

Keycloak連携例

Spring Boot + Spring Security OAuth2 Client:

// application.yml
spring:
  security:
    oauth2:
      client:
        registration:
          keycloak:
            client-id: my-app
            client-secret: ${KEYCLOAK_SECRET}
            scope: openid, profile, email
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
        provider:
          keycloak:
            issuer-uri: https://keycloak.example.com/realms/my-realm

// SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2Login(oauth2 -> oauth2
                .defaultSuccessUrl("/dashboard")
            )
            .oauth2Client(Customizer.withDefaults());
        return http.build();
    }
}

Django + mozilla-django-oidc:

# settings.py
INSTALLED_APPS += ['mozilla_django_oidc']

AUTHENTICATION_BACKENDS = [
    'mozilla_django_oidc.auth.OIDCAuthenticationBackend',
    'django.contrib.auth.backends.ModelBackend',
]

OIDC_RP_CLIENT_ID = os.environ['OIDC_CLIENT_ID']
OIDC_RP_CLIENT_SECRET = os.environ['OIDC_CLIENT_SECRET']
OIDC_OP_AUTHORIZATION_ENDPOINT = 'https://keycloak.example.com/realms/my-realm/protocol/openid-connect/auth'
OIDC_OP_TOKEN_ENDPOINT = 'https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token'
OIDC_OP_USER_ENDPOINT = 'https://keycloak.example.com/realms/my-realm/protocol/openid-connect/userinfo'
OIDC_OP_JWKS_ENDPOINT = 'https://keycloak.example.com/realms/my-realm/protocol/openid-connect/certs'
OIDC_RP_SIGN_ALGO = 'RS256'

# urls.py
urlpatterns += [
    path('oidc/', include('mozilla_django_oidc.urls')),
]

Next.js + NextAuth.js OIDC Provider:

// app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth'
import KeycloakProvider from 'next-auth/providers/keycloak'

const handler = NextAuth({
  providers: [
    KeycloakProvider({
      clientId: process.env.KEYCLOAK_CLIENT_ID!,
      clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
      issuer: process.env.KEYCLOAK_ISSUER, // https://keycloak.example.com/realms/my-realm
    }),
  ],
  callbacks: {
    async jwt({ token, account }) {
      if (account) {
        token.accessToken = account.access_token
        token.refreshToken = account.refresh_token
        token.expiresAt = account.expires_at
      }
      // 期限切れ前に更新
      if (Date.now() < (token.expiresAt as number) * 1000) {
        return token
      }
      return refreshAccessToken(token)
    },
    async session({ session, token }) {
      session.accessToken = token.accessToken as string
      return session
    },
  },
})

export { handler as GET, handler as POST }

Google/Azure AD/Okta連携

OIDC の Discovery endpoint (.well-known/openid-configuration) を活用すれば、どの IdP でも同じパターンで連携できます。

Google:    https://accounts.google.com/.well-known/openid-configuration
Azure AD:  https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration
Okta:      https://{domain}.okta.com/.well-known/openid-configuration
Keycloak:  https://keycloak.example.com/realms/{realm}/.well-known/openid-configuration
// 汎用的な OIDC Discovery の活用 (Node.js)
import { Issuer } from 'openid-client'

async function setupOIDC(issuerUrl: string, clientId: string, clientSecret: string) {
  const issuer = await Issuer.discover(issuerUrl)

  console.log('Authorization Endpoint:', issuer.metadata.authorization_endpoint)
  console.log('Token Endpoint:', issuer.metadata.token_endpoint)
  console.log('UserInfo Endpoint:', issuer.metadata.userinfo_endpoint)
  console.log('JWKS URI:', issuer.metadata.jwks_uri)

  const client = new issuer.Client({
    client_id: clientId,
    client_secret: clientSecret,
    redirect_uris: ['https://myapp.example.com/callback'],
    response_types: ['code'],
  })

  return client
}

トークンローテーション戦略

Refresh Token Rotation

Refresh Token Rotation は、窃取されたトークンが再利用されるのを検知するための中核となるセキュリティ機構です。

[正常な流れ]
ClientPOST /token (grant_type=refresh_token, refresh_token=RT_1)
Server → 新しい AT_2 + 新しい RT_2 を発行、RT_1 を無効化

[窃取シナリオ — Automatic Reuse Detection]
攻撃者が RT_1 を窃取して使用:
  攻撃者 → POST /token (refresh_token=RT_1)  ← すでに使用済みの RT!
  ServerRT_1 がすでに使用済みであることを検知
RT_1 のトークンファミリー全体 (RT_2, RT_3 ...) をすべて無効化
         → ユーザーに強制的な再ログインを要求
// Node.js / Express: Refresh Token Rotation の実装
import { randomUUID } from 'crypto'
import Redis from 'ioredis'

const redis = new Redis()

interface TokenFamily {
  userId: string
  familyId: string
  usedTokens: Set<string>
}

async function rotateRefreshToken(currentRefreshToken: string) {
  const tokenData = await redis.get(`rt:${currentRefreshToken}`)
  if (!tokenData) {
    throw new Error('INVALID_REFRESH_TOKEN')
  }

  const parsed = JSON.parse(tokenData)
  const familyKey = `family:${parsed.familyId}`

  // Reuse Detection: すでに使用済みのトークンならファミリー全体を無効化
  const isUsed = await redis.sismember(`${familyKey}:used`, currentRefreshToken)
  if (isUsed) {
    console.warn(`[SECURITY] Token reuse detected! Family: ${parsed.familyId}`)
    await revokeTokenFamily(parsed.familyId)
    throw new Error('TOKEN_REUSE_DETECTED')
  }

  // 現在の RT を「使用済み」として記録
  await redis.sadd(`${familyKey}:used`, currentRefreshToken)

  // 新しいトークンを発行
  const newRefreshToken = randomUUID()
  const newAccessToken = generateJWT(parsed.userId)

  await redis.setex(
    `rt:${newRefreshToken}`,
    7 * 24 * 3600,
    JSON.stringify({
      userId: parsed.userId,
      familyId: parsed.familyId,
      createdAt: Date.now(),
    })
  )

  // 既存の RT を削除
  await redis.del(`rt:${currentRefreshToken}`)

  return { accessToken: newAccessToken, refreshToken: newRefreshToken }
}

async function revokeTokenFamily(familyId: string) {
  const members = await redis.smembers(`family:${familyId}:used`)
  const pipeline = redis.pipeline()
  for (const token of members) {
    pipeline.del(`rt:${token}`)
  }
  pipeline.del(`family:${familyId}:used`)
  await pipeline.exec()
}

Access Token更新タイミング

戦略方式長所短所
Proactive (事前更新)期限の 30~60 秒前に先回りして更新ユーザー体験が途切れない不要な更新が起こりうる
Reactive (反応更新)401 応答を受けてから更新し再試行実装が単純初回リクエスト失敗 + 遅延
Hybridタイマーによる事前更新 + 401 フォールバック両方の長所を結合実装の複雑さが増す
// Axios インターセプタ: Hybrid な更新戦略
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'

let isRefreshing = false
let failedQueue: Array<{ resolve: Function; reject: Function }> = []

const api = axios.create({ baseURL: '/api', withCredentials: true })

api.interceptors.response.use(
  (response) => response,
  async (error: AxiosError) => {
    const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }

    if (error.response?.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        // すでに更新中ならキューに追加する
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject })
        }).then(() => api(originalRequest))
      }

      originalRequest._retry = true
      isRefreshing = true

      try {
        await axios.post('/api/auth/refresh', {}, { withCredentials: true })
        failedQueue.forEach(({ resolve }) => resolve())
        failedQueue = []
        return api(originalRequest)
      } catch (refreshError) {
        failedQueue.forEach(({ reject }) => reject(refreshError))
        failedQueue = []
        window.location.href = '/login'
        return Promise.reject(refreshError)
      } finally {
        isRefreshing = false
      }
    }
    return Promise.reject(error)
  }
)

Cookie/JWTハイブリッドパターン詳細

実務で推奨される保存戦略は次のとおりです。

データ保存場所理由
Session IDHttpOnly Secure クッキーJSアクセス不可、自動送信
Access TokenBFF サーバーのメモリ/Redisブラウザへの露出を防ぐ
Refresh TokenBFF サーバーの Redis (暗号化)長期トークンは必ずサーバー保管
CSRF Tokennon-HttpOnly クッキーまたはヘッダーJS で読んでヘッダーに含める

実務での Set-Cookie 設定:

// Express.js: セッションクッキーの設定 (BFF)
import session from 'express-session'
import RedisStore from 'connect-redis'
import { createClient } from 'redis'

const redisClient = createClient({ url: process.env.REDIS_URL })
await redisClient.connect()

app.use(
  session({
    store: new RedisStore({ client: redisClient }),
    name: '__Host-session', // __Host- 接頭辞: Secure + 特定ドメインに固定
    secret: process.env.SESSION_SECRET!,
    resave: false,
    saveUninitialized: false,
    cookie: {
      httpOnly: true, // JavaScript からアクセス不可
      secure: true, // HTTPS でのみ送信
      sameSite: 'lax', // CSRF への防御: 外部サイトからの送信を遮断
      maxAge: 24 * 60 * 60 * 1000, // 24 時間
      path: '/',
      // domain を省略 → 現在のホストにのみ適用 (__Host- 接頭辞と併用)
    },
  })
)

// Set-Cookie ヘッダーの結果例:
// Set-Cookie: __Host-session=s%3Aabc123...; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=86400

ブラウザストレージ別アクセス可能性表

ストレージJSアクセスサーバー自動送信XSS脆弱性CSRF脆弱性推奨用途
HttpOnly クッキー不可自動(same-origin)安全脆弱 (SameSiteで緩和)Session ID, Refresh Token
通常のクッキー可能自動脆弱脆弱CSRF Token (読み取り用)
localStorage可能手動脆弱安全非機密な設定値
sessionStorage可能手動脆弱安全タブ別一時データ
メモリ (変数)可能手動比較的安全安全SPA の Access Token

中心となる原則: 機微なトークンは HttpOnly クッキーまたはサーバーセッションに保存し、JavaScript から直接トークンを扱わないでください


CORS + マルチドメインSSO

サブドメイン間のCookie共有

# Domain 属性を利用したサブドメイン間のクッキー共有
Set-Cookie: session=abc; Domain=.example.com; Path=/; HttpOnly; Secure; SameSite=Lax

→ app1.example.com, app2.example.com, admin.example.com すべてでクッキーが送信される

Cross-originクレデンシャル送信

// フロントエンド: credentials: 'include' の設定が必須
fetch('https://api.example.com/data', {
  method: 'GET',
  credentials: 'include', // クッキーを cross-origin で送信する
})

// バックエンド: CORS の設定 (Express)
app.use(
  cors({
    origin: 'https://app.example.com', // '*' は使用不可 (credentials 使用時)
    credentials: true, // Access-Control-Allow-Credentials: true
    methods: ['GET', 'POST', 'PUT', 'DELETE'],
    allowedHeaders: ['Content-Type', 'Authorization', 'X-CSRF-Token'],
  })
)

Third-party Cookie制限と対応

主要なブラウザが third-party cookie を遮断または制限しています。SameSite=None; Secure を設定すれば cross-site 送信は可能ですが、それすら次第に制限されつつあります。

対応戦略:


ログアウト戦略

ローカルログアウト

// Express: ローカルログアウト (セッション + クッキーの削除)
app.post('/logout', async (req, res) => {
  const sessionData = req.session

  // 1. IdP のトークンを revoke する (任意だが推奨)
  if (sessionData?.refreshToken) {
    await fetch(`${ISSUER}/protocol/openid-connect/revoke`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        token: sessionData.refreshToken,
        token_type_hint: 'refresh_token',
        client_id: CLIENT_ID,
        client_secret: CLIENT_SECRET,
      }),
    })
  }

  // 2. セッションの削除
  req.session.destroy((err) => {
    if (err) console.error('Session destroy error:', err)
    // 3. クッキーの削除
    res.clearCookie('__Host-session', { path: '/', httpOnly: true, secure: true })
    res.json({ success: true })
  })
})

SSOグローバルログアウト

OIDC end_session_endpoint:

// ユーザーを IdP のログアウトページへリダイレクトする
const logoutUrl = new URL(`${ISSUER}/protocol/openid-connect/logout`)
logoutUrl.searchParams.set('id_token_hint', idToken)
logoutUrl.searchParams.set('post_logout_redirect_uri', 'https://app.example.com')
res.redirect(logoutUrl.toString())

Back-Channel Logout (IdP → 各サービスを直接呼び出す):

// POST /backchannel-logout エンドポイント (各サービスに実装する)
app.post('/backchannel-logout', async (req, res) => {
  const { logout_token } = req.body

  // logout_token (JWT) の検証
  const decoded = await verifyLogoutToken(logout_token)
  const userId = decoded.sub
  const sessionId = decoded.sid

  // 該当ユーザーのすべてのセッションを無効化
  await redis.del(`user_sessions:${userId}`)
  console.log(`[Back-Channel Logout] User ${userId} session ${sessionId} invalidated`)

  res.sendStatus(200)
})

Front-Channel Logout (IdP → iframe で各サービスのログアウト URL を呼ぶ):

<!-- IdP がレンダリングするログアウトページに各 RP のログアウト iframe を挿入 -->
<iframe src="https://app1.example.com/logout?sid=xxx" width="0" height="0"></iframe>
<iframe src="https://app2.example.com/logout?sid=xxx" width="0" height="0"></iframe>

参考: Back-Channel Logout のほうが Front-Channel より信頼性が高いです。Front-Channel はブラウザ終了時には動作せず、third-party cookie の制限の影響も受けます。


トークン無効化とブラックリスト

Stateless JWTの無効化の限界

JWT は署名だけで検証できる self-contained なトークンなので、発行後から期限切れまでサーバー側で無効化できません。これが JWT の本質的な限界です。

Redisベースのブラックリスト

// JWT ブラックリストのミドルウェア
async function jwtBlacklistMiddleware(req: Request, res: Response, next: NextFunction) {
  const token = req.headers.authorization?.replace('Bearer ', '')
  if (!token) return res.sendStatus(401)

  // ブラックリストの確認 (jti またはトークンのハッシュ)
  const decoded = jwt.decode(token) as { jti: string; exp: number }
  const isBlacklisted = await redis.exists(`blacklist:${decoded.jti}`)

  if (isBlacklisted) {
    return res.status(401).json({ error: 'Token has been revoked' })
  }

  // 通常の JWT 検証
  try {
    req.user = jwt.verify(token, PUBLIC_KEY, { algorithms: ['RS256'] })
    next()
  } catch {
    return res.sendStatus(401)
  }
}

// ブラックリストへの登録 (ログアウトまたは管理者による強制無効化)
async function revokeToken(token: string) {
  const decoded = jwt.decode(token) as { jti: string; exp: number }
  const ttl = decoded.exp - Math.floor(Date.now() / 1000)
  if (ttl > 0) {
    await redis.setex(`blacklist:${decoded.jti}`, ttl, '1')
  }
}

Token Introspection(RFC 7662)

Opaque トークンを使うとき、リソースサーバーが Authorization Server にトークンの有効性を直接問い合わせる方式です。

POST /token/introspect HTTP/1.1
Host: idp.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW

token=abc123opaque_token

→ レスポンス:
{
  "active": true,
  "sub": "user-1234",
  "scope": "openid profile",
  "client_id": "my-app",
  "exp": 1709913600
}

実務で推奨される組み合わせ: 短命の Access Token (5~15 分) + Refresh Token Rotation により、ブラックリストなしでも実質的な無効化の効果が得られます。AT が短ければ窃取されても早く期限切れになり、RT はローテーションによって再利用が検知されます。


マルチサービス認証(マイクロサービス)

API GatewayでのJWT検証

# Kong Gateway: JWT 検証プラグインの設定
plugins:
  - name: jwt
    config:
      uri_param_names: []
      header_names: ['Authorization']
      claims_to_verify:
        - exp
      key_claim_name: iss
      secret_is_base64: false
// Go: API Gateway の JWT 検証ミドルウェア
func JWTMiddleware(jwksURL string) func(http.Handler) http.Handler {
    keySet, _ := jwk.Fetch(context.Background(), jwksURL)

    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            tokenStr := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
            token, err := jwt.Parse([]byte(tokenStr), jwt.WithKeySet(keySet),
                jwt.WithValidate(true),
                jwt.WithAudience("my-api"),
            )
            if err != nil {
                http.Error(w, "Unauthorized", http.StatusUnauthorized)
                return
            }
            ctx := context.WithValue(r.Context(), "user", token)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

Audience(aud)クレームの活用

サービス間でトークンを伝播する際は、aud クレームでトークンの意図された受信者を限定します。

ユーザー → BFF:       aud=["bff-service"]
BFFUser Service:   aud=["user-service"]     (トークン交換または新しいトークンの発行)
BFFOrder Service:  aud=["order-service"]

各サービスは、自分の aud の値がトークンに含まれているかを必ず検証しなければなりません。これにより、User Service 用のトークンが Order Service で悪用されるのを防ぎます。

Service Mesh(Istio)mTLS

サービス間の通信は Istio の mTLS でトランスポート層のセキュリティを確保し、JWT はアプリケーション層の認可に使います。

# Istio: RequestAuthentication + AuthorizationPolicy
apiVersion: security.istio.io/v1
kind: RequestAuthentication
metadata:
  name: jwt-auth
spec:
  jwtRules:
    - issuer: 'https://idp.example.com'
      jwksUri: 'https://idp.example.com/.well-known/jwks.json'
      forwardOriginalToken: true
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: require-jwt
spec:
  rules:
    - from:
        - source:
            requestPrincipals: ['*']
      when:
        - key: request.auth.claims[aud]
          values: ['order-service']

セキュリティトレードオフ総合

脅威Cookie専用JWT専用(localStorage)ハイブリッド(BFF)
XSSHttpOnly により安全非常に脆弱 (トークン窃取可能)HttpOnly クッキーにより安全
CSRF脆弱 (SameSiteで緩和)安全 (クッキー未使用)SameSite + CSRF トークンで防御
Token Theftセッション ID のみ露出AT+RT ともに露出の危険トークンはサーバーにのみ存在
Replay AttackセッションバインディングAT 窃取時に再利用が可能短命の AT + サーバー検証
Session Fixationログイン後のセッション再生成で防御該当なしセッション再生成で防御
トークンの即時無効化セッション削除により可能不可 (期限切れまで待つ)セッション+ブラックリストで可能

Defense in Depth の原則: 単一の防御線に依存しません。HttpOnly クッキー + SameSite + CSRF Token + CSP (Content Security Policy) + 短命トークン + Token Rotation を何重にも適用します。


フレームワーク別要約比較表

項目Spring BootDjangoReact (SPA)Next.js (App Router)
トークン保存サーバーセッション (HttpSession)サーバーセッション (DB/Redis)メモリ変数サーバーセッション (NextAuth)
JWT検証spring-security-oauth2-resource-serverPyJWT / djangorestframework-simplejwt不要 (BFF パターン時)jose ライブラリ (サーバーサイド)
ミドルウェアSecurityFilterChainDjangoMiddlewareAxios インターセプタmiddleware.ts
CSRF防御CsrfFilter (自動)CsrfViewMiddleware (自動)クッキー未使用なら不要CSRF Token を手動で実装
ログアウトOidcClientInitiatedLogoutSuccessHandlermozilla-django-oidc logout viewメモリのトークン削除signOut() + IdP ログアウト
セッションストアRedis (Spring Session)Redis (django-redis)該当なしRedis / DB

統合チェックリスト

SSO + ハイブリッド認証を実装する際に必ず確認すべき項目です。


よくあるバグと誤解

1. 「OAuth 2.0 は認証プロトコルだ」 — 違います

OAuth 2.0 は 認可 (Authorization) のフレームワークです。ユーザーが誰であるかを標準として知らせるものではありません。認証が必要なら OIDC を使う必要があります。Access Token だけで「ログイン」を実装すると、セキュリティ上の穴が生まれます。

2. 「JWT は常に stateless だ」 — 現実は違います

理論上 JWT は stateless ですが、実際のプロダクションでは ブラックリスト、セッションストア、Token Introspection といった stateful な要素がほぼ必須になります。「Pure Stateless JWT」は即時の無効化ができないため、セキュリティ事故への対応が難しくなります。

3. Access Token を localStorage に保存してもよい?

絶対にしないでください。XSS 攻撃が一度成立するだけでトークンが窃取されます。BFF パターンを使ってトークンをサーバーに保管し、ブラウザには HttpOnly のセッションクッキーだけを渡してください。

4. Refresh Token に長い有効期限を設定するだけで安全?

Refresh Token は 窃取されると長期間にわたって悪用される おそれがあります。必ず Token Rotation + Reuse Detection を適用し、可能であればデバイス/IP のバインディングも追加してください。

5. CORS の設定で Access-Control-Allow-Origin: * を使う?

credentials: true と併せてワイルドカード (*) を使うと、ブラウザがリクエストを拒否します。正確な origin を明示する必要があります。それだけでなく、ワイルドカードの origin はセキュリティ上も危険です。

6. SameSite=Strict にすれば最も安全?

SameSite=Strict は、外部サイトからリンク経由で入ってきたときにもクッキーを送らないため、SSO のユーザー体験を大きく損ないます。ほとんどの場合は SameSite=Lax が適切です。

7. ID Token を API 呼び出しに使う?

ID Token は クライアントアプリがユーザー情報を確認するためのものです。API の呼び出しには必ず Access Token を使ってください。ID Token の aud はクライアントアプリであって、API サーバーではありません。


参考資料

  1. OpenID Connect Core 1.0 Specification
  2. RFC 6749 - The OAuth 2.0 Authorization Framework
  3. RFC 6750 - OAuth 2.0 Bearer Token Usage
  4. RFC 7519 - JSON Web Token (JWT)
  5. RFC 7662 - OAuth 2.0 Token Introspection
  6. RFC 7636 - Proof Key for Code Exchange (PKCE)
  7. OpenID Connect RP-Initiated Logout
  8. OpenID Connect Back-Channel Logout
  9. Keycloak Documentation - Securing Applications
  10. Auth0 - Token Best Practices
  11. Auth0 - Refresh Token Rotation
  12. OWASP - Session Management Cheat Sheet
  13. OWASP - JSON Web Token Cheat Sheet
  14. Spring Security OAuth2 Resource Server Reference
  15. NextAuth.js Documentation
  16. Mozilla Django OIDC Documentation
  17. RFC 9449 - OAuth 2.0 Demonstrating Proof of Possession (DPoP)
  18. Istio Security - Request Authentication

コメント

まだコメントはありません。

ログインするとコメントできます