Engineer Quiz

← 記事一覧

OAuth 2.0 と JWT - 認証・認可の完全ガイド

OAuth 2.0 と JWT - 認証・認可の完全ガイド

📚 概要

OAuth 2.0 は、サードパーティアプリケーションに対してユーザーの代理でリソースへのアクセスを許可する認可フレームワークです。JWT(JSON Web Token)は、情報を安全に送信するためのコンパクトなトークン形式です。

この記事では、OAuth 2.0 の仕組みと JWT の構造を詳しく解説し、セキュリティベストプラクティスまでカバーします。

🕰️ 歴史的背景

認証・認可の進化

2000年代初頭: パスワード共有の時代

  • ユーザーがサードパーティアプリに自分のパスワードを渡していた
  • セキュリティリスクが極めて高い
  • パスワード変更時にすべてのアプリで更新が必要

2006年: OAuth 1.0の誕生

  • Twitter と Google が協力して開発
  • パスワードを共有せずにアクセス許可を実現
  • しかし、実装が複雑で署名処理が必要

2012年: OAuth 2.0の登場

  • RFC 6749 として標準化
  • HTTPS前提でシンプル化
  • モバイルアプリやSPAに対応
  • 現在の事実上の標準

2015年: JWTの標準化

  • RFC 7519 として標準化
  • OAuth 2.0のアクセストークンとして広く採用
  • ステートレスな認証を実現

🔧 技術解説

OAuth 2.0 の基本概念

4つの役割:

graph LR
    A[Resource Owner] -->|Authorizes| B[Client]
    B -->|Requests Token| C[Authorization Server]
    C -->|Issues Token| B
    B -->|Access with Token| D[Resource Server]
    D -->|Returns Data| B
    
    style A fill:#51cf66
    style C fill:#4dabf7
    style D fill:#ff6b6b
  1. Resource Owner: ユーザー(リソースの所有者)
  2. Client: サードパーティアプリケーション
  3. Authorization Server: 認可サーバー(トークン発行)
  4. Resource Server: リソースサーバー(APIサーバー)

OAuth 2.0 のフロー

1. Authorization Code Flow(最も安全)

sequenceDiagram
    participant User
    participant Client
    participant AuthServer
    participant API
    
    User->>Client: Login Request
    Client->>AuthServer: Authorization Request
    AuthServer->>User: Login Page
    User->>AuthServer: Credentials
    AuthServer->>Client: Authorization Code
    Client->>AuthServer: Code + Client Secret
    AuthServer->>Client: Access Token
    Client->>API: API Request + Token
    API->>Client: Protected Resource

ステップ:

  1. クライアントが認可サーバーにリダイレクト
  2. ユーザーがログインして許可
  3. 認可サーバーがauthorization codeを返す
  4. クライアントがcodeをaccess tokenと交換
  5. access tokenでAPIにアクセス

実装例:

// 1. 認可リクエスト
const authUrl = new URL('https://auth.example.com/oauth/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', 'your_client_id');
authUrl.searchParams.set('redirect_uri', 'https://yourapp.com/callback');
authUrl.searchParams.set('scope', 'read:profile write:posts');
authUrl.searchParams.set('state', generateRandomState()); // CSRF対策

window.location.href = authUrl.toString();

// 2. コールバック処理
app.get('/callback', async (req, res) => {
  const { code, state } = req.query;
  
  // stateを検証(CSRF対策)
  if (state !== req.session.oauthState) {
    return res.status(400).send('Invalid state');
  }
  
  // 3. トークン取得
  const response = await fetch('https://auth.example.com/oauth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      grant_type: 'authorization_code',
      code,
      client_id: 'your_client_id',
      client_secret: 'your_client_secret',
      redirect_uri: 'https://yourapp.com/callback'
    })
  });
  
  const { access_token, refresh_token, expires_in } = await response.json();
  
  // トークンを保存
  req.session.accessToken = access_token;
  req.session.refreshToken = refresh_token;
  
  res.redirect('/dashboard');
});

// 4. APIアクセス
app.get('/api/user', async (req, res) => {
  const response = await fetch('https://api.example.com/user', {
    headers: {
      'Authorization': `Bearer ${req.session.accessToken}`
    }
  });
  
  const user = await response.json();
  res.json(user);
});

2. Implicit Flow(非推奨)

SPAで使われていましたが、セキュリティ上の理由で非推奨になりました。

3. Client Credentials Flow(サーバー間通信)

const response = await fetch('https://auth.example.com/oauth/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    grant_type: 'client_credentials',
    client_id: 'your_client_id',
    client_secret: 'your_client_secret',
    scope: 'api:read'
  })
});

const { access_token } = await response.json();

4. Refresh Token Flow

async function refreshAccessToken(refreshToken: string) {
  const response = await fetch('https://auth.example.com/oauth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: 'your_client_id',
      client_secret: 'your_client_secret'
    })
  });
  
  const { access_token, refresh_token, expires_in } = await response.json();
  
  return { access_token, refresh_token, expires_in };
}

JWT - JSON Web Token

JWTは、3つの部分で構成されます:

header.payload.signature

構造:

graph LR
    A[JWT Token] --> B[Header]
    A --> C[Payload]
    A --> D[Signature]
    
    B --> E[Algorithm + Type]
    C --> F[Claims]
    D --> G[HMAC or RSA]
    
    style B fill:#4dabf7
    style C fill:#51cf66
    style D fill:#ff6b6b

1. Header(ヘッダー)

{
  "alg": "HS256",
  "typ": "JWT"
}
  • alg: 署名アルゴリズム(HS256, RS256など)
  • typ: トークンタイプ(JWT)

2. Payload(ペイロード)

{
  "sub": "1234567890",
  "name": "John Doe",
  "email": "john@example.com",
  "iat": 1516239022,
  "exp": 1516242622,
  "scope": "read:profile write:posts"
}

標準クレーム:

  • sub: Subject(ユーザーID)
  • iat: Issued At(発行時刻)
  • exp: Expiration Time(有効期限)
  • nbf: Not Before(この時刻より前は無効)
  • iss: Issuer(発行者)
  • aud: Audience(対象)

3. Signature(署名)

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

実装例(Node.js):

import jwt from 'jsonwebtoken';

// トークン生成
function generateToken(userId: string) {
  const payload = {
    sub: userId,
    iat: Math.floor(Date.now() / 1000),
    exp: Math.floor(Date.now() / 1000) + 3600 // 1時間後
  };
  
  const secret = process.env.JWT_SECRET!;
  return jwt.sign(payload, secret, { algorithm: 'HS256' });
}

// トークン検証
function verifyToken(token: string) {
  try {
    const secret = process.env.JWT_SECRET!;
    const decoded = jwt.verify(token, secret) as { sub: string };
    return decoded;
  } catch (error) {
    if (error instanceof jwt.TokenExpiredError) {
      throw new Error('Token expired');
    }
    if (error instanceof jwt.JsonWebTokenError) {
      throw new Error('Invalid token');
    }
    throw error;
  }
}

// ミドルウェア
app.use((req, res, next) => {
  const authHeader = req.headers.authorization;
  
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'No token provided' });
  }
  
  const token = authHeader.substring(7);
  
  try {
    const decoded = verifyToken(token);
    req.userId = decoded.sub;
    next();
  } catch (error) {
    const message = error instanceof Error ? error.message : 'Authentication failed';
    res.status(401).json({ error: message });
  }
});

💡 実践例

完全なOAuth 2.0 + JWT実装

import express from 'express';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';

const app = express();
const JWT_SECRET = process.env.JWT_SECRET!;
const sessions = new Map(); // 本番ではRedis使用

// 認可エンドポイント
app.get('/oauth/authorize', (req, res) => {
  const { client_id, redirect_uri, scope, state } = req.query;
  
  // クライアント検証(省略)
  
  // ログイン画面を表示(省略)
  
  // 認可コード生成
  const code = crypto.randomBytes(32).toString('hex');
  sessions.set(code, {
    client_id,
    redirect_uri,
    scope,
    userId: req.user.id, // ログイン済みユーザー
    expires: Date.now() + 600000 // 10分
  });
  
  res.redirect(`${redirect_uri}?code=${code}&state=${state}`);
});

// トークンエンドポイント
app.post('/oauth/token', express.json(), (req, res) => {
  const { grant_type, code, client_id, client_secret, redirect_uri } = req.body;
  
  if (grant_type === 'authorization_code') {
    const session = sessions.get(code);
    
    if (!session || session.expires < Date.now()) {
      return res.status(400).json({ error: 'invalid_grant' });
    }
    
    // クライアント認証(省略)
    
    sessions.delete(code);
    
    // アクセストークン生成
    const accessToken = jwt.sign({
      sub: session.userId,
      scope: session.scope,
      exp: Math.floor(Date.now() / 1000) + 3600
    }, JWT_SECRET);
    
    // リフレッシュトークン生成
    const refreshToken = crypto.randomBytes(32).toString('hex');
    sessions.set(refreshToken, {
      userId: session.userId,
      scope: session.scope,
      type: 'refresh'
    });
    
    res.json({
      access_token: accessToken,
      token_type: 'Bearer',
      expires_in: 3600,
      refresh_token: refreshToken,
      scope: session.scope
    });
  }
});

// 保護されたAPIエンドポイント
app.get('/api/user', (req, res) => {
  const authHeader = req.headers.authorization;
  
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Unauthorized' });
  }
  
  const token = authHeader.substring(7);
  
  try {
    const decoded = jwt.verify(token, JWT_SECRET) as { sub: string; scope: string };
    
    // スコープチェック
    if (!decoded.scope.includes('read:profile')) {
      return res.status(403).json({ error: 'Insufficient scope' });
    }
    
    // ユーザー情報を返す
    res.json({
      id: decoded.sub,
      name: 'John Doe',
      email: 'john@example.com'
    });
  } catch (error) {
    res.status(401).json({ error: 'Invalid token' });
  }
});

🔒 セキュリティベストプラクティス

1. HTTPS必須

すべての通信はHTTPSで行う。HTTPでの通信は絶対にしない。

2. State パラメータでCSRF対策

// 認可リクエスト時
const state = crypto.randomBytes(32).toString('hex');
req.session.oauthState = state;

// コールバック時
if (req.query.state !== req.session.oauthState) {
  throw new Error('CSRF detected');
}

3. Refresh Tokenのローテーション

// リフレッシュ時に新しいRefresh Tokenを発行
const newRefreshToken = crypto.randomBytes(32).toString('hex');
sessions.delete(oldRefreshToken); // 古いトークンを無効化
sessions.set(newRefreshToken, { userId, scope });

4. トークンの有効期限を短く

  • Access Token: 15分〜1時間
  • Refresh Token: 1週間〜30日
  • Authorization Code: 10分

5. JWTの署名検証

// ❌ 悪い例: 署名を検証しない
const decoded = jwt.decode(token); // 危険!

// ✅ 良い例: 必ず検証する
const decoded = jwt.verify(token, secret);

6. alg: none攻撃への対策

// アルゴリズムを明示的に指定
jwt.verify(token, secret, { algorithms: ['HS256'] });

7. Scopeの適切な設定

// 必要最小限のスコープのみ要求
const scope = 'read:profile'; // 書き込み権限は不要なら含めない

🎯 OAuth 2.0 vs JWT

OAuth 2.0 JWT
目的 認可フレームワーク トークン形式
役割 アクセス許可の仕組み 情報を安全に送信
使い方 プロトコル データフォーマット
関係 JWTをトークンとして使える OAuth 2.0で使われることが多い

🔍 関連する問題

この記事に関連するクイズ問題:

  • Q4: OAuth 2.0のフロー
  • Q5: JWTの構造と検証

📝 まとめ

  • OAuth 2.0: 認可フレームワーク、パスワードを共有せずにアクセス許可
  • Authorization Code Flow: 最も安全なフロー
  • JWT: 署名付きトークン、ステートレスな認証
  • セキュリティ: HTTPS必須、State/CSRF対策、短い有効期限
  • ベストプラクティス: 署名検証、スコープ最小化、トークンローテーション

次のステップ: 実際にOAuth 2.0サーバーを構築し、JWTベースの認証を実装してみましょう!

推奨リソース: