OAuth 2.0 フロー
OAuth 2.0 は「認可の委譲」のための標準プロトコル。Authorization Code + PKCE、Client Credentials、Device Code、Implicit(非推奨)の各フローの仕組み、適用場面、セキュリティ上の注意点を網羅的に解説する。
この章で学ぶこと
前提知識
このガイドを読む前に、以下の知識があると理解が深まります:
- 基本的なプログラミングの知識
- 関連する基礎概念の理解
- JWT 詳解 の内容を理解していること
1. OAuth 2.0 の基本概念
OAuth 2.0 の役割:
「第三者アプリケーションにユーザーのリソースへの
限定的なアクセスを許可する仕組み」
例: 「GitHub のリポジトリ一覧を MyApp に許可する」
→ ユーザーは GitHub のパスワードを MyApp に渡さない
→ MyApp は必要な権限(スコープ)のみ取得
4つのアクター:
|
|---|
| Resource Owner(リソースオーナー) |
| → ユーザー本人 |
|
| Client(クライアント) |
| → アクセスを要求するアプリケーション |
| → MyApp |
|
| Authorization Server(認可サーバー) |
| → アクセス許可を発行するサーバー |
| → GitHub の OAuth サーバー |
|
| Resource Server(リソースサーバー) |
| → 保護されたリソースを持つサーバー |
| → GitHub API |
|
フローの選択:
アプリ種別 │ 推奨フロー
──────────────────────┼──────────────────────
Web アプリ(サーバーあり)│ Authorization Code
SPA(サーバーなし) │ Authorization Code + PKCE
モバイルアプリ │ Authorization Code + PKCE
サーバー間通信 │ Client Credentials
IoT / テレビ │ Device Code
SPA(レガシー) │ Implicit(非推奨)
1.1 OAuth 2.0 の歴史と設計思想
OAuth の進化:
OAuth 1.0(2007年):
→ 署名ベース(HMAC-SHA1)
→ 各リクエストに署名が必要
→ 実装が複雑(正規化、署名生成)
→ TLS が必須ではなかった
OAuth 2.0(2012年、RFC 6749):
→ Bearer トークンベース
→ TLS 必須(署名を簡素化)
→ 複数のグラントタイプ(フロー)
→ スコープによる権限制御
→ リフレッシュトークンの導入
OAuth 2.1(策定中):
→ PKCE の必須化(全フロー)
→ Implicit フローの廃止
→ Resource Owner Password の廃止
→ リフレッシュトークンのローテーション推奨
→ Bearer トークンの sender-constraint 推奨
設計上の重要な判断:
① OAuth 2.0 は「認可」プロトコル(認証ではない)
→ 「このアプリに GitHub リポジトリへのアクセスを許可」
→ ユーザーの身元確認は OpenID Connect が担う
② アクセストークンはクライアントにとって「不透明」
→ クライアントはトークンの中身を解析してはいけない
→ リソースサーバーのみがトークンを検証する
→ JWT を使うかどうかは認可サーバーの実装次第
③ フロントチャネル vs バックチャネル
→ フロントチャネル: ブラウザのリダイレクト(傍受リスク)
→ バックチャネル: サーバー間の直接通信(安全)
→ 認可コードはフロントチャネルで受け取り
→ トークン交換はバックチャネルで実行
1.2 クライアントの分類
OAuth 2.0 におけるクライアントの分類:
Confidential Client(秘密クライアント):
→ client_secret を安全に保持できる
→ サーバーサイドアプリケーション
→ バックエンド Web アプリ
→ client_secret をトークン交換時に使用
Public Client(公開クライアント):
→ client_secret を安全に保持できない
→ SPA(ブラウザ上の JavaScript)
→ モバイルアプリ(デコンパイル可能)
→ デスクトップアプリ
→ PKCE を使用して保護
|
|---|
| Confidential Client: |
| ┌─────────────┐ client_secret ┌──────────┐ |
| Web Server | ───────────────────> | Auth | |
| (backend) | 安全に送信可能 | Server | |
| └─────────────┘ └──────────┘ |
|
| Public Client: |
| ┌─────────────┐ client_secret ┌──────────┐ |
| SPA / Mobile | ───────────× ─────── | Auth | |
| (frontend) | ソースから漏洩する | Server | |
| └─────────────┘ └──────────┘ |
| 代わりに PKCE を使用 |
|
クライアント登録時の設定:
→ client_id: 公開識別子
→ client_secret: 秘密鍵(Confidential のみ)
→ redirect_uris: 許可されたリダイレクト先
→ grant_types: 許可されたフロー
→ scopes: 要求可能なスコープ
→ token_endpoint_auth_method: 認証方式
→ client_secret_basic: Basic 認証
→ client_secret_post: POST パラメータ
→ private_key_jwt: JWT ベース認証
→ none: Public Client
2. Authorization Code フロー
Authorization Code フロー(最も安全、最も一般的):
ユーザー クライアント 認可サーバー リソースサーバー
│ │ │ │
│ ログイン │ │ │
│──────────>│ │ │
│ │ ① 認可リクエスト │
│ │───────────>│ │
│ │ │ │
│ ② ログイン画面 │ │
│<──────────────────────│ │
│ │ │ │
│ ③ 認証 + 権限承認 │ │
│──────────────────────>│ │
│ │ │ │
│ ④ redirect_uri + │ │
│ authorization_code │ │
│──────────>│ │ │
│ │ │ │
│ │ ⑤ code → token 交換 │
│ │───────────>│ │
│ │ │ │
│ │ ⑥ access_token + │
│ │ refresh_token │
│ │<───────────│ │
│ │ │ │
│ │ ⑦ API リクエスト │
│ │ + Bearer access_token │
│ │───────────────────────────>│
│ │ │ │
│ │ ⑧ リソース │
│ │<───────────────────────────│
│ レスポンス │ │ │
│<──────────│ │ │
2.1 各ステップの詳細
① 認可リクエストの内部:
GET /authorize?
response_type=code ← 認可コードを要求
&client_id=my-app-id ← クライアント識別子
&redirect_uri=https://myapp.com/callback ← コールバック先
&scope=read:user repo ← 要求する権限
&state=xyzabc123 ← CSRF 防御用ランダム値
&code_challenge=E9Melhoa... ← PKCE チャレンジ(推奨)
&code_challenge_method=S256 ← PKCE メソッド
各パラメータの意味と重要性:
response_type:
→ "code" = Authorization Code フロー
→ "token" = Implicit フロー(非推奨)
state:
→ CSRF 防御の要
→ 暗号的にランダムな値(32バイト以上)
→ セッションに紐づけて保存
→ コールバック時に一致確認
→ 一致しなければ → 攻撃の可能性
redirect_uri:
→ 事前登録された URI と完全一致が必須
→ オープンリダイレクタの防止
→ ワイルドカード禁止(セキュリティリスク)
→ localhost は開発時のみ許可
④ 認可レスポンス:
HTTP/1.1 302 Found
Location: https://myapp.com/callback
?code=SplxlOBeZQQYbYS6WxSbIA ← 認可コード(短寿命)
&state=xyzabc123 ← 送信した state がそのまま返る
認可コードの特性:
→ 有効期限: 10分以内(推奨: 30秒〜1分)
→ 一度使い切り(使用後は無効化)
→ client_id に紐づく
→ redirect_uri に紐づく
→ 漏洩しても client_secret なしでは使えない
(Confidential Client の場合)
⑤ トークン交換リクエスト:
POST /token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic <base64(client_id:client_secret)>
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https://myapp.com/callback
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
認可サーバーの検証:
✓ client_id と client_secret の一致
✓ code が有効(未使用、未期限切れ)
✓ code が client_id に紐づくか
✓ redirect_uri が認可リクエスト時と同一か
✓ code_verifier のハッシュが code_challenge と一致(PKCE)
✓ すべて OK → トークン発行
⑥ トークンレスポンス:
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
Pragma: no-cache
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "8xLOxBtZp8",
"scope": "read:user repo"
}
重要なレスポンスヘッダー:
→ Cache-Control: no-store(トークンをキャッシュしない)
→ Pragma: no-cache(HTTP/1.0 互換)
// Authorization Code フロー 完全実装
// ① 認可リクエスト(クライアント → 認可サーバー)
function getAuthorizationUrl(state: string): string {
const params = new URLSearchParams({
response_type: 'code',
client_id: process.env.GITHUB_CLIENT_ID!,
redirect_uri: 'https://myapp.com/callback',
scope: 'read:user repo',
state, // CSRF 防御用ランダム値
});
return `https://github.com/login/oauth/authorize?${params}`;
}
// ④⑤ コールバック処理(認可コード → トークン交換)
async function handleCallback(code: string, state: string) {
// state の検証(CSRF 防御)
if (state !== storedState) {
throw new Error('Invalid state parameter');
}
// ⑤ トークン交換(サーバー側で実行 → client_secret を安全に使用)
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
client_id: process.env.GITHUB_CLIENT_ID,
client_secret: process.env.GITHUB_CLIENT_SECRET, // サーバーのみ
code,
redirect_uri: 'https://myapp.com/callback',
}),
});
const { access_token, refresh_token, scope, token_type } = await tokenResponse.json();
// ⑦ リソース取得
const userResponse = await fetch('https://api.github.com/user', {
headers: { Authorization: `Bearer ${access_token}` },
});
return userResponse.json();
}
2.2 認可サーバーの内部実装
// 認可サーバーの内部実装(Express)
import express from 'express';
import crypto from 'crypto';
interface AuthorizationCode {
code: string;
clientId: string;
userId: string;
redirectUri: string;
scope: string;
codeChallenge?: string;
codeChallengeMethod?: string;
createdAt: Date;
used: boolean;
}
interface TokenRecord {
accessToken: string;
refreshToken: string;
clientId: string;
userId: string;
scope: string;
accessTokenExpiresAt: Date;
refreshTokenExpiresAt: Date;
revoked: boolean;
}
class AuthorizationServer {
private codes: Map<string, AuthorizationCode> = new Map();
private tokens: Map<string, TokenRecord> = new Map();
private refreshTokenIndex: Map<string, string> = new Map(); // refreshToken → accessToken
// ① 認可エンドポイント
async handleAuthorize(req: express.Request, res: express.Response) {
const {
response_type,
client_id,
redirect_uri,
scope,
state,
code_challenge,
code_challenge_method,
} = req.query as Record<string, string>;
// バリデーション
if (response_type !== 'code') {
return res.status(400).json({ error: 'unsupported_response_type' });
}
// クライアント検証
const client = await this.getClient(client_id);
if (!client) {
return res.status(400).json({ error: 'invalid_client' });
}
// redirect_uri の完全一致確認
if (!client.redirectUris.includes(redirect_uri)) {
// redirect_uri が不正な場合はリダイレクトしてはいけない
// (オープンリダイレクタ防止)
return res.status(400).json({ error: 'invalid_redirect_uri' });
}
// スコープ検証
const requestedScopes = scope.split(' ');
const allowedScopes = requestedScopes.filter(s => client.allowedScopes.includes(s));
// ユーザー認証済みか確認(未認証ならログイン画面へ)
const user = req.session?.user;
if (!user) {
// ログイン画面にリダイレクト(認可パラメータを保持)
return res.redirect(`/login?return_to=${encodeURIComponent(req.originalUrl)}`);
}
// 同意画面を表示(または以前の同意を確認)
const existingConsent = await this.getConsent(user.id, client_id, allowedScopes);
if (!existingConsent) {
return res.render('consent', {
client,
scopes: allowedScopes,
state,
// 同意フォーム送信先
});
}
// 認可コード生成
const code = crypto.randomBytes(32).toString('hex');
this.codes.set(code, {
code,
clientId: client_id,
userId: user.id,
redirectUri: redirect_uri,
scope: allowedScopes.join(' '),
codeChallenge: code_challenge,
codeChallengeMethod: code_challenge_method,
createdAt: new Date(),
used: false,
});
// 認可コードは短寿命(30秒後に自動削除)
setTimeout(() => this.codes.delete(code), 30 * 1000);
// リダイレクト
const callbackUrl = new URL(redirect_uri);
callbackUrl.searchParams.set('code', code);
if (state) callbackUrl.searchParams.set('state', state);
res.redirect(302, callbackUrl.toString());
}
// ⑤ トークンエンドポイント
async handleToken(req: express.Request, res: express.Response) {
const { grant_type } = req.body;
// Cache-Control ヘッダー(RFC 6749 Section 5.1)
res.set('Cache-Control', 'no-store');
res.set('Pragma', 'no-cache');
switch (grant_type) {
case 'authorization_code':
return this.handleAuthorizationCodeGrant(req, res);
case 'refresh_token':
return this.handleRefreshTokenGrant(req, res);
case 'client_credentials':
return this.handleClientCredentialsGrant(req, res);
default:
return res.status(400).json({ error: 'unsupported_grant_type' });
}
}
private async handleAuthorizationCodeGrant(
req: express.Request,
res: express.Response,
) {
const { code, redirect_uri, code_verifier } = req.body;
const { clientId, clientSecret } = this.extractClientCredentials(req);
// クライアント認証
const client = await this.authenticateClient(clientId, clientSecret);
if (!client) {
return res.status(401).json({ error: 'invalid_client' });
}
// 認可コード検証
const authCode = this.codes.get(code);
if (!authCode) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'Code not found or expired' });
}
// 使用済みチェック
if (authCode.used) {
// 認可コード再利用 = 攻撃の可能性
// → 該当コードで発行された全トークンを無効化
await this.revokeTokensByCode(code);
return res.status(400).json({ error: 'invalid_grant', error_description: 'Code already used' });
}
// client_id 一致確認
if (authCode.clientId !== clientId) {
return res.status(400).json({ error: 'invalid_grant' });
}
// redirect_uri 一致確認
if (authCode.redirectUri !== redirect_uri) {
return res.status(400).json({ error: 'invalid_grant' });
}
// PKCE 検証
if (authCode.codeChallenge) {
if (!code_verifier) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'code_verifier required' });
}
const valid = this.verifyPKCE(
code_verifier,
authCode.codeChallenge,
authCode.codeChallengeMethod || 'plain',
);
if (!valid) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'PKCE verification failed' });
}
}
// コードを使用済みにマーク
authCode.used = true;
// トークン発行
const tokens = await this.issueTokens(clientId, authCode.userId, authCode.scope);
res.json({
access_token: tokens.accessToken,
token_type: 'Bearer',
expires_in: 3600,
refresh_token: tokens.refreshToken,
scope: authCode.scope,
});
}
// PKCE 検証
private verifyPKCE(
verifier: string,
challenge: string,
method: string,
): boolean {
if (method === 'S256') {
const hash = crypto.createHash('sha256').update(verifier).digest();
const computed = hash
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
return computed === challenge;
}
if (method === 'plain') {
return verifier === challenge;
}
return false;
}
// クライアント認証情報の抽出
private extractClientCredentials(req: express.Request): {
clientId: string;
clientSecret: string | null;
} {
// Basic 認証ヘッダーから取得
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Basic ')) {
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString();
const [clientId, clientSecret] = decoded.split(':');
return { clientId, clientSecret };
}
// POST ボディから取得
return {
clientId: req.body.client_id,
clientSecret: req.body.client_secret || null,
};
}
// トークン発行
private async issueTokens(
clientId: string,
userId: string,
scope: string,
): Promise<{ accessToken: string; refreshToken: string }> {
const accessToken = crypto.randomBytes(32).toString('hex');
const refreshToken = crypto.randomBytes(48).toString('hex');
const record: TokenRecord = {
accessToken,
refreshToken,
clientId,
userId,
scope,
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), // 1時間
refreshTokenExpiresAt: new Date(Date.now() + 30 * 24 * 3600 * 1000), // 30日
revoked: false,
};
this.tokens.set(accessToken, record);
this.refreshTokenIndex.set(refreshToken, accessToken);
return { accessToken, refreshToken };
}
}
3. PKCE(Proof Key for Code Exchange)
PKCE の必要性:
SPA / モバイルアプリの問題:
→ client_secret を安全に保持できない
→ 認可コードの横取り攻撃(Authorization Code Interception)
→ 悪意あるアプリが redirect_uri を傍受して code を取得
PKCE の仕組み:
→ code_verifier: クライアントが生成するランダム文字列
→ code_challenge: code_verifier のハッシュ(SHA-256)
→ 認可リクエスト時に challenge を送信
→ トークン交換時に verifier を送信
→ サーバーが verifier をハッシュして challenge と比較
PKCE フロー:
① code_verifier = ランダム文字列(43〜128文字)
② code_challenge = BASE64URL(SHA256(code_verifier))
③ 認可リクエスト: code_challenge + code_challenge_method=S256
④ コールバック: authorization_code を受信
⑤ トークン交換: code + code_verifier を送信
⑥ サーバー: SHA256(code_verifier) == code_challenge を検証
3.1 PKCE が防ぐ攻撃
認可コード横取り攻撃(PKCE なし):
正規アプリ 悪意あるアプリ 認可サーバー
│ │ │
│ 認可リクエスト │ │
│──────────────────────────────────>│
│ │ │
│ 302 Redirect │ │
│ ?code=abc123 │ │
│<──────────────────────────────────│
│ │ │
│ ★ 悪意あるアプリが │ │
│ code を傍受! │ │
│─────────────────>│ │
│ │ │
│ │ code → token │
│ │──────────────>│
│ │ │
│ │ access_token │
│ │<──────────────│
│ │ │
│ │ ★ 不正にリソースアクセス!
傍受の方法:
→ カスタム URL スキームの乗っ取り(モバイル)
→ ブラウザ拡張の悪用
→ OS レベルのリダイレクト傍受
認可コード横取り攻撃(PKCE あり):
正規アプリ 悪意あるアプリ 認可サーバー
│ │ │
│ verifier を生成 │ │
│ challenge = SHA256(verifier) │
│ │ │
│ 認可リクエスト │ │
│ + challenge │ │
│──────────────────────────────────>│
│ │ │
│ 302 Redirect │ │
│ ?code=abc123 │ │
│<──────────────────────────────────│
│ │ │
│ ★ 悪意あるアプリが │ │
│ code を傍受 │ │
│─────────────────>│ │
│ │ │
│ │ code → token │
│ │ verifier = ??? │
│ │──────────────>│
│ │ │
│ │ ✗ PKCE 検証失敗!│
│ │ verifier 不明 │
│ │<──────────────│
│ │ │
│ 正規アプリ: │ │
│ code + verifier │ │
│──────────────────────────────────>│
│ │ ✓ PKCE 検証成功 │
│ access_token │ │
│<──────────────────────────────────│
// PKCE 実装
// code_verifier と code_challenge の生成
async function generatePKCE(): Promise<{ verifier: string; challenge: string }> {
// code_verifier: 43〜128文字の暗号ランダム文字列
const verifier = base64URLEncode(crypto.getRandomValues(new Uint8Array(32)));
// code_challenge: SHA-256 ハッシュの Base64URL エンコード
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const challenge = base64URLEncode(new Uint8Array(hashBuffer));
return { verifier, challenge };
}
function base64URLEncode(buffer: Uint8Array): string {
return btoa(String.fromCharCode(...buffer))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
// ① 認可リクエスト(PKCE 付き)
async function startAuthFlow() {
const { verifier, challenge } = await generatePKCE();
const state = crypto.randomUUID();
// verifier と state をセッションに保存
sessionStorage.setItem('pkce_verifier', verifier);
sessionStorage.setItem('oauth_state', state);
const params = new URLSearchParams({
response_type: 'code',
client_id: 'my-spa-client-id',
redirect_uri: 'https://myapp.com/callback',
scope: 'openid profile email',
state,
code_challenge: challenge,
code_challenge_method: 'S256',
});
window.location.href = `https://auth.example.com/authorize?${params}`;
}
// ⑤ コールバック(PKCE + トークン交換)
async function handlePKCECallback(code: string, state: string) {
// state 検証
const storedState = sessionStorage.getItem('oauth_state');
if (state !== storedState) throw new Error('Invalid state');
const verifier = sessionStorage.getItem('pkce_verifier');
// トークン交換(client_secret 不要)
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: 'https://myapp.com/callback',
client_id: 'my-spa-client-id',
code_verifier: verifier!, // client_secret の代わり
}),
});
const tokens = await response.json();
// { access_token, refresh_token, id_token, token_type, expires_in }
// クリーンアップ
sessionStorage.removeItem('pkce_verifier');
sessionStorage.removeItem('oauth_state');
return tokens;
}
3.2 PKCE の内部:SHA-256 による検証
PKCE の暗号学的な安全性:
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
code_challenge = BASE64URL(SHA256(code_verifier))
= "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
なぜ安全か:
① SHA-256 は一方向関数
→ challenge から verifier を逆算することは計算上不可能
→ 2^256 の探索空間 ≈ 1.16 × 10^77
② verifier は 43〜128 文字(RFC 7636)
→ 十分なエントロピー
→ ブルートフォース不可能
③ verifier はネットワークに流れない(バックチャネルのみ)
→ フロントチャネルに challenge のみ
→ challenge を傍受しても verifier は不明
plain メソッド(非推奨):
→ code_challenge = code_verifier そのもの
→ 傍受されると意味がない
→ SHA-256 をサポートしないクライアント向け
→ OAuth 2.1 では S256 のみ必須
4. Client Credentials フロー
Client Credentials フロー:
用途: サーバー間通信(ユーザーが介在しない)
例: バッチ処理、マイクロサービス間、バックエンド API
クライアント 認可サーバー リソースサーバー
│ │ │
│ client_id + │ │
│ client_secret │ │
│──────────────────>│ │
│ │ │
│ access_token │ │
│<──────────────────│ │
│ │ │
│ Bearer token │ │
│──────────────────────────────────────>│
│ │ │
│ リソース │ │
│<──────────────────────────────────────│
特徴:
→ ユーザーの同意画面なし
→ client_id + client_secret で認証
→ refresh_token は発行されない
→ トークンの有効期限で管理
4.1 Client Credentials の実装パターン
// Client Credentials フロー(基本)
async function getServiceToken(): Promise<string> {
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
// Basic 認証で client_id:client_secret を送信
'Authorization': `Basic ${btoa(`${CLIENT_ID}:${CLIENT_SECRET}`)}`,
},
body: new URLSearchParams({
grant_type: 'client_credentials',
scope: 'read:data write:data',
}),
});
const { access_token } = await response.json();
return access_token;
}
// トークンキャッシュ付きの実装(本番向け)
class ServiceTokenManager {
private token: string | null = null;
private expiresAt: number = 0;
private refreshPromise: Promise<string> | null = null;
private readonly bufferSeconds = 60; // 期限の60秒前に更新
constructor(
private clientId: string,
private clientSecret: string,
private tokenUrl: string,
private scope: string,
) {}
async getToken(): Promise<string> {
// キャッシュが有効ならそのまま返す
if (this.token && Date.now() < this.expiresAt - this.bufferSeconds * 1000) {
return this.token;
}
// 同時リクエスト時に重複リフレッシュを防止
if (this.refreshPromise) {
return this.refreshPromise;
}
this.refreshPromise = this.fetchNewToken();
try {
const token = await this.refreshPromise;
return token;
} finally {
this.refreshPromise = null;
}
}
private async fetchNewToken(): Promise<string> {
const response = await fetch(this.tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${Buffer.from(
`${this.clientId}:${this.clientSecret}`,
).toString('base64')}`,
},
body: new URLSearchParams({
grant_type: 'client_credentials',
scope: this.scope,
}),
});
if (!response.ok) {
throw new Error(`Token request failed: ${response.status}`);
}
const data = await response.json();
this.token = data.access_token;
this.expiresAt = Date.now() + data.expires_in * 1000;
return this.token;
}
}
// マイクロサービス間通信での使用例
class OrderService {
private tokenManager: ServiceTokenManager;
constructor() {
this.tokenManager = new ServiceTokenManager(
process.env.ORDER_SERVICE_CLIENT_ID!,
process.env.ORDER_SERVICE_CLIENT_SECRET!,
'https://auth.example.com/token',
'inventory:read inventory:reserve payments:create',
);
}
async createOrder(orderData: OrderRequest): Promise<Order> {
const token = await this.tokenManager.getToken();
// 在庫サービスに問い合わせ
const inventory = await fetch('https://inventory.internal/api/check', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ items: orderData.items }),
});
// 決済サービスに送信
const payment = await fetch('https://payments.internal/api/charge', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: orderData.total,
currency: 'JPY',
}),
});
// ...
}
}
4.2 private_key_jwt 認証
Client Credentials の高度な認証方式:
client_secret_basic(基本):
→ Authorization: Basic base64(client_id:client_secret)
→ シンプルだが、secret の安全な管理が必要
client_secret_post:
→ POST ボディに client_id と client_secret を含める
→ TLS 必須
private_key_jwt(推奨・高セキュリティ):
→ client_secret の代わりに RSA/EC 秘密鍵で署名した JWT を送信
→ 秘密鍵が外部に送信されない
→ 鍵のローテーションが容易
private_key_jwt のフロー:
| クライアント | | 認可サーバー |
|---|
| | |
| JWT 生成: | | |
| { | | |
| iss: client | | |
| sub: client | | |
| aud: auth | | |
| exp: +5min | | |
| jti: random | | |
| } | | |
| → 秘密鍵で署名 | | |
| | |
| POST /token | | |
| grant_type= | | |
| client_creds | | |
| client_ | | |
| assertion=JWT | | |
| ─────────────────────────────────> | |
| | 公開鍵で検証 |
| | (JWKS) |
| | |
| access_token | | |
| <───────────────────────────────── | |
// private_key_jwt 認証の実装
import * as jose from 'jose';
async function getTokenWithPrivateKeyJWT(
privateKey: jose.KeyLike,
clientId: string,
tokenUrl: string,
scope: string,
): Promise<string> {
// クライアントアサーション JWT の生成
const assertion = await new jose.SignJWT({})
.setProtectedHeader({ alg: 'RS256', typ: 'JWT' })
.setIssuer(clientId)
.setSubject(clientId)
.setAudience(tokenUrl)
.setIssuedAt()
.setExpirationTime('5m')
.setJti(crypto.randomUUID())
.sign(privateKey);
const response = await fetch(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
scope,
client_id: clientId,
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
client_assertion: assertion,
}),
});
const data = await response.json();
return data.access_token;
}
5. Device Code フロー
Device Code フロー:
用途: キーボード入力が困難なデバイス(テレビ、IoT、CLI)
例: 「テレビで https://example.com/activate にアクセスして
コード ABC-123 を入力してください」
デバイス 認可サーバー ユーザーのスマホ/PC
│ │ │
│ device auth │ │
│──────────────>│ │
│ │ │
│ device_code + │ │
│ user_code + │ │
│ verification │ │
│ _uri │ │
│<──────────────│ │
│ │ │
│ 画面に表示: │ │
│ "コード: │ │
│ ABC-123" │ │
│ │ ユーザーが │
│ │ verification_uri│
│ │ にアクセス │
│ │<────────────────│
│ │ コード入力+承認 │
│ │<────────────────│
│ │ │
│ ポーリング │ │
│ (device_code) │ │
│──────────────>│ │
│ │ │
│ access_token │ │
│<──────────────│ │
5.1 Device Code フローのセキュリティ
Device Code フローの攻撃と対策:
攻撃1: リモートフィッシング
→ 攻撃者が自分のデバイスコードをフィッシングメールで送信
→ 「ログインして ABC-123 を入力してください」
→ ユーザーが攻撃者のデバイスにアクセスを許可してしまう
対策:
→ ユーザーにデバイス情報を表示
→ 「このデバイスからのアクセスを許可しますか?」と明示
→ verification_uri_complete を使用しない(自動承認防止)
攻撃2: ポーリング過多(DoS)
→ 悪意あるクライアントが高頻度でポーリング
対策:
→ interval パラメータで最小間隔を指定
→ slow_down エラーで間隔延長を要求
→ レート制限
攻撃3: デバイスコードのブルートフォース
→ user_code の総当たり試行
対策:
→ user_code の試行回数制限
→ 十分なエントロピー(8文字英数字 = ~40ビット)
→ 短い有効期限(15分程度)
// Device Code フロー(CLI ツール等)
async function deviceCodeFlow() {
// デバイス認可リクエスト
const deviceRes = await fetch('https://auth.example.com/device/code', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: 'my-cli-app',
scope: 'openid profile',
}),
});
const {
device_code,
user_code,
verification_uri,
verification_uri_complete,
expires_in,
interval,
} = await deviceRes.json();
// ユーザーに表示
console.log(`ブラウザで ${verification_uri} にアクセスし、`);
console.log(`コード ${user_code} を入力してください。`);
// ポーリングでトークン取得を試行
let pollInterval = interval;
const deadline = Date.now() + expires_in * 1000;
while (Date.now() < deadline) {
await sleep(pollInterval * 1000);
const tokenRes = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
device_code,
client_id: 'my-cli-app',
}),
});
const data = await tokenRes.json();
if (data.error === 'authorization_pending') continue;
if (data.error === 'slow_down') {
pollInterval += 5; // ポーリング間隔を延長
continue;
}
if (data.error) throw new Error(data.error_description);
return data; // { access_token, refresh_token, ... }
}
throw new Error('Device code expired');
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
6. Implicit フロー(非推奨)
Implicit フロー(歴史的背景と非推奨の理由):
OAuth 2.0 初期の SPA 向けフロー:
→ ブラウザから直接 access_token を取得
→ トークン交換ステップが不要(シンプル)
→ CORS が普及する前の妥協策
フロー:
クライアント 認可サーバー
│ │
│ response_type= │
│ token │
│──────────────────>│
│ │
│ #access_token=... │ ← URL フラグメントに含まれる
│<──────────────────│
非推奨の理由:
① アクセストークンがフロントチャネルに露出:
→ URL フラグメントに含まれる
→ ブラウザ履歴に残る
→ Referer ヘッダーで漏洩する可能性
→ ログファイルに記録される可能性
② リフレッシュトークンが使えない:
→ トークン期限切れ = 再認可が必要
→ UX が悪い
③ トークン置換攻撃:
→ 攻撃者のトークンを注入可能
→ aud 検証が困難
④ PKCE が使えない:
→ トークン交換ステップがないため
代替策:
→ Authorization Code + PKCE を使用
→ BFF(Backend for Frontend)パターン
→ OAuth 2.1 では Implicit は廃止予定
移行ガイド:
Before (Implicit):
response_type=token
→ #access_token=xxx が直接返される
After (Auth Code + PKCE):
response_type=code
code_challenge=xxx
code_challenge_method=S256
→ ?code=yyy が返される
→ バックチャネルで code → token 交換
7. トークンのライフサイクル管理
7.1 リフレッシュトークンフロー
リフレッシュトークンの仕組み:
access_token の有効期限が切れた場合:
クライアント 認可サーバー
│ │
│ API リクエスト │
│ + 期限切れ token │
│──────────────────>│
│ │
│ 401 Unauthorized │
│<──────────────────│
│ │
│ POST /token │
│ grant_type= │
│ refresh_token │
│ + refresh_token │
│──────────────────>│
│ │
│ 新 access_token + │
│ 新 refresh_token │
│ (ローテーション) │
│<──────────────────│
│ │
│ API リクエスト │
│ + 新 token │
│──────────────────>│
│ │
│ 200 OK + データ │
│<──────────────────│
トークンの有効期限設計:
access_token:
→ 短寿命: 5分〜1時間
→ 漏洩時の影響を最小化
→ 権限変更の反映を早める
→ 推奨: 15分(金融系: 5分)
refresh_token:
→ 長寿命: 7日〜90日
→ ユーザー体験(頻繁な再ログイン防止)
→ 使用時にローテーション(新しいものに交換)
→ 推奨: 30日(activity に応じて延長も可)
id_token(OIDC):
→ 認証時刻の証明のみ
→ リフレッシュ不可
→ 推奨: 1時間
// リフレッシュトークン ローテーション実装
class TokenRefreshManager {
private refreshing: Promise<TokenPair> | null = null;
constructor(
private tokenUrl: string,
private clientId: string,
private onTokenRefresh: (tokens: TokenPair) => void,
) {}
// トークンリフレッシュ(重複防止付き)
async refreshTokens(currentRefreshToken: string): Promise<TokenPair> {
// 既にリフレッシュ中なら同じ Promise を返す
if (this.refreshing) {
return this.refreshing;
}
this.refreshing = this.doRefresh(currentRefreshToken);
try {
const tokens = await this.refreshing;
this.onTokenRefresh(tokens);
return tokens;
} finally {
this.refreshing = null;
}
}
private async doRefresh(refreshToken: string): Promise<TokenPair> {
const response = await fetch(this.tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: this.clientId,
}),
});
if (!response.ok) {
const error = await response.json();
if (error.error === 'invalid_grant') {
// リフレッシュトークンが無効 → 再ログインが必要
throw new TokenRefreshError('refresh_token_invalid', 'Re-authentication required');
}
throw new TokenRefreshError(error.error, error.error_description);
}
const data = await response.json();
return {
accessToken: data.access_token,
refreshToken: data.refresh_token, // ローテーション: 新しい refresh_token
expiresIn: data.expires_in,
};
}
}
// HTTP クライアントとの統合(自動リフレッシュ)
class AuthenticatedHttpClient {
private accessToken: string;
private refreshToken: string;
private expiresAt: number;
private refreshManager: TokenRefreshManager;
constructor(
initialTokens: TokenPair,
refreshManager: TokenRefreshManager,
) {
this.accessToken = initialTokens.accessToken;
this.refreshToken = initialTokens.refreshToken;
this.expiresAt = Date.now() + initialTokens.expiresIn * 1000;
this.refreshManager = refreshManager;
}
async fetch(url: string, options: RequestInit = {}): Promise<Response> {
// アクセストークンの期限チェック
await this.ensureValidToken();
const response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${this.accessToken}`,
},
});
// 401 が返ってきた場合はリフレッシュを試行
if (response.status === 401) {
try {
await this.doTokenRefresh();
// リトライ
return fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${this.accessToken}`,
},
});
} catch (error) {
if (error instanceof TokenRefreshError) {
// リフレッシュ失敗 → 再ログインが必要
this.redirectToLogin();
}
throw error;
}
}
return response;
}
private async ensureValidToken(): Promise<void> {
// 有効期限の30秒前にプロアクティブにリフレッシュ
if (Date.now() > this.expiresAt - 30 * 1000) {
await this.doTokenRefresh();
}
}
private async doTokenRefresh(): Promise<void> {
const tokens = await this.refreshManager.refreshTokens(this.refreshToken);
this.accessToken = tokens.accessToken;
this.refreshToken = tokens.refreshToken;
this.expiresAt = Date.now() + tokens.expiresIn * 1000;
}
private redirectToLogin(): void {
window.location.href = '/login?reason=session_expired';
}
}
7.2 リフレッシュトークンのセキュリティ
リフレッシュトークン ローテーションの重要性:
ローテーションなし(危険):
→ リフレッシュトークンが漏洩 → 無期限にアクセス可能
→ 攻撃者と正規ユーザーが同じトークンを使用
→ 検知が困難
ローテーションあり(推奨):
→ リフレッシュするたびに新しいトークンを発行
→ 古いトークンは無効化
リプレイ検知:
正常フロー:
RT1 → 新 AT + RT2 → 新 AT + RT3 → ...
攻撃フロー:
攻撃者が RT1 を窃取
正規ユーザー: RT1 → RT2(正常)
攻撃者: RT1 → ✗ 使用済み!
→ 全リフレッシュトークンを無効化
→ ユーザーに再ログインを要求
リプレイ検知の仕組み:
| Token Family |
|---|
|
| RT1 → RT2 → RT3 → RT4(現在有効) |
|
| RT1 が再使用された場合: |
| → RT1, RT2, RT3, RT4 すべて無効化 |
| → 「トークンファミリー」全体を無効化 |
| → ユーザーに再認証を要求 |
// リフレッシュトークン ローテーション + リプレイ検知
class RefreshTokenStore {
private redis: Redis;
async storeToken(
tokenFamily: string,
refreshToken: string,
userId: string,
clientId: string,
expiresIn: number,
): Promise<void> {
const data = JSON.stringify({
userId,
clientId,
tokenFamily,
createdAt: Date.now(),
});
// リフレッシュトークン → データのマッピング
await this.redis.setex(`rt:${refreshToken}`, expiresIn, data);
// トークンファミリーに追加(リプレイ検知用)
await this.redis.sadd(`tf:${tokenFamily}`, refreshToken);
await this.redis.expire(`tf:${tokenFamily}`, expiresIn);
}
async useToken(refreshToken: string): Promise<{
userId: string;
clientId: string;
tokenFamily: string;
} | null> {
const raw = await this.redis.get(`rt:${refreshToken}`);
if (!raw) {
// トークンが存在しない
// → 有効期限切れ or 既に使用済み(リプレイ攻撃の可能性)
return null;
}
const data = JSON.parse(raw);
// トークンを使用済みにする(一度きりの使用)
await this.redis.del(`rt:${refreshToken}`);
return data;
}
// トークンファミリー全体を無効化(リプレイ検知時)
async revokeFamily(tokenFamily: string): Promise<void> {
const tokens = await this.redis.smembers(`tf:${tokenFamily}`);
if (tokens.length > 0) {
const keys = tokens.map(t => `rt:${t}`);
await this.redis.del(...keys);
}
await this.redis.del(`tf:${tokenFamily}`);
}
// 特定ユーザーの全トークンを無効化
async revokeAllForUser(userId: string): Promise<void> {
// ユーザーのトークンファミリー一覧を取得して全無効化
const families = await this.redis.smembers(`user_families:${userId}`);
for (const family of families) {
await this.revokeFamily(family);
}
await this.redis.del(`user_families:${userId}`);
}
}
8. スコープ設計
スコープの設計パターン:
resource:action 形式:
→ read:user, write:user, delete:user
→ read:repo, write:repo, admin:repo
GitHub のスコープ例:
→ repo: リポジトリ全般
→ read:user: ユーザープロフィール読取
→ user:email: メールアドレス読取
→ admin:org: 組織管理
設計原則:
→ 最小権限: 必要最小限のスコープのみ要求
→ 粒度: 細かすぎず粗すぎず
→ 命名: resource:action の一貫したパターン
→ ドキュメント: 各スコープの意味を明確に
8.1 スコープ設計の詳細パターン
スコープの階層設計:
粗粒度(GitHub 方式):
repo → リポジトリ全般(読取・書込・削除)
repo:status → コミットステータスのみ
read:user → ユーザー情報読取
user:email → メールアドレスのみ
利点: シンプルでユーザーが理解しやすい
欠点: 細かい制御が困難
中粒度(Google 方式):
https://www.googleapis.com/auth/calendar
https://www.googleapis.com/auth/calendar.readonly
https://www.googleapis.com/auth/gmail.send
https://www.googleapis.com/auth/gmail.readonly
利点: URI で名前空間を明確に
欠点: URL が長い
細粒度(resource:action 方式):
users:read → ユーザー一覧の閲覧
users:write → ユーザー情報の更新
users:delete → ユーザーの削除
posts:read → 記事の閲覧
posts:write → 記事の作成・編集
posts:publish → 記事の公開
admin:settings:read → 管理設定の閲覧
admin:settings:write → 管理設定の変更
利点: 最小権限の原則を徹底
欠点: スコープ数が増大
同意画面での表示:
| MyApp がアクセスを要求しています |
|---|
|
| □ プロフィール情報の閲覧(users:read) |
| □ メールアドレスの読取(users:email) |
| □ リポジトリの読取(repos:read) |
|
| [許可する] [拒否する] |
// スコープベースの認可チェック実装
class ScopeValidator {
// スコープ文字列を配列に変換
static parse(scopeString: string): string[] {
return scopeString.split(' ').filter(Boolean);
}
// 要求されたスコープが付与されたスコープに含まれるか確認
static hasScope(grantedScopes: string[], requiredScope: string): boolean {
// 完全一致チェック
if (grantedScopes.includes(requiredScope)) return true;
// 階層チェック("repo" は "repo:status" を包含)
for (const granted of grantedScopes) {
if (requiredScope.startsWith(granted + ':')) return true;
}
// ワイルドカードチェック
for (const granted of grantedScopes) {
if (granted.endsWith(':*')) {
const prefix = granted.slice(0, -1);
if (requiredScope.startsWith(prefix)) return true;
}
}
return false;
}
// 複数スコープの確認(全て必要)
static hasAllScopes(
grantedScopes: string[],
requiredScopes: string[],
): boolean {
return requiredScopes.every(scope => this.hasScope(grantedScopes, scope));
}
// 複数スコープの確認(いずれか1つ)
static hasAnyScope(
grantedScopes: string[],
requiredScopes: string[],
): boolean {
return requiredScopes.some(scope => this.hasScope(grantedScopes, scope));
}
}
// Express ミドルウェアとして使用
function requireScope(...scopes: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
const tokenScopes = req.tokenPayload?.scope?.split(' ') || [];
if (!ScopeValidator.hasAllScopes(tokenScopes, scopes)) {
return res.status(403).json({
error: 'insufficient_scope',
required_scope: scopes.join(' '),
granted_scope: tokenScopes.join(' '),
});
}
next();
};
}
// 使用例
app.get('/api/repos',
authenticateBearer,
requireScope('repos:read'),
listReposHandler,
);
app.post('/api/repos/:id/deploy',
authenticateBearer,
requireScope('repos:write', 'deploy:execute'),
deployHandler,
);
9. トークン失効(Revocation)
トークン失効の仕組み(RFC 7009):
POST /revoke
Content-Type: application/x-www-form-urlencoded
Authorization: Basic <client_credentials>
token=<access_token or refresh_token>
&token_type_hint=refresh_token
失効が必要なケース:
→ ユーザーがログアウト
→ ユーザーがアプリ連携を解除
→ 管理者がユーザーのアクセスを取り消し
→ パスワード変更時に全トークンを無効化
→ セキュリティインシデント発生時
失効の範囲:
→ refresh_token を失効 → 関連する access_token も失効(推奨)
→ access_token のみ失効 → refresh_token で再取得可能(不十分)
// トークン失効エンドポイントの実装
async function handleRevocation(req: express.Request, res: express.Response) {
const { token, token_type_hint } = req.body;
const { clientId } = extractClientCredentials(req);
// RFC 7009: 成功時は常に 200 OK を返す
// (トークンが存在しない場合も 200 を返す)
res.status(200);
try {
if (token_type_hint === 'refresh_token' || !token_type_hint) {
// リフレッシュトークンの失効を試行
const revoked = await tokenStore.revokeRefreshToken(token, clientId);
if (revoked) {
// 関連するアクセストークンも失効
await tokenStore.revokeAccessTokensByRefreshToken(token);
return res.json({ revoked: true });
}
}
if (token_type_hint === 'access_token' || !token_type_hint) {
// アクセストークンの失効を試行
await tokenStore.revokeAccessToken(token, clientId);
return res.json({ revoked: true });
}
res.json({ revoked: false });
} catch (error) {
// エラーでも 200 OK を返す(RFC 7009 準拠)
res.json({ revoked: false });
}
}
// トークンイントロスペクション(RFC 7662)
async function handleIntrospection(req: express.Request, res: express.Response) {
const { token } = req.body;
const tokenData = await tokenStore.getToken(token);
if (!tokenData || tokenData.revoked || tokenData.expiresAt < new Date()) {
// 無効なトークン
return res.json({ active: false });
}
// 有効なトークン
res.json({
active: true,
scope: tokenData.scope,
client_id: tokenData.clientId,
username: tokenData.username,
token_type: 'Bearer',
exp: Math.floor(tokenData.expiresAt.getTime() / 1000),
iat: Math.floor(tokenData.createdAt.getTime() / 1000),
sub: tokenData.userId,
aud: tokenData.audience,
iss: 'https://auth.example.com',
});
}
10. セキュリティ脅威と対策
OAuth 2.0 に対する主要な攻撃:
| 攻撃 | 対策 |
|---|
| CSRF(認可リクエスト偽造) | state パラメータ |
| 認可コード横取り | PKCE |
| オープンリダイレクタ | redirect_uri の完全一致 |
| トークン漏洩 | 短寿命 + TLS + HttpOnly |
| コード再利用攻撃 | 一度きりの使用 + ファミリー無効化 |
| クリックジャッキング | X-Frame-Options: DENY |
| 混同攻撃(Mix-Up) | iss パラメータ検証 |
| トークン置換 | aud / azp クレーム検証 |
10.1 オープンリダイレクタ攻撃
オープンリダイレクタ攻撃の仕組み:
① 攻撃者が redirect_uri を操作:
/authorize?
client_id=legit-app
&redirect_uri=https://evil.com/steal ← 不正な URI
② 認可サーバーが redirect_uri を検証しない場合:
→ 認可コードが evil.com に送信される
→ 攻撃者がコードを取得
対策:
→ redirect_uri の完全一致検証(部分一致禁止)
→ ワイルドカード禁止
→ 事前登録された URI のみ許可
→ パスの追加やクエリパラメータの変更も拒否
安全な実装:
✗ redirect_uri.startsWith('https://myapp.com')
→ https://myapp.com.evil.com にマッチしてしまう
✗ redirect_uri が登録済みのいずれかで「始まる」
→ https://myapp.com/callback/../../../evil にマッチしうる
✓ redirect_uri === 登録済み URI(完全一致のみ)
10.2 CSRF 攻撃と state パラメータ
OAuth 2.0 における CSRF 攻撃:
攻撃シナリオ(state パラメータなし):
攻撃者 被害者 認可サーバー
│ │ │
│ ① 攻撃者のアカウント │ │
│ で認可コード取得 │ │
│───────────────────────────────────────>│
│ │ │
│ code=ATTACKER_CODE │ │
│<───────────────────────────────────────│
│ │ │
│ ② 被害者に偽の │ │
│ callback URL を │ │
│ 踏ませる │ │
│ myapp.com/callback │ │
│ ?code=ATTACKER_CODE │ │
│───────────────────>│ │
│ │ │
│ │ ③ ATTACKER_CODE │
│ │ で token 取得 │
│ │──────────────────>│
│ │ │
│ │ ④ 攻撃者のアカウントの│
│ │ トークンを使用! │
│ │<──────────────────│
結果:
→ 被害者のアプリが攻撃者のアカウントに紐づく
→ 被害者が攻撃者のアカウントにデータをアップロード
→ 攻撃者がそのデータを取得
対策: state パラメータ
→ 暗号的にランダムな値を生成
→ セッションに紐づけて保存
→ コールバック時に一致確認
→ 一致しなければリクエストを拒否
11. BFF(Backend for Frontend)パターン
BFF パターンの概要:
SPA の OAuth セキュリティ課題:
→ access_token をブラウザに保存 → XSS リスク
→ refresh_token をブラウザに保存 → 漏洩リスク
→ PKCE でも、トークン自体の保護は不十分
BFF パターンの解決策:
→ フロントエンドとバックエンドの間に薄い BFF サーバーを配置
→ BFF が OAuth フローを代行
→ トークンはサーバー側(BFF)のみに保存
→ ブラウザとの通信は HttpOnly Cookie で行う
アーキテクチャ:
| SPA | ──────────> | BFF | ─────────> | API |
|---|
| (React) | <────────── | | <───────── | Server |
│
│ OAuth 2.0
│
BFF の役割:
→ /bff/login → 認可リクエスト開始
→ /bff/callback → コールバック処理 + セッション作成
→ /bff/api/* → API プロキシ(Bearer トークン付与)
→ /bff/logout → セッション破棄 + トークン失効
// BFF パターンの実装
import express from 'express';
import session from 'express-session';
const bff = express();
bff.use(session({
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000,
},
}));
// ログイン開始
bff.get('/bff/login', async (req, res) => {
const { verifier, challenge } = await generatePKCE();
const state = crypto.randomUUID();
// セッションに保存
req.session.pkceVerifier = verifier;
req.session.oauthState = state;
const params = new URLSearchParams({
response_type: 'code',
client_id: process.env.OAUTH_CLIENT_ID!,
redirect_uri: `${process.env.BFF_URL}/bff/callback`,
scope: 'openid profile email',
state,
code_challenge: challenge,
code_challenge_method: 'S256',
});
res.redirect(`${process.env.AUTH_URL}/authorize?${params}`);
});
// コールバック
bff.get('/bff/callback', async (req, res) => {
const { code, state } = req.query as Record<string, string>;
// state 検証
if (state !== req.session.oauthState) {
return res.status(403).json({ error: 'Invalid state' });
}
// トークン交換
const tokenResponse = await fetch(`${process.env.AUTH_URL}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: `${process.env.BFF_URL}/bff/callback`,
client_id: process.env.OAUTH_CLIENT_ID!,
client_secret: process.env.OAUTH_CLIENT_SECRET!, // BFF は Confidential Client
code_verifier: req.session.pkceVerifier,
}),
});
const tokens = await tokenResponse.json();
// トークンをセッションに保存(ブラウザには渡さない!)
req.session.accessToken = tokens.access_token;
req.session.refreshToken = tokens.refresh_token;
req.session.tokenExpiresAt = Date.now() + tokens.expires_in * 1000;
// クリーンアップ
delete req.session.pkceVerifier;
delete req.session.oauthState;
// SPA にリダイレクト
res.redirect(process.env.SPA_URL!);
});
// API プロキシ
bff.all('/bff/api/*', async (req, res) => {
if (!req.session.accessToken) {
return res.status(401).json({ error: 'Not authenticated' });
}
// トークンの期限チェック + リフレッシュ
if (Date.now() > req.session.tokenExpiresAt - 30000) {
try {
const refreshed = await refreshTokens(req.session.refreshToken);
req.session.accessToken = refreshed.access_token;
req.session.refreshToken = refreshed.refresh_token;
req.session.tokenExpiresAt = Date.now() + refreshed.expires_in * 1000;
} catch {
return res.status(401).json({ error: 'Session expired' });
}
}
// API にプロキシ
const apiPath = req.path.replace('/bff/api', '');
const apiResponse = await fetch(`${process.env.API_URL}${apiPath}`, {
method: req.method,
headers: {
'Authorization': `Bearer ${req.session.accessToken}`,
'Content-Type': req.headers['content-type'] || 'application/json',
},
body: ['POST', 'PUT', 'PATCH'].includes(req.method)
? JSON.stringify(req.body)
: undefined,
});
const data = await apiResponse.json();
res.status(apiResponse.status).json(data);
});
// ログアウト
bff.post('/bff/logout', async (req, res) => {
// トークン失効
if (req.session.refreshToken) {
await fetch(`${process.env.AUTH_URL}/revoke`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${Buffer.from(
`${process.env.OAUTH_CLIENT_ID}:${process.env.OAUTH_CLIENT_SECRET}`,
).toString('base64')}`,
},
body: new URLSearchParams({
token: req.session.refreshToken,
token_type_hint: 'refresh_token',
}),
});
}
// セッション破棄
req.session.destroy(() => {
res.clearCookie('connect.sid');
res.json({ success: true });
});
});
12. アンチパターン
OAuth 2.0 のアンチパターン:
① フロントエンドに client_secret を含める:
✗ SPA の JavaScript に client_secret をハードコード
→ ブラウザの開発者ツールで閲覧可能
→ ソースマップから発見可能
対策: Public Client + PKCE を使用
② redirect_uri の部分一致検証:
✗ startsWith や contains で検証
→ オープンリダイレクタ攻撃が可能
対策: 完全一致のみ許可
③ state パラメータの省略:
✗ CSRF 防御なしで OAuth フロー実行
→ 攻撃者のアカウントに紐づけ攻撃が可能
対策: 暗号的にランダムな state を必ず使用
④ アクセストークンの長寿命化:
✗ access_token の有効期限を24時間以上に設定
→ 漏洩時の影響が長期化
対策: 短寿命(15分)+ リフレッシュトークン
⑤ リフレッシュトークンのローテーションなし:
✗ 同じリフレッシュトークンを永続的に使用
→ 漏洩時に永続的なアクセスを許す
対策: ローテーション + リプレイ検知
⑥ Implicit フローの使用:
✗ SPA で response_type=token を使用
→ トークンが URL フラグメントに露出
対策: Authorization Code + PKCE に移行
⑦ スコープの過剰要求:
✗ 必要以上のスコープを要求
→ ユーザーの信頼を損なう
→ 漏洩時の影響範囲が拡大
対策: 最小権限の原則(必要なスコープのみ要求)
⑧ トークンの不適切な保存:
✗ localStorage にアクセストークンを保存
→ XSS で窃取可能
対策: BFF パターン or メモリ内保存 + HttpOnly Cookie
13. エッジケース
OAuth 2.0 のエッジケース:
① スコープのダウングレード:
→ ユーザーが要求された一部のスコープのみ承認
→ クライアントは実際に付与されたスコープを確認すべき
→ レスポンスの scope パラメータで確認
② 認可サーバーのダウンタイム:
→ トークンリフレッシュが失敗
→ 対策: 短期的にキャッシュされたトークンで動作継続
→ access_token の有効期限まではリソースアクセス可能
③ クロックスキュー:
→ JWT の exp/iat 検証時にサーバー間の時刻のずれ
→ 対策: ±30秒の許容範囲(clock skew tolerance)
④ 複数の redirect_uri:
→ 開発環境と本番環境で異なるコールバック URL
→ 認可リクエストの redirect_uri は登録済みのいずれかと完全一致
→ 環境ごとにクライアント ID を分離する方が安全
⑤ トークンの最大サイズ:
→ JWT をアクセストークンとして使用する場合
→ HTTP ヘッダーの制限(通常 8KB)
→ スコープやクレームが多いと超過する可能性
→ 対策: トークンイントロスペクション、参照トークン
14. 演習問題
演習1(基礎): Authorization Code + PKCE フローの実装
以下の要件で OAuth 2.0 クライアントを実装せよ。
要件:
1. PKCE 対応の認可リクエスト生成
2. state パラメータによる CSRF 防御
3. コールバック処理(検証 + トークン交換)
4. トークンの安全な保存
テストケース:
→ code_verifier の長さが 43〜128 文字であること
→ code_challenge が SHA-256 で正しく計算されること
→ state の不一致で例外が発生すること
→ トークン交換後に verifier がクリーンアップされること
演習2(応用): リフレッシュトークン ローテーション
以下の機能を持つリフレッシュトークン管理システムを実装せよ。
要件:
1. リフレッシュトークンのローテーション(毎回新しいトークン)
2. リプレイ検知(使用済みトークンの再利用を検知)
3. トークンファミリーの無効化
4. ユーザー単位の全トークン無効化
テストケース:
→ 正常なリフレッシュフロー
→ 使用済みトークンの再利用 → ファミリー全体が無効化
→ パスワード変更 → 全トークン無効化
演習3(発展): 認可サーバーの構築
OAuth 2.0 認可サーバーを実装せよ。
要件:
1. Authorization Code + PKCE フロー対応
2. Client Credentials フロー対応
3. リフレッシュトークンフロー
4. トークン失効エンドポイント(RFC 7009)
5. トークンイントロスペクション(RFC 7662)
6. クライアント登録管理
セキュリティ要件:
→ 認可コードの一度切り使用
→ redirect_uri の完全一致検証
→ PKCE の S256 検証
→ リフレッシュトークンのローテーション
15. FAQ・トラブルシューティング
Q1: OAuth 2.0 と OpenID Connect の違いは何ですか?
A1: OAuth 2.0 は「認可」、OIDC は「認証」です:
→ OAuth 2.0: 「MyApp に GitHub のリポジトリへのアクセスを許可」
→ OIDC: 「このユーザーは alice@example.com である」
→ OIDC は OAuth 2.0 の上に構築された認証レイヤー
→ OIDC 追加要素: id_token、userinfo エンドポイント、標準クレーム
Q2: PKCE は Confidential Client にも必要ですか?
A2: OAuth 2.1 では全クライアントに PKCE が必須になります:
→ Confidential Client でも追加のセキュリティ層として有効
→ 認可コードの漏洩リスクをさらに低減
→ 実装コストが低い(数十行のコード)ため、常に使用を推奨
Q3: アクセストークンの適切な有効期限は?
A3: ユースケースによります:
→ 一般的な Web アプリ: 15〜60分
→ 金融系: 5〜15分
→ マイクロサービス間: 30〜60分
→ 原則: 短いほど安全、長いほど UX が良い
→ リフレッシュトークンと組み合わせて調整
Q4: SPA でトークンをどこに保存すべき?
A4: 推奨される方法(安全な順):
① BFF パターン(サーバー側セッション)← 最も安全
② メモリ(JavaScript 変数)+ HttpOnly Cookie の refresh
③ sessionStorage(タブ単位で隔離)
✗ localStorage は XSS リスクが高く非推奨
Q5: state パラメータと PKCE の違いは何ですか?
A5: 防御する攻撃が異なります:
→ state: CSRF 攻撃の防御(攻撃者のコードを注入)
→ PKCE: 認可コード横取り攻撃の防御(正規のコードを窃取)
→ 両方を使うことが推奨(防御する脅威が異なるため)
Q6: Device Code フローで user_code の形式に制約はありますか?
A6: RFC 8628 の推奨:
→ 8文字以上の英数字(大文字推奨)
→ ハイフン区切り推奨(例: ABCD-EFGH)
→ 混同しやすい文字を除外(0/O, 1/I/l)
→ 十分なエントロピー(ブルートフォース防止)
Q7: リフレッシュトークンのローテーションで古いトークンが使われたら?
A7: リプレイ攻撃の可能性が高いです:
→ そのトークンファミリー全体を無効化
→ ユーザーに再認証を要求
→ セキュリティアラートを発行
→ 監査ログに記録
FAQ
Q1: このトピックを学ぶ上で最も重要なポイントは何ですか?
実践的な経験を積むことが最も重要です。理論だけでなく、実際にコードを書いて動作を確認することで理解が深まります。
Q2: 初心者がよく陥る間違いは何ですか?
基礎を飛ばして応用に進むことです。このガイドで説明している基本概念をしっかり理解してから、次のステップに進むことをお勧めします。
Q3: 実務ではどのように活用されていますか?
このトピックの知識は、日常的な開発業務で頻繁に活用されます。特にコードレビューやアーキテクチャ設計の際に重要になります。
まとめ
| フロー |
用途 |
セキュリティ |
| Authorization Code |
Web アプリ(サーバーあり) |
最高 |
| Auth Code + PKCE |
SPA、モバイル |
高い |
| Client Credentials |
サーバー間 |
高い |
| Device Code |
IoT、CLI |
中程度 |
| Implicit |
非推奨 |
低い |
| トピック |
ポイント |
| PKCE |
SHA-256 による認可コード横取り防止。OAuth 2.1 で必須 |
| state |
CSRF 防御。暗号的ランダム値をセッションに紐づけ |
| リフレッシュトークン |
ローテーション + リプレイ検知が推奨 |
| スコープ |
最小権限の原則。resource:action 形式 |
| BFF パターン |
SPA の最もセキュアなトークン管理方法 |
| トークン失効 |
RFC 7009。ログアウト時に必ず実行 |
次に読むべきガイド
参考文献
- RFC 6749. "The OAuth 2.0 Authorization Framework." IETF, 2012.
- RFC 7636. "Proof Key for Code Exchange by OAuth Public Clients." IETF, 2015.
- RFC 8628. "OAuth 2.0 Device Authorization Grant." IETF, 2019.
- RFC 7009. "OAuth 2.0 Token Revocation." IETF, 2013.
- RFC 7662. "OAuth 2.0 Token Introspection." IETF, 2015.
- OAuth 2.0 Security Best Current Practice. IETF draft, 2024.
- OAuth 2.1 Authorization Framework. IETF draft, 2024.
- OAuth.net. "OAuth 2.0." oauth.net, 2024.