OAuth 2.1が来た:何が変わり、何が廃止され、アプリをどう移行するか
2024年以前にSPAをデプロイしたことがあるなら、そのOAuth実装はたぶん脆弱です。「理論的に脆弱」とかじゃなくて、普通に攻撃できるレベルで。
Reactチュートリアルでみんなが写経したImplicit Grantフロー? OAuth 2.1で廃止されました。モバイルアプリが使っていたResource Owner Password Credentials(ROPC)フロー? こっちも廃止。 URLクエリストリングにBearerトークンを入れること? 禁止。
OAuth 2.1はマイナーバージョンアップではありません。10年分のセキュリティの失敗を仕様に落とし込んだもので、実際のプロダクションコードが壊れます。今使っているライブラリはすでにOAuth 2.1のデフォルトに切り替わりつつあり、IDプロバイダーはレガシーエンドポイントを閉じ始めています。まだ移行していないなら、正直あまり時間がありません。
この記事では、OAuth 2.1のすべてのBreaking Changeを取り上げ、各決定が_なぜ_行われたのかを説明し(意味のない官僚主義ではないとわかるように)、既存の実装を移行するためのプロダクション対応 TypeScriptコードを提供します。
OAuth 2.1とは何なのか
OAuth 2.1は新しいプロトコルではありません。OAuth 2.0(RFC 6749)に、2012年以降に出たセキュリティベストプラクティスRFCを全部統合したものです。つまりOAuth 2.0に14年分のエラッタ、セキュリティアドバイザリ、「これやっておいてください」という推奨事項をコア仕様に直接組み込んだもの、というわけです。
吸収された主要なRFC:
| RFC | 内容 | OAuth 2.1への影響 |
|---|---|---|
| RFC 7636 | PKCE(Proof Key for Code Exchange) | 全クライアントに義務化 |
| RFC 7009 | トークン失効(Revocation) | コア機能として統合 |
| RFC 8252 | ネイティブアプリのベストプラクティス | ループバックリダイレクトの標準化 |
| RFC 9207 | 認可サーバー発行者識別(Issuer Identification) | Issuer検証が必須に |
| RFC 9126 | Pushed Authorization Requests(PAR) | 高セキュリティフローに推奨 |
| RFC 9449 | DPoP(Demonstration of Proof-of-Possession) | Bearerトークンに代わり推奨 |
実質的な効果:6つの仕様の代わりに1つ読めばよくなりました。 ただし移行コストは現実にあります。何百万ものアプリがまだ使っているフローをOAuth 2.1が_廃止_するからです。
4つのBreaking Change
1. Implicit Grantが廃止されました
廃止されたもの: response_type=tokenフロー全体。
OAuth 2.0では、Implicit Grantはクライアントシークレットを安全に保存できないブラウザアプリ向けに作られました。認可サーバーがURLフラグメントにアクセストークンを直接返す仕組みです(#access_token=...)。シンプルですが、セキュリティ的にはひどいものでした。
なぜ脆弱なのか:
// Implicit Flowの脆弱性チェーン
1. ユーザーが「Googleでログイン」をクリック
2. リダイレクト先: https://auth.example.com/authorize?
response_type=token&
client_id=my-spa&
redirect_uri=https://app.example.com/callback
3. 認証後、コールバックにリダイレクト:
https://app.example.com/callback#access_token=eyJhbGciOi...
// 🚨 問題1: URLフラグメントにトークン露出
// - ブラウザ履歴に残る
// - ユーザーとサーバー間のプロキシ/CDNログに記録
// - ページ上のすべてのJavaScriptからアクセス可能(XSS = 即死)
// 🚨 問題2: トークン受信者の検証が不可能
// - 攻撃者がリダイレクトを傍受してトークンを窃取できる
// - PKCEなし、コード交換なし、検証ステップなし
// 🚨 問題3: リフレッシュトークンなし
// - 短命トークンは常に再認証が必要
// - ユーザーが不満を持ち、開発者がトークン寿命を延長
// - URLフラグメント内の長寿命トークン = セキュリティ悪化
OAuth 2.1の代替手段: PKCEを含むAuthorization Codeフロー。すべてのSPA、モバイルアプリ、Implicitを使っていたすべてのクライアントが移行する必要があります。
// ❌ 以前: Implicit Grant(OAuth 2.1で廃止) const authUrl = new URL('https://auth.example.com/authorize'); authUrl.searchParams.set('response_type', 'token'); // ← 禁止 authUrl.searchParams.set('client_id', CLIENT_ID); authUrl.searchParams.set('redirect_uri', REDIRECT_URI); window.location.href = authUrl.toString(); // ✅ 現在: Authorization Code + PKCE const codeVerifier = generateCodeVerifier(); const codeChallenge = await generateCodeChallenge(codeVerifier); const authUrl = new URL('https://auth.example.com/authorize'); authUrl.searchParams.set('response_type', 'code'); // ← トークンではなくコード authUrl.searchParams.set('client_id', CLIENT_ID); authUrl.searchParams.set('redirect_uri', REDIRECT_URI); authUrl.searchParams.set('code_challenge', codeChallenge); authUrl.searchParams.set('code_challenge_method', 'S256'); window.location.href = authUrl.toString();
2. PKCEがすべてのクライアントに義務化されました
変更点: PKCEはOAuth 2.0ではオプションで、パブリッククライアント(SPA、モバイルアプリ)にのみ推奨されていました。OAuth 2.1では、すでにクライアントシークレットを持つコンフィデンシャルなサーバーサイドアプリケーションを含め、すべてのクライアントタイプに義務化されます。
コンフィデンシャルクライアントにもPKCEが必要な理由:
クライアントシークレットがあっても、リダイレクト時に認可コードが傍受される可能性があります。PKCEは、攻撃者が自分の認可コードを被害者のセッションに注入する認可コードインジェクション攻撃を防ぎます。クライアントシークレットはトークンエンドポイントを保護し、PKCEは認可フローを保護するのです。
PKCEの動作原理:
import { createHash, randomBytes } from 'crypto'; // ステップ1: 暗号学的に安全なランダムコード検証器を生成 function generateCodeVerifier(): string { // 32バイト = base64urlで43文字 return randomBytes(32) .toString('base64url'); } // ステップ2: 検証器からコードチャレンジを生成 async function generateCodeChallenge(verifier: string): Promise<string> { // SHA-256ハッシュの後、base64urlエンコード const hash = createHash('sha256') .update(verifier) .digest(); return Buffer.from(hash).toString('base64url'); } // ステップ3: 認可リクエストに含める const verifier = generateCodeVerifier(); const challenge = await generateCodeChallenge(verifier); // チャレンジを認可サーバーに送信 // 検証器は安全に保存(sessionStorage、localStorageではなく) sessionStorage.setItem('pkce_verifier', verifier); // ステップ4: トークン交換時に検証器を含める async function exchangeCode(code: string): Promise<TokenResponse> { const verifier = sessionStorage.getItem('pkce_verifier'); 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: REDIRECT_URI, client_id: CLIENT_ID, code_verifier: verifier!, // ← 元のリクエストを行ったのは自分だという証明 }), }); return response.json(); }
検証フロー:
Client Auth Server
| |
|-- code_challenge=SHA256(v) ----> | 認可リクエスト
| | (チャレンジを保存)
|<-------- code=abc123 ----------- | 認可レスポンス
| |
|-- code=abc123 -----------------> | トークンリクエスト
| code_verifier=v | (SHA256(v)を計算し
| | 保存したチャレンジと比較)
|<-------- access_token ---------- | トークンレスポンス
| |
// 攻撃者がコードを傍受しても交換できません。
// 元のリクエストで送ったcode_challengeに一致する
// code_verifierを持っていないからです。
3. ROPC(Resource Owner Password Credentials)が廃止されました
廃止されたもの: grant_type=passwordフロー。
ROPCはアプリケーションがユーザーのユーザー名とパスワードを直接受け取り、トークンと交換できるようにするものでした。認可サーバーにリダイレクトできないレガシーアプリの「一時的な移行パス」として作られましたが、実際にはその「一時的」が10年以上居座り、OAuthのセキュリティメリットを全部台無しにする悪習になってしまいました。
なぜ廃止されたのか:
// ❌ ROPC: アプリが生の資格情報を直接処理 const response = await fetch('https://auth.example.com/token', { method: 'POST', body: new URLSearchParams({ grant_type: 'password', // ← OAuth 2.1で廃止 username: '[email protected]', // ← アプリがパスワードを見れる password: 'hunter2', // ← フィッシングリスク、クレデンシャルスタッフィング client_id: CLIENT_ID, }), }); // 🚨 問題点: // 1. アプリがユーザーの生パスワードを保持 — OAuthの存在意義を否定 // 2. MFA非対応 — password grantでは2FA不可 // 3. 同意画面なし — ユーザーがどの権限を付与するか制御不可 // 4. ユーザーにサードパーティアプリにパスワードを入力する習慣をつけてしまう // 5. アプリが侵害されたら全ユーザーのパスワードが漏洩
OAuth 2.1の代替手段はユースケースによって異なります:
| シナリオ | 以前のフロー | 新しいフロー |
|---|---|---|
| ユーザーログイン(Web/モバイル) | ROPC | Authorization Code + PKCE |
| マシン間認証 | ROPC(濫用) | Client Credentials |
| CLIツール認証 | ROPC | Device Authorization(RFC 8628) |
| レガシーシステム移行 | ROPC | Token Exchange(RFC 8693) |
CLI用Device Authorizationフロー:
// ✅ Device Authorization Flow(CLIツールでROPCの代替) async function deviceLogin(): Promise<void> { // ステップ1: デバイスコードをリクエスト const deviceResponse = await fetch('https://auth.example.com/device', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: CLI_CLIENT_ID, scope: 'read write', }), }); const { device_code, user_code, verification_uri, interval } = await deviceResponse.json(); // ステップ2: ユーザーにコードを表示 console.log(`${verification_uri} を開いてコードを入力: ${user_code}`); // ステップ3: 完了までポーリング while (true) { await sleep(interval * 1000); const tokenResponse = await fetch('https://auth.example.com/token', { method: 'POST', body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code, client_id: CLI_CLIENT_ID, }), }); const result = await tokenResponse.json(); if (result.access_token) { saveTokens(result); console.log('認証成功!'); return; } if (result.error === 'expired_token') { throw new Error('ログインが期限切れです。もう一度お試しください。'); } // 'authorization_pending' または 'slow_down' → ポーリング続行 } }
4. リダイレクトURIの厳密なマッチング
変更点: OAuth 2.0では「緩い」リダイレクトURIマッチングが許可されていました。プレフィックスマッチング、ワイルドカードサブドメインなどです。OAuth 2.1では厳密な文字列マッチングが必須です。
// ❌ OAuth 2.0: これらの「緩い」パターンが許可されていた // 登録: https://app.example.com/callback // マッチ: https://app.example.com/callback?foo=bar ← OK // マッチ: https://app.example.com/callback/extra ← OK(プレフィックスマッチ) // マッチ: https://*.example.com/callback ← OK(ワイルドカード) // ✅ OAuth 2.1: 厳密な文字列マッチングのみ // 登録: https://app.example.com/callback // マッチ: https://app.example.com/callback ← OK // 不一致: https://app.example.com/callback?foo=bar ← 拒否 // 不一致: https://app.example.com/callback/ ← 拒否(末尾スラッシュ) // 不一致: https://sub.example.com/callback ← 拒否
なぜ思っている以上に重要なのか:
オープンリダイレクト脆弱性は、最も一般的なOAuth攻撃の一つでした。攻撃者がhttps://evil.com/stealのようなリダイレクトURIを登録し、認可サーバーがプレフィックスマッチングと緩い比較を使っていれば、認可コードを自分のサーバーにリダイレクトできました。
移行チェックリスト:
// リダイレクトURI登録の監査 const redirectUris = { // ❌ 修正が必要なもの: bad: [ 'https://app.example.com/*', // ワイルドカード — 不許可 'https://app.example.com/auth/callback/', // 末尾スラッシュの不一致 'http://localhost:3000/callback', // 本番環境でHTTP ], // ✅ 正しい登録: good: [ 'https://app.example.com/auth/callback', // 厳密マッチ、末尾スラッシュなし 'https://staging.example.com/auth/callback', // ステージング用の個別エントリ 'http://127.0.0.1:3000/callback', // 開発用ループバック(RFC 8252) 'http://[::1]:3000/callback', // 開発用IPv6ループバック ], }; // 注意: 開発環境では、RFC 8252がループバックアドレス(127.0.0.1と[::1])での // HTTPを許可していますが、"localhost"は不可です(DNS解決の問題)
追加のセキュリティ要件
4つのBreaking Changeに加え、OAuth 2.1は複数のセキュリティプラクティスを強化しています:
URLにBearerトークンを入れることの禁止
// ❌ OAuth 2.0ではこれが許可されていた fetch('https://api.example.com/data?access_token=eyJhbGciOi...'); // URL内のトークン = サーバーアクセスログ、プロキシログ、CDNログ、ブラウザ履歴に記録 // ✅ OAuth 2.1: Authorizationヘッダーのみ fetch('https://api.example.com/data', { headers: { 'Authorization': 'Bearer eyJhbGciOi...', }, }); // ✅ またはフォーム送信時のPOSTボディ fetch('https://api.example.com/data', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ access_token: 'eyJhbGciOi...', }), });
リフレッシュトークンローテーション
OAuth 2.1はリフレッシュトークンローテーションを強く推奨しています(事実上必須)。リフレッシュトークンが使用されるたびに、古いトークンは無効化され新しいものが発行されます。
interface TokenStore { accessToken: string; refreshToken: string; expiresAt: number; } async function refreshAccessToken(store: TokenStore): Promise<TokenStore> { const response = await fetch('https://auth.example.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: store.refreshToken, client_id: CLIENT_ID, }), }); if (!response.ok) { // リフレッシュトークンがすでに使用済みまたは失効 // 再認証が必要 throw new AuthenticationRequiredError('セッションが期限切れです'); } const data = await response.json(); return { accessToken: data.access_token, refreshToken: data.refresh_token, // ← 新しいリフレッシュトークン! expiresAt: Date.now() + data.expires_in * 1000, }; } // 🚨 重要: レースコンディションの処理 // 2つのタブが同時にリフレッシュすると、一方は新しいリフレッシュトークンを取得し、 // もう一方は古いトークンが無効化されて失敗します。 // ミューテックスまたはリーダーエレクションパターンを使いましょう: class TokenRefresher { private refreshPromise: Promise<TokenStore> | null = null; async getValidToken(store: TokenStore): Promise<TokenStore> { if (store.expiresAt > Date.now() + 30_000) { return store; // 30秒バッファでまだ有効 } // 同時リフレッシュ試行の重複排除 if (!this.refreshPromise) { this.refreshPromise = refreshAccessToken(store).finally(() => { this.refreshPromise = null; }); } return this.refreshPromise; } }
完全な移行: React SPAの例
Implicit Grantを使っていた典型的なReact SPAの完全なbefore/after移行です:
// === auth.ts — OAuth 2.1準拠の認証モジュール === const AUTH_CONFIG = { authority: 'https://auth.example.com', clientId: 'my-spa-client', redirectUri: 'https://app.example.com/auth/callback', // 厳密マッチ scope: 'openid profile email', tokenEndpoint: 'https://auth.example.com/token', authorizeEndpoint: 'https://auth.example.com/authorize', }; // --- PKCEユーティリティ --- function generateRandomString(length: number): string { const array = new Uint8Array(length); crypto.getRandomValues(array); return btoa(String.fromCharCode(...array)) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); } async function sha256(plain: string): Promise<ArrayBuffer> { const encoder = new TextEncoder(); return crypto.subtle.digest('SHA-256', encoder.encode(plain)); } async function generatePKCE(): Promise<{ verifier: string; challenge: string; }> { const verifier = generateRandomString(32); const hashed = await sha256(verifier); const challenge = btoa(String.fromCharCode(...new Uint8Array(hashed))) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); return { verifier, challenge }; } // --- ログインフロー --- export async function login(): Promise<void> { const { verifier, challenge } = await generatePKCE(); const state = generateRandomString(16); // PKCE検証器とstateをsessionStorageに保存 // (リダイレクトを生き残り、タブを閉じるとクリア) sessionStorage.setItem('oauth_code_verifier', verifier); sessionStorage.setItem('oauth_state', state); const params = new URLSearchParams({ response_type: 'code', client_id: AUTH_CONFIG.clientId, redirect_uri: AUTH_CONFIG.redirectUri, scope: AUTH_CONFIG.scope, state, code_challenge: challenge, code_challenge_method: 'S256', }); window.location.href = `${AUTH_CONFIG.authorizeEndpoint}?${params.toString()}`; } // --- コールバックハンドラー --- export async function handleCallback(): Promise<{ accessToken: string; refreshToken: string; idToken: string; }> { const params = new URLSearchParams(window.location.search); const code = params.get('code'); const state = params.get('state'); // CSRF防止のためstateを検証 const savedState = sessionStorage.getItem('oauth_state'); if (!state || state !== savedState) { throw new Error('stateパラメータが無効です — CSRF攻撃の可能性'); } const verifier = sessionStorage.getItem('oauth_code_verifier'); if (!verifier) { throw new Error('PKCE検証器がありません — ログインフローを再開してください'); } // コードをトークンに交換 const response = await fetch(AUTH_CONFIG.tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code: code!, redirect_uri: AUTH_CONFIG.redirectUri, client_id: AUTH_CONFIG.clientId, code_verifier: verifier, }), }); if (!response.ok) { const error = await response.json(); throw new Error(`トークン交換失敗: ${error.error_description}`); } // クリーンアップ sessionStorage.removeItem('oauth_code_verifier'); sessionStorage.removeItem('oauth_state'); // URLをクリーン window.history.replaceState({}, '', window.location.pathname); return response.json(); } // --- トークン管理 --- export async function refreshToken( currentRefreshToken: string ): Promise<{ accessToken: string; refreshToken: string; }> { const response = await fetch(AUTH_CONFIG.tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: currentRefreshToken, client_id: AUTH_CONFIG.clientId, }), }); if (!response.ok) { // リフレッシュトークンがローテーションされ使用済み、または失効 throw new Error('SESSION_EXPIRED'); } return response.json(); } // --- ログアウト --- export async function logout(idToken: string): Promise<void> { // サーバーサイドでトークンを失効 await fetch('https://auth.example.com/revoke', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ token: idToken, token_type_hint: 'access_token', client_id: AUTH_CONFIG.clientId, }), }); // ローカル状態をクリアしてリダイレクト sessionStorage.clear(); window.location.href = 'https://auth.example.com/logout?' + new URLSearchParams({ id_token_hint: idToken, post_logout_redirect_uri: 'https://app.example.com', }).toString(); }
IDプロバイダー別移行ガイド
主要なIDプロバイダーごとにOAuth 2.1の適用タイムラインが異なります:
Auth0 / Okta
// Auth0 SDK v2+はすでにAuthorization Code + PKCEがデフォルト // 移行: SDKのアップデートとレガシー設定の削除 // ❌ 以前のAuth0設定 const auth0 = new Auth0Client({ domain: 'your-tenant.auth0.com', clientId: 'YOUR_CLIENT_ID', useRefreshTokens: false, // ← Implicitセットアップでよくあった設定 cacheLocation: 'localstorage', // ← リフレッシュトークンなしで必要だった }); // ✅ 新しいAuth0設定(OAuth 2.1準拠) const auth0 = new Auth0Client({ domain: 'your-tenant.auth0.com', clientId: 'YOUR_CLIENT_ID', authorizationParams: { redirect_uri: 'https://app.example.com/callback', // 厳密マッチ }, useRefreshTokens: true, // ← リフレッシュトークンローテーション有効化 cacheLocation: 'memory', // ← メモリキャッシュの方が安全 });
Google OAuth
// Googleは2022年に新規アプリのImplicitフローをすでに非推奨化 // 既存アプリ: 移行期限は異なる // ❌ 以前: Google Sign-In(Implicit) // <script src="https://apis.google.com/js/platform.js"></script> // gapi.auth2.init({ client_id: '...' }) — 廃止 // ✅ 現在: Google Identity Services(Authorization Code + PKCE) // 新しいGISライブラリでコードフローを使用 google.accounts.oauth2.initCodeClient({ client_id: GOOGLE_CLIENT_ID, scope: 'openid profile email', ux_mode: 'redirect', redirect_uri: 'https://app.example.com/auth/google/callback', state: generateState(), });
Microsoft Entra ID(Azure AD)
// MSAL.js v2+はデフォルトでAuthorization Code + PKCE使用 import { PublicClientApplication } from '@azure/msal-browser'; const msalConfig = { auth: { clientId: 'YOUR_CLIENT_ID', authority: 'https://login.microsoftonline.com/YOUR_TENANT_ID', redirectUri: 'https://app.example.com/auth/callback', // 厳密マッチ }, cache: { cacheLocation: 'sessionStorage', // ← localStorageより安全 storeAuthStateInCookie: false, }, }; const msalInstance = new PublicClientApplication(msalConfig); // ログイン — MSAL v2+ではPKCEは自動 await msalInstance.loginRedirect({ scopes: ['openid', 'profile', 'User.Read'], });
OAuth 2.1を超えるセキュリティ強化
OAuth 2.1は最低ラインであって、上限ではありません。機密データを扱うプロダクションアプリでは、以下の追加対策を検討してください:
DPoP(Demonstration of Proof-of-Possession)
DPoPはアクセストークンを特定のクライアントにバインドし、トークン窃取やリプレイ攻撃を防ぎます。誰でも使えるBearerトークンの代わりに、DPoPトークンはクライアントのキーペアに暗号学的にバインドされます。
// DPoP: Proof-of-Possessionトークン async function createDPoPProof( url: string, method: string, accessToken?: string ): Promise<string> { // DPoPキーペアを生成または取得 const keyPair = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign', 'verify'] ); const header = { alg: 'ES256', typ: 'dpop+jwt', jwk: await crypto.subtle.exportKey('jwk', keyPair.publicKey), }; const payload = { jti: crypto.randomUUID(), htm: method, htu: url, iat: Math.floor(Date.now() / 1000), // トークンバインディングのためにアクセストークンハッシュを含める ...(accessToken && { ath: await sha256Base64url(accessToken), }), }; return signJWT(header, payload, keyPair.privateKey); } // 使い方: すべてのAPIリクエストにDPoPプルーフを添付 const dpopProof = await createDPoPProof( 'https://api.example.com/data', 'GET', accessToken ); fetch('https://api.example.com/data', { headers: { 'Authorization': `DPoP ${accessToken}`, 'DPoP': dpopProof, }, });
Pushed Authorization Requests(PAR)
PARはリダイレクト前に認可パラメータをサーバーに直接送ることで、認可リクエストの改ざんを防ぎます:
// PAR: 認可パラメータをまずサーバーサイドにプッシュ async function initiateLoginWithPAR(): Promise<void> { const { verifier, challenge } = await generatePKCE(); const state = generateRandomString(16); // ステップ1: 認可リクエストをサーバーにプッシュ const parResponse = await fetch('https://auth.example.com/par', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: AUTH_CONFIG.clientId, redirect_uri: AUTH_CONFIG.redirectUri, scope: AUTH_CONFIG.scope, response_type: 'code', state, code_challenge: challenge, code_challenge_method: 'S256', }), }); const { request_uri } = await parResponse.json(); // ステップ2: request_uriのみでリダイレクト // (すべてのパラメータはサーバーサイドに保存、URLに露出しない) sessionStorage.setItem('oauth_code_verifier', verifier); sessionStorage.setItem('oauth_state', state); window.location.href = `${AUTH_CONFIG.authorizeEndpoint}?` + `client_id=${AUTH_CONFIG.clientId}&` + `request_uri=${encodeURIComponent(request_uri)}`; }
OAuth 2.1とAIエージェント
OAuth 2.1のあまり議論されない影響の一つが、新興AIエージェントエコシステムにおける役割です。Model Context Protocol(MCP)サーバーやAgent-to-Agent(A2A)プロトコルが成熟するにつれ、OAuth 2.1がAIの委任アクセスのセキュリティ基盤となっています。
// AIエージェントのOAuth: スコープ制限、期限付きアクセス // AIエージェントにユーザーの全権限を与えてはいけません const agentTokenRequest = { grant_type: 'authorization_code', code: authorizationCode, code_verifier: pkceVerifier, client_id: 'ai-agent-client', redirect_uri: 'https://agent.example.com/oauth/callback', // ← AIエージェントには狭いスコープ scope: 'read:emails read:calendar', // 'write:*'は不可 // ← 短命トークンをリクエスト // (エージェントタスクに数日間のアクセスは不要) }; // MCPサーバー認証の場合: // MCP仕様はAIエージェントを通じたサードパーティツールアクセスに // PKCE付きOAuth 2.1を推奨しています。 // これにより、ユーザーはAIエージェントが自分のデータに // アクセスする範囲に明示的に同意することになります。
なぜ重要なのか: GitHubリポ、Slackチャンネル、メールの受信トレイにアクセスするAIエージェントには、暗号学的に検証可能で、スコープが狭く、時間制限のある認可が必要です。環境変数に入れた固定APIキーではなく。PKCE + DPoP付きOAuth 2.1がまさにこれを提供します。
よくある移行ミス
ミス1: PKCE検証器をlocalStorageに保存
// ❌ ダメ: localStorageはセッション間で永続化される localStorage.setItem('pkce_verifier', verifier); // XSS攻撃でこれを読んでコード交換を完了できてしまう // ✅ 良い: sessionStorageはタブを閉じるとクリア sessionStorage.setItem('pkce_verifier', verifier); // XSSには依然として脆弱だが、露出ウィンドウが短い // ✅ ベスト: サーバーサイドのHTTP-onlyセッションクッキーに保存 // (BFF / Backend-for-Frontendパターン用)
ミス2: リフレッシュトークンのレースコンディション未処理
// ❌ ダメ: 2つの同時APIコールが2回のリフレッシュを発火 // タブ1: refresh_token=abc → 新しいrefresh_token=defを取得 // タブ2: refresh_token=abc → 失敗(abcはすでにローテーション済み) // タブ2が不必要にユーザーをログアウトさせてしまう // ✅ 良い: ミューテックスでトークンリフレッシュを一元管理 class TokenManager { private refreshLock: Promise<void> | null = null; async getAccessToken(): Promise<string> { const tokens = this.getStoredTokens(); if (this.isExpired(tokens)) { if (!this.refreshLock) { this.refreshLock = this.doRefresh().finally(() => { this.refreshLock = null; }); } await this.refreshLock; } return this.getStoredTokens().accessToken; } }
ミス3: stateパラメータの無視
// ❌ ダメ: stateパラメータなし = CSRF保護なし const authUrl = `${authEndpoint}?response_type=code&client_id=${clientId}`; // 攻撃者が被害者を攻撃者のアカウントにログインさせるURLを作成できる // ✅ 良い: 常にstateを生成して検証 const state = crypto.randomUUID(); sessionStorage.setItem('oauth_state', state); const authUrl = `${authEndpoint}?response_type=code&client_id=${clientId}&state=${state}`; // コールバックで: コード交換前にstateが一致するか確認
ミス4: 開発環境で「localhost」リダイレクトを使用
// ❌ ダメ: "localhost"はDNSで解決される(ハイジャック可能) const devRedirect = 'http://localhost:3000/callback'; // ✅ 良い: ループバックIPアドレスを使用(RFC 8252) const devRedirect = 'http://127.0.0.1:3000/callback'; // またはIPv6: 'http://[::1]:3000/callback' // DNSなしでローカルに解決され、リダイレクトハイジャックを防止
移行チェックリスト
既存のOAuth実装を監査する際にこのチェックリストを活用してください:
## OAuth 2.1移行チェックリスト ### 緊急(IdPの強制適用前に必ず修正) - [ ] すべての`response_type=token`使用を削除(Implicit Grant) - [ ] すべての`grant_type=password`使用を削除(ROPC) - [ ] すべてのauthorization codeフローにPKCEを追加 - [ ] リダイレクトURIを厳密マッチングに切り替え - [ ] URLクエリストリングのBearerトークンを削除 ### 高優先度 - [ ] リフレッシュトークンローテーションを実装 - [ ] リフレッシュトークンのレースコンディション処理(ミューテックスパターン) - [ ] PKCE検証器をlocalStorage→sessionStorageに変更 - [ ] 開発リダイレクトをlocalhostから127.0.0.1に変更 - [ ] すべての認可リクエストにstateパラメータを追加 ### 推奨 - [ ] 高セキュリティトークンバインディングのためのDPoP実装 - [ ] 機密フローにPAR(Pushed Authorization Requests)を使用 - [ ] AIエージェントトークンのスコープを限定(読み取り専用、時間制限) - [ ] サードパーティライブラリのOAuth 2.1準拠を監査 - [ ] ログアウト時のトークン失効を設定
まとめ
OAuth 2.1は複雑さを追加するものではありません。10年間セキュリティインシデントの原因となってきた危険な仕様を削ぎ落とすアップデートです。Implicit Grantは「楽だから」と使われて実際に穴を開けたショートカットでした。ROPCは「一時的に使うだけ」のはずが10年以上居座りました。ワイルドカードリダイレクトは便利でしたが、結局事故を招きました。
ほとんどのアプリケーションでは、移行は思ったよりシンプルです:
-
Implicit GrantをAuthorization Code + PKCEに置き換えましょう。 これが最もインパクトの大きい変更です。モダンSDK(Auth0、MSAL、Firebase)を使っているなら、SDKバージョンを上げるだけで自動的に対応されることが多いです。
-
ROPCフローを削除しましょう。 適切な代替手段に切り替えてください:ユーザー向けアプリにはAuthorization Code、M2MにはClient Credentials、CLIにはDevice Authorization。
-
リダイレクトURIを監査しましょう。 すべての環境(プロダクション、ステージング、開発)を厳密マッチURIとして登録しましょう。ローカル開発には
localhostではなく127.0.0.1を使いましょう。 -
リフレッシュトークンローテーションを有効化しましょう。 タブ間の同時リフレッシュに対応するミューテックスパターンの実装が必要です。
-
トークンをURLに入れるのはやめましょう。
Authorizationヘッダーのみを使ってください。
IDプロバイダーはすでに動き出しています。Auth0、Okta、Google、Microsoftはレガシーフローを廃止済みか、強制適用のタイムラインを設定済みです。今のうちに自分のペースで移行する方が、認証が400エラーを返し始めてから焦って移行するより、はるかに楽です。
関連ツールを見る
Pockitの無料開発者ツールを試してみましょう