Skip to content
Published on

OAuth 2.0 & 認証完全ガイド2025:JWT、セッション、SSO、OIDC、Passkeyまで

Authors

はじめに

Webアプリケーションにおいて「認証(Authentication)」と「認可(Authorization)」はセキュリティの要です。ユーザーログインからAPIアクセス制御、マイクロサービス間通信まで、適切な認証体系なくして安全なシステムの構築はできません。

2025年現在、認証エコシステムは急速に進化しています。従来のセッションベース認証からJWTトークンベース認証、OAuth 2.0、OIDCを経てPasskeyまで、選択肢はかつてないほど多様化しています。

このガイドでは、Authentication vs Authorizationの根本的な違いからJWT深層分析、OAuth 2.0全フロー、SSO、Passkey、セキュリティベストプラクティスまで、Web認証のすべてを網羅します。


1. Authentication vs Authorization

1.1 核心的な違い

Authentication(認証) - 「あなたは誰ですか?」
- ユーザーの身元を確認するプロセス
- ログインプロセスそのもの
- 例:ID/パスワード、生体認証、OTP

Authorization(認可) - 「あなたは何ができますか?」
- 認証済みユーザーの権限を確認するプロセス
- リソースアクセス制御
- 例:管理者のみ削除可能、本人データのみ閲覧可能

1.2 認証/認可フロー

1. ユーザーがログインリクエスト(Authentication)
   POST /auth/login
   Body: { "email": "user@example.com", "password": "..." }

2. サーバーが資格情報を検証(Authentication)
   - DBでユーザーを検索
   - パスワードハッシュを比較
   - 認証成功時にトークン/セッションを発行

3. APIリクエスト時にトークン/セッションを付与
   GET /admin/users
   Authorization: Bearer eyJhbGci...

4. サーバーが権限を確認(Authorization)
   - トークンからロールを抽出
   - エンドポイントへのアクセス権限を確認
   - 権限あり -> 200 OK
   - 権限なし -> 403 Forbidden

1.3 HTTPステータスコード

401 Unauthorized = 認証(Authentication)失敗
- 「あなたが誰かわかりません。ログインしてください。」
- トークンなし、トークン期限切れ、無効な資格情報

403 Forbidden = 認可(Authorization)失敗
- 「あなたが誰かは知っていますが、このリソースへのアクセス権限がありません。」
- 一般ユーザーが管理者APIを呼び出し

2. セッションベース認証

2.1 動作原理

1. クライアント: POST /login(メール、パスワード)
2. サーバー: 資格情報を検証
3. サーバー: セッション作成(サーバーメモリ/Redisに保存)
   Session ID: "sess_abc123"
   Data: { userId: 42, role: "admin", createdAt: "..." }
4. サーバー: Set-Cookieヘッダーでセッション IDを返却
   Set-Cookie: session_id=sess_abc123; HttpOnly; Secure; SameSite=Strict
5. クライアント: 以降のリクエストでクッキーを自動送信
   Cookie: session_id=sess_abc123
6. サーバー: セッションIDでユーザー情報を検索

2.2 Express.jsセッション実装

const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const redis = require('redis');

const app = express();
const redisClient = redis.createClient({ url: 'redis://localhost:6379' });

app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: true,        // HTTPSのみで送信
    httpOnly: true,      // JavaScriptアクセスをブロック(XSS防止)
    sameSite: 'strict',  // CSRF防止
    maxAge: 24 * 60 * 60 * 1000  // 24時間
  }
}));

// ログイン
app.post('/login', async (req, res) => {
  const { email, password } = req.body;
  const user = await db.findUserByEmail(email);

  if (!user || !await bcrypt.compare(password, user.passwordHash)) {
    return res.status(401).json({ error: '無効な資格情報' });
  }

  req.session.userId = user.id;
  req.session.role = user.role;
  res.json({ message: 'ログイン成功' });
});

// 認証ミドルウェア
function requireAuth(req, res, next) {
  if (!req.session.userId) {
    return res.status(401).json({ error: '認証が必要です' });
  }
  next();
}

// 認可ミドルウェア
function requireRole(role) {
  return (req, res, next) => {
    if (req.session.role !== role) {
      return res.status(403).json({ error: '権限がありません' });
    }
    next();
  };
}

// ログアウト
app.post('/logout', (req, res) => {
  req.session.destroy((err) => {
    res.clearCookie('connect.sid');
    res.json({ message: 'ログアウト成功' });
  });
});

2.3 セッションストア比較

ストアメリットデメリット適した用途
メモリ最速再起動で消失、共有不可開発環境
Redis高速、共有可能、TTL追加インフラ必要本番環境(推奨)
DB (PostgreSQL)永続性、監査可能低速監査が必要な場合
ファイル簡単な実装低速、共有不可小規模アプリ

3. トークンベース認証

3.1 JWT構造

JWT = Header.Payload.Signature(Base64URLエンコード、ドットで区切り)

Header(ヘッダー):
{
  "alg": "RS256",    // 署名アルゴリズム
  "typ": "JWT",      // トークンタイプ
  "kid": "key-id-1"  // キー識別子(Key Rotation用)
}

Payload(ペイロード - Claims):
{
  "iss": "https://auth.example.com",   // Issuer(発行者)
  "sub": "user-123",                    // Subject(主体)
  "aud": "https://api.example.com",    // Audience(対象)
  "exp": 1711356600,                    // Expiration(有効期限)
  "iat": 1711353000,                    // Issued At(発行日時)
  "nbf": 1711353000,                    // Not Before(使用開始時刻)
  "jti": "unique-token-id",             // JWT ID(一意ID)
  "email": "user@example.com",          // カスタムクレーム
  "roles": ["admin", "user"]            // カスタムクレーム
}

Signature(署名):
RS256(
  base64url(header) + "." + base64url(payload),
  privateKey
)

3.2 署名アルゴリズム比較

アルゴリズムタイプキーユースケース
HS256対称鍵1つのシークレットキー単一サーバー、内部サービス
RS256非対称鍵公開鍵/秘密鍵ペアマイクロサービス、外部検証
ES256非対称鍵 (ECDSA)短いキー長モバイル、IoT(性能重視)
// HS256 - 同じキーで署名と検証
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET;

// 署名
const token = jwt.sign({ sub: 'user-123' }, SECRET, { algorithm: 'HS256' });

// 検証(同じキーが必要)
const decoded = jwt.verify(token, SECRET);
// RS256 - 秘密鍵で署名、公開鍵で検証
const fs = require('fs');
const privateKey = fs.readFileSync('private.pem');
const publicKey = fs.readFileSync('public.pem');

// 署名(認証サーバー)
const token = jwt.sign(
  { sub: 'user-123', roles: ['admin'] },
  privateKey,
  { algorithm: 'RS256', expiresIn: '15m' }
);

// 検証(APIサーバー - 公開鍵のみ必要)
const decoded = jwt.verify(token, publicKey);

3.3 JWT検証チェックリスト

1. 署名検証(Signature Verification)
   - 正しいアルゴリズムで署名されているか確認
   - alg: "none"攻撃を防止(必ずアルゴリズムを明示)

2. 有効期限の確認(exp)
   - 現在時刻がexpより前であることを確認
   - クロックスキューを考慮した若干の余裕(leeway)

3. 発行者の確認(iss)
   - 信頼できる発行者であることを確認

4. 対象の確認(aud)
   - このトークンが自サービス向けであることを確認

5. Not Beforeの確認(nbf)
   - トークン使用開始時刻以降であることを確認

6. 追加セキュリティ検証
   - トークンがブラックリストにないか確認
   - ユーザーが無効化されていないか確認

4. JWT深層分析

4.1 Access TokenとRefresh Token

Access Token:
- 短い有効期間(15分~1時間)
- APIリクエスト時にAuthorizationヘッダーに含む
- 盗取されても被害期間が限定的
- サーバーに保存しない(Stateless)

Refresh Token:
- 長い有効期間(7日~30日)
- Access Tokenの更新にのみ使用
- サーバーDB/Redisに保存(Stateful)
- HttpOnlyクッキーに保存(XSS防止)

4.2 トークン更新フロー

1. 初回ログイン
   POST /auth/login -> Access Token + Refresh Token発行

2. APIリクエスト
   GET /api/data
   Authorization: Bearer [access_token]
   -> 200 OK(正常)

3. Access Token期限切れ
   GET /api/data
   Authorization: Bearer [expired_access_token]
   -> 401 Unauthorized

4. トークン更新
   POST /auth/refresh
   Cookie: refresh_token=...
   -> 新しいAccess Token + 新しいRefresh Token発行

5. Refresh Token期限切れ
   POST /auth/refresh
   -> 401 Unauthorized -> 再ログインが必要

4.3 Refresh Token Rotation

// Refresh Token Rotation実装
async function refreshTokens(refreshToken) {
  // 1. DBからRefresh Tokenを検索
  const storedToken = await db.findRefreshToken(refreshToken);

  if (!storedToken) {
    // 既に使用済みのRefresh Token -> 全トークン無効化(盗取検知)
    await db.revokeAllTokensForUser(storedToken.userId);
    throw new Error('Refresh Token reuse detected');
  }

  // 2. 有効性確認
  if (storedToken.expiresAt < Date.now()) {
    throw new Error('Refresh Token expired');
  }

  // 3. 以前のRefresh Tokenを無効化
  await db.revokeRefreshToken(refreshToken);

  // 4. 新しいトークンペアを発行
  const newAccessToken = jwt.sign(
    { sub: storedToken.userId, roles: storedToken.roles },
    privateKey,
    { algorithm: 'RS256', expiresIn: '15m' }
  );

  const newRefreshToken = crypto.randomUUID();
  await db.saveRefreshToken({
    token: newRefreshToken,
    userId: storedToken.userId,
    expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000
  });

  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}

4.4 トークンブラックリスト

// Redisベースのブラックリスト
const redis = require('redis');
const client = redis.createClient();

// トークンをブラックリストに追加(ログアウト時)
async function blacklistToken(token) {
  const decoded = jwt.decode(token);
  const ttl = decoded.exp - Math.floor(Date.now() / 1000);

  if (ttl > 0) {
    await client.set(`blacklist:${decoded.jti}`, '1', { EX: ttl });
  }
}

// トークン検証時にブラックリストを確認
async function verifyToken(token) {
  const decoded = jwt.verify(token, publicKey);
  const isBlacklisted = await client.get(`blacklist:${decoded.jti}`);

  if (isBlacklisted) {
    throw new Error('Token has been revoked');
  }

  return decoded;
}

4.5 JWTベストプラクティス

やるべきこと(DO:
- Access Tokenの有効期間を短く(15分)
- RS256またはES256を使用(非対称鍵)
- jtiクレームでトークンを一意に識別
- Refresh Tokenはサーバー側で管理
- 機密情報はPayloadに入れない

やってはいけないこと(DON'T:
- JWTlocalStorageに保存(XSSに脆弱)
- HS256をマイクロサービス間で使用
- トークンにパスワード/カード番号を含める
- alg: "none"を許可
- 有効期限なしのトークン発行

5. OAuth 2.0フロー

5.1 OAuth 2.0の役割

Resource Owner(リソースオーナー):
  - エンドユーザー(例:Googleアカウント所有者)

Client(クライアント):
  - リソースアクセスを要求するアプリケーション(例:自分のWebアプリ)

Authorization Server(認可サーバー):
  - トークンを発行するサーバー(例:Google OAuthサーバー)

Resource Server(リソースサーバー):
  - 保護されたリソースをホスティングするサーバー(例:Google API

5.2 Authorization Code Flow(+ PKCE)

最も推奨されるフロー。Webアプリ、SPA、モバイルアプリ全てで使用。
PKCE(Proof Key for Code Exchange)はSPA/モバイルで必須。

1. クライアントがPKCEパラメータを生成
   code_verifier = random(43-128 chars)
   code_challenge = BASE64URL(SHA256(code_verifier))

2. 認可リクエスト
   GET /authorize?
     response_type=code&
     client_id=my-app&
     redirect_uri=https://myapp.com/callback&
     scope=openid profile email&
     state=random-csrf-token&
     code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&
     code_challenge_method=S256

3. ユーザーがログイン + 同意

4. 認可サーバーがAuthorization Codeを返却
   302 Redirect: https://myapp.com/callback?code=AUTH_CODE&state=random-csrf-token

5. Authorization CodeをAccess Tokenに交換
   POST /token
   Content-Type: application/x-www-form-urlencoded

   grant_type=authorization_code&
   code=AUTH_CODE&
   redirect_uri=https://myapp.com/callback&
   client_id=my-app&
   code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

6. トークンレスポンス
   {
     "access_token": "eyJhbGci...",
     "token_type": "Bearer",
     "expires_in": 3600,
     "refresh_token": "dGhpcyBpcyBhIH...",
     "id_token": "eyJhbGci...",
     "scope": "openid profile email"
   }

5.3 Client Credentials Flow

サーバー間通信(Machine-to-Machine)。ユーザー介入なし。

POST /token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&
client_id=service-a&
client_secret=secret-value&
scope=read:data write:data

レスポンス:
{
  "access_token": "eyJhbGci...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "read:data write:data"
}

ユースケース:
- マイクロサービス間のAPI呼び出し
- バッチ処理システム
- CI/CDパイプラインでのAPIアクセス

5.4 Device Code Flow

入力が制限されたデバイス用(スマートTVCLIツール、IoT)

1. デバイスがコードをリクエスト
   POST /device/code
   client_id=tv-app

   レスポンス:
   {
     "device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS",
     "user_code": "WDJB-MJHT",
     "verification_uri": "https://auth.example.com/device",
     "interval": 5,
     "expires_in": 1800
   }

2. ユーザーに表示
   "https://auth.example.com/device でコード WDJB-MJHT を入力してください"

3. ユーザーが別のデバイス(スマホ/PC)でコード入力 + ログイン

4. デバイスがポーリング
   POST /token
   grant_type=urn:ietf:params:oauth:grant-type:device_code&
   device_code=GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS&
   client_id=tv-app

   (ユーザーがまだ認証していない -> "authorization_pending"   (ユーザーが認証完了 -> Access Tokenを返却)

5.5 Implicit Flow(非推奨)

もう推奨されません!PKCEを含むAuthorization Code Flowを使用してください。

非推奨の理由:
1. Access TokenがURL Fragmentに露出(#access_token=...2. ブラウザ履歴にトークンが記録
3. Refresh Tokenを発行できない
4. Token Substitution Attackに脆弱

代替案: Authorization Code + PKCE
- SPAでも安全に使用可能
- Refresh Tokenをサポート
- code_verifierでコード横取りを防止

6. OpenID Connect(OIDC)

6.1 OIDCとは

OAuth 2.0上に構築された認証レイヤー。
OAuth 2.0 = 認可(Authorization)のみを担当
OIDC = 認証(Authentication)を追加

OAuth 2.0: 「このアプリがあなたのGoogle Driveにアクセスしてもよいですか?」
OIDC: 「このユーザーが実際にalice@gmail.comであることを確認」

主要な追加事項:
1. ID Token(JWT形式のユーザー情報)
2. UserInfoエンドポイント
3. 標準化されたクレーム(name, email, pictureなど)
4. Discoveryドキュメント(/.well-known/openid-configuration)

6.2 ID Token

{
  "iss": "https://accounts.google.com",
  "sub": "110169484474386276334",
  "aud": "my-app-client-id",
  "exp": 1711356600,
  "iat": 1711353000,
  "nonce": "n-0S6_WzA2Mj",
  "email": "alice@gmail.com",
  "email_verified": true,
  "name": "Alice Kim",
  "picture": "https://lh3.googleusercontent.com/a/..."
}

6.3 Discoveryドキュメント

GET https://accounts.google.com/.well-known/openid-configuration

レスポンス(主要フィールド):
{
  "issuer": "https://accounts.google.com",
  "authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
  "token_endpoint": "https://oauth2.googleapis.com/token",
  "userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
  "jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
  "scopes_supported": ["openid", "email", "profile"],
  "response_types_supported": ["code", "token", "id_token"],
  "subject_types_supported": ["public"],
  "id_token_signing_alg_values_supported": ["RS256"]
}

7. SSO(シングルサインオン)

7.1 SSOの概念

一度のログインで複数のサービスにアクセスするメカニズム。

:
Googleアカウントでログインすると、Gmail、Drive、YouTube、Calendar全てにアクセス可能。

メリット:
- ユーザー体験の向上(ログイン1回)
- パスワード疲労の軽減
- 中央集約されたユーザー管理
- セキュリティポリシーの一貫性

デメリット:
- 単一障害点(IdP障害時に全サービスに影響)
- 実装の複雑さ
- IdPへの依存性

7.2 SAML 2.0 vs OIDC

項目SAML 2.0OIDC
データ形式XMLJSON/JWT
トランスポートHTTP Redirect/POSTHTTP Redirect
トークンAssertion(XML)ID Token(JWT)
主な用途エンタープライズSSOWeb/モバイルアプリ
複雑度高い低い
モバイル対応限定的優秀
標準化年2005年2014年
SAML 2.0フロー:
1. ユーザーがSP(Service Provider)にアクセス
2. SPSAML Requestを生成 -> IdPにリダイレクト
3. ユーザーがIdPで認証
4. IdPがSAML Assertion(XML)を生成 -> SPPOST
5. SPがAssertionを検証 -> セッション作成

OIDCフロー:
1. ユーザーがアプリにアクセス
2. アプリがIdPにAuthorization要求をリダイレクト
3. ユーザーがIdPで認証
4. IdPがAuthorization Codeを返却
5. アプリがCodeをID Token + Access Tokenに交換

8. ソーシャルログイン実装

8.1 Googleログイン(Next.js + NextAuth.js)

// app/api/auth/[...nextauth]/route.js
import NextAuth from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';
import GitHubProvider from 'next-auth/providers/github';

const handler = NextAuth({
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    }),
    GitHubProvider({
      clientId: process.env.GITHUB_CLIENT_ID,
      clientSecret: process.env.GITHUB_CLIENT_SECRET,
    }),
  ],
  callbacks: {
    async jwt({ token, account, profile }) {
      if (account) {
        token.accessToken = account.access_token;
        token.provider = account.provider;
      }
      return token;
    },
    async session({ session, token }) {
      session.accessToken = token.accessToken;
      session.provider = token.provider;
      return session;
    },
  },
  pages: {
    signIn: '/auth/signin',
    error: '/auth/error',
  },
});

export { handler as GET, handler as POST };

8.2 GitHubログイン(Express.js + Passport.js)

const passport = require('passport');
const GitHubStrategy = require('passport-github2').Strategy;

passport.use(new GitHubStrategy({
    clientID: process.env.GITHUB_CLIENT_ID,
    clientSecret: process.env.GITHUB_CLIENT_SECRET,
    callbackURL: 'https://myapp.com/auth/github/callback'
  },
  async (accessToken, refreshToken, profile, done) => {
    let user = await db.findUserByGitHubId(profile.id);

    if (!user) {
      user = await db.createUser({
        githubId: profile.id,
        name: profile.displayName,
        email: profile.emails[0].value,
        avatar: profile.photos[0].value,
      });
    }

    return done(null, user);
  }
));

// ルート
app.get('/auth/github',
  passport.authenticate('github', { scope: ['user:email'] })
);

app.get('/auth/github/callback',
  passport.authenticate('github', { failureRedirect: '/login' }),
  (req, res) => {
    res.redirect('/dashboard');
  }
);

8.3 Appleログインの特殊性

Appleログインは他のソーシャルログインとの違いがあります:

1. メールを隠す(Private Email Relay)
   - ユーザーが実際のメールの代わりに固有のリレーアドレスを提供可能
   -: abc123@privaterelay.appleid.com

2. ユーザー情報は初回のみ提供
   - 名前、メールは初回ログイン時にのみ返される
   - 初回レスポンスで必ず保存すること!

3. client_secretがJWT
   - 他のプロバイダーとは異なり、client_secretを直接JWTで生成
   - Apple Developer Portalのキーで署名

4. WebではForm POST方式
   - コールバックがPOST方式で配信

9. Passkey / WebAuthn / FIDO2

9.1 Passkeyとは

パスワードレス認証の未来。
生体認証(指紋/顔)、デバイスPIN、セキュリティキーでログイン。

Passkey = WebAuthn + FIDO2 + クラウド同期

メリット:
- フィッシング不可能(ドメインにバインド)
- パスワード漏洩なし
- ユーザー体験の向上(タッチ/顔だけでログイン)
- クロスデバイス対応(iCloud Keychain、Google Password Manager)

ブラウザサポート:
- Chrome 108+2022.12- Safari 16+2022.09- Firefox 122+2024.01- Edge 108+2022.12

9.2 登録(Registration)フロー

// サーバー: Registration Optionsを生成
app.post('/webauthn/register/options', async (req, res) => {
  const user = req.user;

  const options = {
    challenge: crypto.randomBytes(32),
    rp: {
      name: 'My App',
      id: 'myapp.com'
    },
    user: {
      id: Buffer.from(user.id),
      name: user.email,
      displayName: user.name
    },
    pubKeyCredParams: [
      { alg: -7, type: 'public-key' },   // ES256
      { alg: -257, type: 'public-key' }  // RS256
    ],
    authenticatorSelection: {
      authenticatorAttachment: 'platform',
      userVerification: 'preferred',
      residentKey: 'required'
    },
    timeout: 60000
  };

  req.session.challenge = options.challenge;
  res.json(options);
});

// クライアント: Passkeyを作成
const credential = await navigator.credentials.create({
  publicKey: registrationOptions
});

// サーバー: 登録完了
app.post('/webauthn/register/verify', async (req, res) => {
  const { credential } = req.body;

  // challenge検証、origin検証、公開鍵の抽出と保存
  const verified = await verifyRegistration(credential, req.session.challenge);

  if (verified) {
    await db.saveCredential({
      credentialId: credential.id,
      publicKey: verified.publicKey,
      userId: req.user.id,
      counter: verified.counter
    });
    res.json({ success: true });
  }
});

9.3 認証(Authentication)フロー

// サーバー: Authentication Optionsを生成
app.post('/webauthn/login/options', async (req, res) => {
  const options = {
    challenge: crypto.randomBytes(32),
    rpId: 'myapp.com',
    userVerification: 'preferred',
    timeout: 60000
  };

  req.session.challenge = options.challenge;
  res.json(options);
});

// クライアント: Passkeyで認証
const assertion = await navigator.credentials.get({
  publicKey: authenticationOptions
});

// サーバー: 認証検証
app.post('/webauthn/login/verify', async (req, res) => {
  const { assertion } = req.body;

  const credential = await db.findCredential(assertion.id);
  const verified = await verifyAuthentication(
    assertion,
    credential.publicKey,
    req.session.challenge,
    credential.counter
  );

  if (verified) {
    // カウンターを更新(リプレイ攻撃防止)
    await db.updateCounter(assertion.id, verified.newCounter);

    // セッション/トークン発行
    const token = jwt.sign({ sub: credential.userId }, privateKey);
    res.json({ token });
  }
});

10. 多要素認証(MFA)

10.1 MFA方式の比較

方式セキュリティレベルUX実装難易度
SMS OTP低い(SIMスワッピングに脆弱)普通低い
TOTP(Google Authenticator)中程度普通中程度
プッシュ通知(Duo, Okta)高い良い高い
ハードウェアキー(YubiKey)非常に高い普通中程度
Passkey非常に高い非常に良い中程度

10.2 TOTP実装

const speakeasy = require('speakeasy');
const QRCode = require('qrcode');

// MFA設定 - シークレット生成
app.post('/mfa/setup', async (req, res) => {
  const secret = speakeasy.generateSecret({
    name: `MyApp (${req.user.email})`,
    issuer: 'MyApp'
  });

  // QRコード生成
  const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url);

  // シークレットを一時保存(検証前)
  await db.saveTempMfaSecret(req.user.id, secret.base32);

  res.json({
    qrCode: qrCodeUrl,
    manualEntry: secret.base32
  });
});

// MFA設定 - コード検証後に有効化
app.post('/mfa/verify-setup', async (req, res) => {
  const { code } = req.body;
  const secret = await db.getTempMfaSecret(req.user.id);

  const verified = speakeasy.totp.verify({
    secret: secret,
    encoding: 'base32',
    token: code,
    window: 1  // 前後30秒を許容
  });

  if (verified) {
    await db.enableMfa(req.user.id, secret);
    // バックアップコード生成
    const backupCodes = Array.from({ length: 10 }, () =>
      crypto.randomBytes(4).toString('hex')
    );
    await db.saveBackupCodes(req.user.id, backupCodes);
    res.json({ success: true, backupCodes });
  } else {
    res.status(400).json({ error: '無効なコード' });
  }
});

// ログイン時のMFA検証
app.post('/auth/mfa-verify', async (req, res) => {
  const { code, userId } = req.body;
  const user = await db.getUser(userId);

  const verified = speakeasy.totp.verify({
    secret: user.mfaSecret,
    encoding: 'base32',
    token: code,
    window: 1
  });

  if (verified) {
    const token = jwt.sign({ sub: user.id, mfa: true }, privateKey);
    res.json({ token });
  } else {
    res.status(401).json({ error: '無効なMFAコード' });
  }
});

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

11.1 CSRF防止

CSRF(Cross-Site Request Forgery):
悪意のあるサイトが認証済みユーザーの権限でリクエストを偽造。

防御方法:
1. SameSiteクッキー属性
   Set-Cookie: session=abc; SameSite=Strict

2. CSRFトークン
   - サーバーがフォームと共に一意のトークンを発行
   - フォーム送信時にトークンを含む
   - サーバーがトークンを検証

3. Double Submit Cookie
   - CSRFトークンをクッキーとリクエストボディの両方に含む
   - 2つの値が一致するか確認

4. Origin/Referer検証
   - リクエストのOriginヘッダーが許可されたドメインか確認

11.2 XSS防止とトークン保存

トークン保存場所別セキュリティ比較:

localStorage:
- XSSに脆弱(JavaScriptでアクセス可能)
- CSRFには安全
- 使用しないことを強く推奨

sessionStorage:
- XSSに脆弱
- タブ間で共有されない
- localStorageよりやや良い

HttpOnly Cookie:
- XSSに安全(JavaScriptアクセス不可)
- CSRF保護が必要(SameSite=Strict)
- 最も推奨される方法

メモリ(変数):
- XSS/CSRFの両方に最も安全
- ページリフレッシュで消失
- SPAで使用可能、Refresh TokenはHttpOnlyクッキー

11.3 CORS設定

// Express.js CORS設定
const cors = require('cors');

// Bad: 全ドメインを許可
app.use(cors());

// Good: 特定ドメインのみ許可
app.use(cors({
  origin: ['https://myapp.com', 'https://admin.myapp.com'],
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,  // クッキーの含有を許可
  maxAge: 86400       // Preflightキャッシュ24時間
}));

11.4 セキュリティヘッダー

# 必須セキュリティヘッダー
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 0
Content-Security-Policy: default-src 'self'; script-src 'self'
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()

11.5 パスワードポリシー

// bcryptでパスワードハッシュ
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12;

// パスワードハッシュ
async function hashPassword(plainPassword) {
  return await bcrypt.hash(plainPassword, SALT_ROUNDS);
}

// パスワード検証
async function verifyPassword(plainPassword, hashedPassword) {
  return await bcrypt.compare(plainPassword, hashedPassword);
}

// パスワード強度の検証
function validatePasswordStrength(password) {
  const rules = [
    { test: /.{8,}/, message: '最低8文字以上' },
    { test: /[A-Z]/, message: '大文字1文字以上' },
    { test: /[a-z]/, message: '小文字1文字以上' },
    { test: /[0-9]/, message: '数字1文字以上' },
    { test: /[^A-Za-z0-9]/, message: '特殊文字1文字以上' },
  ];

  const failures = rules.filter(r => !r.test.test(password));
  return { valid: failures.length === 0, failures };
}

12. 認証ライブラリ比較

12.1 主要ライブラリ

ライブラリタイプ言語主な用途
NextAuth.js (Auth.js)ライブラリJS/TSNext.jsソーシャルログイン
Passport.jsミドルウェアJSExpress多様な戦略
LuciaライブラリJS/TSセッションベース、直接制御
ClerkBaaSJS/TSフルスタック認証UI
Auth0IDaaS多様エンタープライズSSO
KeycloakセルフホストJavaエンタープライズIdP
Supabase AuthBaaSJS/TSSupabaseエコシステム
Firebase AuthBaaS多様Googleエコシステム

12.2 選択基準

小規模プロジェクト + 迅速な実装:
  -> NextAuth.js / Clerk / Supabase Auth

直接制御 + カスタム要件:
  -> Lucia / Passport.js

エンタープライズSSO + SAML:
  -> Auth0 / Keycloak

マイクロサービス + セルフホスティング:
  -> Keycloak

サーバーレス + Googleエコシステム:
  -> Firebase Auth

13. セッション vs JWT比較表

項目セッションベースJWTベース
状態管理Stateful(サーバー保存)Stateless(トークン自己完結)
保存場所サーバー(Redis/DB)クライアント(クッキー/メモリ)
スケーラビリティセッション共有が必要サーバー間共有不要
セキュリティ(盗取時)サーバーで即座に無効化期限切れまで有効
サイズクッキー:小さい(セッションID)トークン:大きい(Claims含む)
マイクロサービス中央セッションストア必要各サービスで独立検証
モバイル対応クッキー管理が必要Authorizationヘッダー使用
ログアウトセッション削除で即座ブラックリストが必要
サーバー負荷毎リクエストDB/Redis参照署名検証のみ(CPU)
クロスドメインクッキードメイン制限ヘッダーで自由に転送
実装の複雑度低い中程度(Refresh Tokenなど)
CSRF脆弱(クッキー自動送信)安全(Authorizationヘッダー)
XSS安全(HttpOnlyクッキー)脆弱(localStorage保存時)
オフライン利用不可可能(トークン内情報活用)
デバッグサーバーログ確認jwt.ioでデコード可能

14. クイズ

Q1. Authentication vs Authorization

401 Unauthorizedと403 Forbiddenの違いを具体的なシナリオで説明してください。

回答:

  • 401 Unauthorized(認証失敗): ユーザーが自分の身元を証明していない状態。例:Authorizationヘッダーなしでのリクエスト、期限切れJWTトークンの使用、間違ったパスワードの入力。「あなたが誰かわかりません。」

  • 403 Forbidden(認可失敗): ユーザーは認証済みだが、そのリソースに対する権限がない状態。例:一般ユーザーがDELETE /admin/users APIを呼び出す、他のユーザーの非公開データへのアクセスを試みる。「あなたが誰かは知っていますが、この操作を行う権限がありません。」

Q2. JWTセキュリティ

JWTをlocalStorageに保存してはいけない理由と推奨される保存方法を説明してください。

回答: localStorageに保存するとXSS(クロスサイトスクリプティング)攻撃に脆弱です。悪意のあるスクリプトが注入されると、localStorage.getItem('token')でトークンを盗取できます。

推奨される保存方法:

  1. Access Token: メモリ(JavaScript変数)に保存。ページリフレッシュで消失するが最も安全。
  2. Refresh Token: HttpOnly + Secure + SameSite=Strictクッキーに保存。JavaScriptからアクセス不可でXSSに安全。
  3. Access Tokenが期限切れになったらRefresh Tokenクッキーで更新。

Q3. OAuth 2.0 PKCE

SPAでAuthorization Code FlowにPKCEが必須である理由を説明してください。

回答: SPAはソースコードがブラウザに完全に露出されるため、client_secretを安全に保管できません。PKCEなしでは、Authorization Codeが横取りされた場合、攻撃者がこれをAccess Tokenに交換できます。

PKCEはこの問題を解決します:

  1. クライアントがcode_verifier(ランダム文字列)を生成
  2. code_challenge = SHA256(code_verifier)を認可リクエストに含む
  3. トークン交換時に元のcode_verifierを送信
  4. サーバーがSHA256(code_verifier) == code_challengeを検証

攻撃者がAuthorization Codeを横取りしても、code_verifierを知らなければトークン交換は不可能です。

Q4. Passkey

Passkeyがパスワードより安全な理由を3つ説明してください。

回答:

  1. フィッシング不可能: Passkeyは登録されたドメイン(RP ID)にバインドされます。偽サイトではPasskeyが機能しないため、フィッシング攻撃が根本的にブロックされます。

  2. サーバー漏洩に安全: サーバーには公開鍵のみが保存されます。DBが漏洩しても公開鍵では認証を偽造できません。パスワードはハッシュ化されて保存されますが、脆弱なパスワードはクラックされる可能性があります。

  3. 再利用不可: 各サービスごとに固有の鍵ペアが生成されます。1つのサービスで漏洩しても他のサービスに影響がありません。一方、パスワードはユーザーが複数のサイトで再利用するケースが多いです。

Q5. セッション vs JWT

マイクロサービス環境でセッションベース認証よりJWTが有利な理由を説明してください。

回答: マイクロサービス環境でJWTが有利な理由:

  1. 独立した検証: 各サービスが公開鍵さえあれば独立してトークンを検証できます。セッションは中央セッションストア(Redis)を全サービスが共有する必要があります。

  2. ネットワーク負荷の削減: JWTは署名検証のみで認証が完了し、毎リクエストのDB/Redis参照が不要です。

  3. サービス間伝播の容易さ: JWTをAuthorizationヘッダーに含めてサービス間で転送するだけです。ユーザー情報がトークン内に含まれているため、追加の問い合わせが不要です。

  4. スケーリングの容易さ: 新しいサービスインスタンスの追加時にセッションストアの接続設定が不要で、公開鍵の配布だけで済みます。


参考資料

  1. OAuth 2.0 RFC 6749 - https://tools.ietf.org/html/rfc6749
  2. JWT RFC 7519 - https://tools.ietf.org/html/rfc7519
  3. PKCE RFC 7636 - https://tools.ietf.org/html/rfc7636
  4. OpenID Connect Core - https://openid.net/specs/openid-connect-core-1_0.html
  5. WebAuthn Specification - https://www.w3.org/TR/webauthn-2/
  6. FIDO2 Specifications - https://fidoalliance.org/fido2/
  7. OWASP Authentication Cheat Sheet - https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
  8. OWASP Session Management - https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
  9. Auth0 Documentation - https://auth0.com/docs
  10. NextAuth.js (Auth.js) Docs - https://authjs.dev/
  11. Passkeys.dev - https://passkeys.dev/
  12. SAML 2.0 Technical Overview - https://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0.html
  13. OAuth 2.0 Security Best Current Practice - https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics
  14. Keycloak Documentation - https://www.keycloak.org/documentation