oauth2-authentication について
oauth2-authenticationは、Web、モバイル、APIアプリケーションでOAuth2とOpenID Connectによる認証・認可を実装する開発者向けスキルです。認可フロー、トークン管理、スコープ、安全対策を解説します。
主な機能
- 01複数のOAuth2認可フローを解説
- 02アクセストークンとリフレッシュトークンの管理
- 03OpenID ConnectのIDトークンを解説
- 04スコープによるアクセス制御
- 05stateとPKCEの安全対策
活用シーン
- 01SPAやモバイルアプリのログイン実装
- 02サービス間通信のトークン認可設定
- 03ソーシャルログインやシングルサインオンの統合
- 04スコープを使ったAPIリソースの保護
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ manutej/ luxor-claude-marketplace --skill oauth2-authenticationインストール後、Claude Code で/oauth2-authenticationを入力して読み込まれたか確認します。
同梱ファイルREADME.md
詳細
OAuth2 認証
OAuth2 と OpenID Connect を使用して、安全な認証と認可を実装するための包括的なスキルです。このスキルでは、主要なすべての認可フロー、トークン管理戦略、セキュリティのベストプラクティスに加え、Web、モバイル、API アプリケーション向けの実践的な実装パターンを取り上げます。
このスキルを使用する場面
以下のような場合に、このスキルを使用してください。
- Web アプリケーション、SPA、モバイルアプリでユーザー認証を実装する
- アクセストークンとリフレッシュトークンを使用して API 認可を構築する
- ソーシャルログイン(Google、GitHub、Facebook、Twitter など)を統合する
- 安全なマシン間(M2M)認証を構築する
- 複数のアプリケーション間でシングルサインオン(SSO)を実装する
- OAuth2 認可サーバーまたは ID プロバイダーを構築する
- サードパーティーによるアクセスを可能にする委任認可を追加する
- トークンベース認証で API を保護する
- パスワードレス認証フローを実装する
- 組織ごとのルールを適用したマルチテナント認証を追加する
- セッションベース認証からトークンベース認証に移行する
- OAuth2 スコープを使用してきめ細かなアクセス制御を実装する
基本概念
OAuth2 の基礎
OAuth2 は、HTTP サービス上のユーザーアカウントへの限定的なアクセスをアプリケーションに許可する認可フレームワークです。ユーザーアカウントをホストするサービスにユーザー認証を委任し、サードパーティーアプリケーションがそのアカウントにアクセスすることを許可します。
主な用語:
- リソース所有者:データまたはリソースを所有するユーザー
- クライアント:リソースへのアクセスを要求するアプリケーション(Web アプリ、モバイルアプリ、SPA)
- 認可サーバー:リソース所有者を認証した後、アクセストークンを発行します
- リソースサーバー:保護されたリソースをホストし、アクセストークンを受け付けて検証します
- アクセストークン:保護されたリソースへのアクセスに使用する有効期間の短い認証情報
- リフレッシュトークン:新しいアクセストークンの取得に使用する有効期間の長い認証情報
- スコープ:特定のリソースへのアクセスまたは操作の実行に付与される権限
- 認可コード:アクセストークンとの交換に使用する一時的なコード
- state パラメーター:認可フロー中の CSRF 攻撃を防止します
- リダイレクト URI:認可後にユーザーがリダイレクトされるコールバック URL
OAuth2 のグラントタイプ(認可フロー)
OAuth2 では、さまざまな用途に対応する複数のグラントタイプが定義されています。
1. 認可コードフロー
最も安全なフロー — サーバーサイドアプリケーションに推奨
認可コードフローは、OAuth2 の中で最も安全で、広く使用されているフローです。サーバーサイドで認可コードをアクセストークンと交換します。
フローの手順:
- クライアントは client_id、redirect_uri、scope、state を指定して、ユーザーを認可サーバーにリダイレクトします
- ユーザーが認証を行い、要求された権限に同意します
- 認可サーバーは認可コードを付けてクライアントにリダイレクトします
- クライアントは(サーバーサイドで)クライアントシークレットを使用して、コードをアクセストークンと交換します
- クライアントはアクセストークンを使用して、保護されたリソースにアクセスします
使用する場面:
- 従来型のサーバーサイド Web アプリケーション
- クライアントシークレットを安全に保存できるアプリケーション
- 最大限のセキュリティが必要な場合
- リフレッシュトークンが必要な場合
セキュリティ上の利点:
- アクセストークンがブラウザーに公開されません
- クライアントシークレットによるクライアント認証
- 認可コードは一度しか使用できず、有効期間も短いです
- state パラメーターによって CSRF 攻撃を防止します
2. PKCE を使用した認可コードフロー
パブリッククライアント(SPA およびモバイルアプリ)向けの安全なフロー
PKCE(Proof Key for Code Exchange、「ピクシー」と発音)は、クライアントシークレットを安全に保存できないパブリッククライアント向けに設計された、認可コードフローの拡張機能です。
フローの手順:
- クライアントが code_verifier(ランダムな文字列)と code_challenge(SHA256 ハッシュ)を生成します
- クライアントが code_challenge を付けて認可サーバーにリダイレクトします
- ユーザーが認証し、同意します
- 認可サーバーが認可コードを返します
- クライアントがコードと code_verifier を交換してアクセストークンを取得します
- サーバーが code_verifier と code_challenge が一致することを検証します
使用する場面:
- シングルページアプリケーション(SPA)
- モバイルアプリケーション(iOS、Android)
- デスクトップアプリケーション
- シークレットを保存できないすべてのパブリッククライアント
セキュリティ上の利点:
- 認可コードの傍受攻撃を防ぎます
- クライアントシークレットが不要です
- 悪意のあるアプリによるリダイレクトの傍受から保護します
- OAuth2 のセキュリティに関するベストプラクティス(RFC 8252)で推奨されています
3. クライアントクレデンシャルフロー
マシン間認証
クライアントクレデンシャルフローは、通常はサービス間通信において、クライアント自体がリソースオーナーである場合に使用されます。
フローの手順:
- クライアントが client_id と client_secret で認証します
- 認可サーバーがクレデンシャルを検証します
- 認可サーバーがアクセストークンを発行します
- クライアントがトークンを使用して保護されたリソースにアクセスします
使用する場面:
- API と通信するバックエンドサービス
- cron ジョブやスケジュールされたタスク
- マイクロサービスの認証
- API にアクセスする CI/CD パイプライン
- ユーザーコンテキストを伴わないシステムレベルの操作
特徴:
- ユーザーの関与がありません
- クライアントがリソースオーナーです
- リフレッシュトークンはありません(新しいアクセストークンをリクエストします)
- 通常、トークンの有効期間が長いか、キャッシュされます
4. インプリシットフロー(非推奨)
レガシーフロー — 推奨されなくなりました
インプリシットフローは、認可コードとの交換を行わずに、URL フラグメント内でトークンを直接返します。このフローは現在、安全ではないと見なされているため、使用しないでください。
非推奨の理由:
- アクセストークンがブラウザー履歴に露出します
- クライアント認証がありません
- リフレッシュトークンをサポートしていません
- トークン窃取に対して脆弱です
- 代わりに PKCE を使用した認可コードフローを使用してください
5. リソースオーナーパスワードクレデンシャル(ROPC)
レガシーフロー — 必要な場合を除き使用しないでください
リソースオーナーパスワードクレデンシャルフローでは、クライアントがユーザー名とパスワードを直接収集し、それらをトークンと交換します。
フローの手順:
- ユーザーがクライアントにユーザー名とパスワードを提供します
- クライアントがクレデンシャルを認可サーバーに送信します
- 認可サーバーが検証し、トークンを発行します
使用する場面(まれ):
- レガシー認証から移行するファーストパーティのモバイルアプリ
- 信頼できるファーストパーティアプリケーションのみ
- ブラウザーやリダイレクトフローを利用できない場合
使用を避ける理由:
- クライアントがユーザーのクレデンシャルを直接扱います(セキュリティリスク)
- 多要素認証をサポートしていません
- フィッシングに対して脆弱です
- OAuth2 の委任認可の原則に反します
- 可能な場合は、代わりに PKCE を使用した認可コードフローを使用してください
6. デバイス認可フロー
入力が制限されたデバイス向け
デバイスフローは、入力機能が限られたデバイス(スマートテレビ、IoT デバイス、CLI ツール)向けに設計されています。
フローの手順:
- デバイスが認可サーバーにデバイスコードとユーザーコードをリクエストします
- デバイスがユーザーコードを表示し、URL にアクセスするようユーザーに指示します
- ユーザーが別のデバイスで URL にアクセスし、ユーザーコードを入力します
- ユーザーが認証し、デバイスを認可します
- デバイスがアクセストークンを求めて認可サーバーをポーリングします
- ユーザーが認可を完了すると、認可サーバーがアクセストークンを発行します
使用する場面:
- スマートテレビやストリーミングデバイス
- キーボードのない IoT デバイス
- CLI ツールやコマンドラインアプリケーション
- ゲーム機
- 入力が難しいすべてのデバイス
トークンの種類と管理
アクセストークン
保護されたリソースへのアクセスに使用する短期間有効な認証情報
特徴:
- 通常、15 分から 1 時間で有効期限が切れます
- ベアラートークン形式です:
Authorization: Bearer <access_token> - 不透明なトークンまたは JWT(JSON Web Token)を使用できます
- 機密性の高い認証情報として扱う必要があります
- ログに記録したり、URL に含めたりしないでください
- API リクエストごとに検証してください
JWT の構造(JWT を使用する場合):
Header.Payload.Signature
JWT ペイロードのクレーム:
sub:サブジェクト(ユーザー ID)iat:発行時刻exp:有効期限iss:発行者(認可サーバー)aud:対象(リソースサーバー)scope:付与された権限- カスタムクレーム(ユーザーメタデータ、ロールなど)
トークンの検証:
- 公開鍵を使用して署名を検証します
- 有効期限(exp クレーム)を確認します
- 発行者(iss クレーム)を検証します
- 対象(aud クレーム)を検証します
- トークンに必要なスコープが含まれていることを確認します
リフレッシュトークン
新しいアクセストークンを取得するための長期間有効な認証情報
特徴:
- 通常、数日、数週間、または数か月で有効期限が切れます
- 1 回限り、または再利用可能です(実装によって異なります)
- 安全に保存する必要があります(ブラウザーの localStorage には保存しないでください)
- 保存時には暗号化する必要があります
- 認可サーバーによって取り消される場合があります
- ローテーションポリシーの対象となります
リフレッシュトークンのローテーション:
- リフレッシュのたびに新しいリフレッシュトークンが発行されます
- 古いリフレッシュトークンは無効化されます
- トークンリプレイ攻撃を防ぎます
- トークンの盗難を検出します(複数回のリフレッシュ試行)
- 推奨されるセキュリティ対策です
トークン保存のベストプラクティス:
Web アプリケーション:
- アクセストークン:メモリ(React context、Vuex、Redux)
- リフレッシュトークン:HttpOnly、Secure、SameSite Cookie
- 代替案:リフレッシュトークンをバックエンドに保存し、セッションを使用します
モバイルアプリケーション:
- プラットフォームのセキュアストレージを使用します
- iOS:Keychain Services
- Android:EncryptedSharedPreferences または Keystore
- 平文ファイルには保存しないでください
SPA:
- アクセストークンはメモリ内にのみ保存します
- リフレッシュトークンには BFF(Backend for Frontend)パターンを使用します
- Token Handler パターンの利用を検討します
- localStorage は避けてください(XSS の脆弱性)
ID トークン(OpenID Connect)
ユーザーの ID 情報を含むトークン
特徴:
- 常に JWT 形式です
- ユーザープロフィール情報を含みます
- 認証に使用します(認可には使用しません)
- アクセストークンとともに返されます
- 使用前に検証する必要があります
標準クレーム:
sub:サブジェクト(一意のユーザー ID)name:氏名email:メールアドレスemail_verified:メールアドレスの確認状況picture:プロフィール画像の URLiat、exp:発行時刻/有効期限
OAuth2 スコープ
アクセス制御のためのきめ細かな権限
スコープは、クライアントが要求しているアクセス権と、アクセストークンで許可される操作を定義します。
スコープの命名規則:
read:users- ユーザーデータの読み取りwrite:users- ユーザーの作成/更新delete:users- ユーザーの削除admin:all- 管理者の全権限openid- OpenID Connect の ID トークンを要求profile- ユーザープロフィール情報へのアクセスemail- ユーザーのメールアドレスへのアクセス
ベストプラクティス:
- 必要最小限のスコープを要求します(最小権限の原則)
- 読み取り権限と書き込み権限を分けます
- リソースごとにスコープを作成します
- 利用可能なすべてのスコープを文書化します
- ユーザーが要求された権限を確認し、理解できるようにします
- API にスコープベースのアクセス制御を実装します
動的スコープ:
read:organization:{org_id}
write:project:{project_id}
admin:tenant:{tenant_id}
セキュリティに関する考慮事項
State パラメーター
クロスサイトリクエストフォージェリ(CSRF)を防止します
state パラメーターはランダムな値です。クライアントはこの値を認可リクエストに含め、コールバックで検証します。
実装:
- ランダムな state 値を生成します(暗号学的に安全な方法で)
- state をセッションまたは暗号化された Cookie に保存します
- 認可 URL に state を含めます
- コールバックを受け取ったときに state が一致することを検証します
- state が一致しない場合、または存在しない場合は拒否します
state 値の例:
state: crypto.randomBytes(32).toString('hex')
// "7f8a3d9e2b1c4f5a6d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"
PKCE の実装
Proof Key for Code Exchange - 認可コードの傍受を防止
PKCE は、認可コード傍受攻撃から認可コードフローを保護します。
コード検証子:
- ランダムな文字列(43〜128 文字)
- 使用可能な文字:A-Z、a-z、0-9、-、.、_、~
- クライアント上で安全に保管
コードチャレンジ:
- コード検証子の SHA256 ハッシュ(推奨)
- またはコード検証子の平文(非推奨)
- 認可リクエストで送信
コードチャレンジ方式:
S256:SHA256 ハッシュ(これを使用)plain:検証子の平文(レガシーのみ)
実装例:
// Generate code verifier
const codeVerifier = generateRandomString(64);
// Generate code challenge
const codeChallenge = base64UrlEncode(
sha256(codeVerifier)
);
// Store code verifier for token exchange
sessionStorage.setItem('code_verifier', codeVerifier);
// Include in authorization URL
const authUrl = `${authEndpoint}?` +
`client_id=${clientId}` +
`&redirect_uri=${redirectUri}` +
`&response_type=code` +
`&scope=${scopes}` +
`&state=${state}` +
`&code_challenge=${codeChallenge}` +
`&code_challenge_method=S256`;
トークンのセキュリティ
アクセストークンとリフレッシュトークンの保護
推奨事項:
- すべての OAuth2 エンドポイントで HTTPS を使用する
- リフレッシュトークンは安全なストレージにのみ保管する
- トークンローテーションを実装する
- 適切なトークン有効期限を設定する
- すべての API リクエストでトークンを検証する
- 強力な署名アルゴリズム(RS256、ES256)を使用した JWT を使用する
- トークン失効を実装する
- 不審なトークンの使用を監視する
禁止事項:
- トークンを localStorage に保管する(XSS のリスク)
- URL やクエリパラメーターにトークンを含める
- アプリケーションログにトークンを記録する
- 弱い署名アルゴリズム(共有シークレットを使う HS256)を使用する
- アプリケーション間でトークンを共有する
- アクセストークンの有効期間を不必要に延長する
- トークンの有効期限を無視する
リダイレクト URI の検証
オープンリダイレクトの脆弱性を防止
厳格な検証ルール:
- 完全一致が必須(ワイルドカード不可)
- プロトコルが完全に一致する必要がある(https://)
- ホストが完全に一致する必要がある
- ポートが一致する必要がある(指定されている場合)
- パスが一致する必要がある(指定されている場合)
- すべてのリダイレクト URI を事前に登録する
モバイルのディープリンク:
- カスタム URL スキームを使用する:
com.example.app://callback - またはユニバーサルリンク(iOS)を使用する:
https://example.com/auth/callback - またはアプリリンク(Android)を使用する:
https://example.com/auth/callback - 認可サーバーにスキームを登録する
localhost での開発:
- 開発時のみ http://localhost を許可する
- ポートを正確に指定する:
http://localhost:3000/callback - localhost が動作しない場合は 127.0.0.1 を使用する
- 本番環境では localhost へのリダイレクトを絶対に使用しない
OpenID Connect(OIDC)
OAuth2 を基盤とする ID レイヤー
OpenID Connect は OAuth2 に ID レイヤーを追加し、認可に加えて認証を提供します。
OAuth2 との主な違い:
- アクセストークンに加えて ID トークンを返す
- ID トークンにユーザーの ID 情報が含まれる
- ユーザー情報エンドポイントが標準化されている
- ディスカバリーエンドポイント(.well-known/openid-configuration)が標準化されている
- セッション管理機能がある
OIDC フロー:
-
認可コードフロー(推奨)
- OAuth2 と同じですが、id_token を返します
- Web アプリにとって最も安全です
-
インプリシットフロー(非推奨)
- id_token を直接返します
- 安全ではないため、代わりに PKCE を使うコードフローを使用してください
-
ハイブリッドフロー
- コードフローとインプリシットフローを組み合わせます
- 複雑で、必要となることはほとんどありません
OIDC スコープ:
openid(必須)- OIDC を有効にしますprofile- 名前、画像、ロケールなどemail- メールアドレスと確認状況address- 住所phone- 電話番号
UserInfo エンドポイント:
GET /userinfo
Authorization: Bearer <access_token>
Response:
{
"sub": "248289761001",
"name": "Jane Doe",
"email": "[email protected]",
"email_verified": true,
"picture": "https://example.com/photo.jpg"
}
ID トークンの検証:
- プロバイダーの公開鍵を使って署名を検証する
- 発行者(iss クレーム)を検証する
- オーディエンス(aud クレーム。client_id と一致する必要があります)を検証する
- 有効期限(exp クレーム)を確認する
- nonce を検証する(リクエストに含まれている場合)
- トークンが最近発行されたことを確認する(iat クレーム)
マルチテナンシーパターン
組織固有の認証
多くの SaaS アプリケーションでは、ユーザーが組織またはテナントのコンテキスト内で認証する必要があります。
パターン:
-
組織パラメーター
- 認可リクエストに組織 ID を含めます
scope=openid profile organization:acme-corp- トークンを特定の組織にスコープ設定します
-
組織セレクター
- 初回認証後にユーザーが組織を選択します
- トークンを組織固有のトークンと交換します
- 組織の切り替えに対応します
-
テナントごとのカスタムドメイン
acme.example.comとglobex.example.com- テナントごとに個別の OAuth2 設定を使用します
- ホワイトラベルの認証エクスペリエンスを提供します
-
トークンクレーム内の組織情報
- アクセストークンに org_id を含めます
- API で組織へのアクセスを検証します
- 複数の組織に所属するユーザーに対応します
認可コードフローの実装
サーバーサイド Web アプリケーションのフロー
バックエンドを備えた従来の Web アプリケーション向けの完全な実装
ステップ 1:設定
// OAuth2 Configuration
const oauth2Config = {
clientId: process.env.OAUTH2_CLIENT_ID,
clientSecret: process.env.OAUTH2_CLIENT_SECRET,
authorizationEndpoint: 'https://auth.example.com/oauth/authorize',
tokenEndpoint: 'https://auth.example.com/oauth/token',
redirectUri: 'https://yourapp.com/auth/callback',
scopes: ['openid', 'profile', 'email', 'read:data'],
// Optional OIDC endpoints
userInfoEndpoint: 'https://auth.example.com/oauth/userinfo',
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
// Security settings
useStateParameter: true,
usePKCE: false, // Not needed for confidential clients
};
ステップ 2:認可 URL を生成する
const crypto = require('crypto');
function generateAuthorizationUrl(req) {
// Generate and store state for CSRF protection
const state = crypto.randomBytes(32).toString('hex');
req.session.oauth2State = state;
// Build authorization URL
const params = new URLSearchParams({
client_id: oauth2Config.clientId,
redirect_uri: oauth2Config.redirectUri,
response_type: 'code',
scope: oauth2Config.scopes.join(' '),
state: state,
});
return `${oauth2Config.authorizationEndpoint}?${params.toString()}`;
}
// Express.js route
app.get('/auth/login', (req, res) => {
const authUrl = generateAuthorizationUrl(req);
res.redirect(authUrl);
});
ステップ 3:コールバックを処理してコードを交換する
const axios = require('axios');
async function exchangeCodeForToken(code) {
const response = await axios.post(
oauth2Config.tokenEndpoint,
new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: oauth2Config.redirectUri,
client_id: oauth2Config.clientId,
client_secret: oauth2Config.clientSecret,
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
return response.data;
// Returns: { access_token, refresh_token, token_type, expires_in, id_token }
}
// Callback route
app.get('/auth/callback', async (req, res) => {
const { code, state, error, error_description } = req.query;
// Check for authorization errors
if (error) {
console.error('Authorization error:', error, error_description);
return res.redirect('/auth/error?message=' + error_description);
}
// Validate state parameter (CSRF protection)
if (state !== req.session.oauth2State) {
console.error('State mismatch - possible CSRF attack');
return res.status(403).send('Invalid state parameter');
}
// Clear state from session
delete req.session.oauth2State;
try {
// Exchange authorization code for tokens
const tokens = await exchangeCodeForToken(code);
// Store tokens securely in session
req.session.accessToken = tokens.access_token;
req.session.refreshToken = tokens.refresh_token;
req.session.tokenExpiry = Date.now() + (tokens.expires_in * 1000);
// Optional: Fetch user info
if (tokens.id_token) {
const userInfo = await getUserInfo(tokens.access_token);
req.session.user = userInfo;
}
// Redirect to application
res.redirect('/dashboard');
} catch (error) {
console.error('Token exchange failed:', error);
res.redirect('/auth/error?message=Authentication failed');
}
});
ステップ 4:API リクエストでアクセストークンを使用する
// Middleware to check authentication
function requireAuth(req, res, next) {
if (!req.session.accessToken) {
return res.redirect('/auth/login');
}
// Check if token is expired
if (Date.now() > req.session.tokenExpiry) {
// Token expired, try to refresh
return refreshAccessToken(req, res, next);
}
next();
}
// API request with access token
async function fetchUserData(accessToken) {
const response = await axios.get('https://api.example.com/user/data', {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
return response.data;
}
// Protected route
app.get('/dashboard', requireAuth, async (req, res) => {
try {
const userData = await fetchUserData(req.session.accessToken);
res.render('dashboard', { user: req.session.user, data: userData });
} catch (error) {
console.error('API request failed:', error);
res.status(500).send('Failed to fetch data');
}
});
ステップ 5:トークンの更新を実装する
async function refreshAccessToken(req, res, next) {
if (!req.session.refreshToken) {
// No refresh token, require re-authentication
return res.redirect('/auth/login');
}
try {
const response = await axios.post(
oauth2Config.tokenEndpoint,
new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: req.session.refreshToken,
client_id: oauth2Config.clientId,
client_secret: oauth2Config.clientSecret,
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
// Update tokens in session
req.session.accessToken = response.data.access_token;
req.session.tokenExpiry = Date.now() + (response.data.expires_in * 1000);
// Update refresh token if rotation is enabled
if (response.data.refresh_token) {
req.session.refreshToken = response.data.refresh_token;
}
next();
} catch (error) {
console.error('Token refresh failed:', error);
// Refresh failed, require re-authentication
delete req.session.accessToken;
delete req.session.refreshToken;
res.redirect('/auth/login');
}
}
ステップ 6:ログアウトを実装する
app.post('/auth/logout', async (req, res) => {
// Optional: Revoke tokens on authorization server
if (req.session.accessToken) {
try {
await revokeToken(req.session.accessToken);
} catch (error) {
console.error('Token revocation failed:', error);
}
}
// Clear session
req.session.destroy((err) => {
if (err) {
console.error('Session destruction failed:', err);
}
res.redirect('/');
});
});
async function revokeToken(token) {
await axios.post(
'https://auth.example.com/oauth/revoke',
new URLSearchParams({
token: token,
client_id: oauth2Config.clientId,
client_secret: oauth2Config.clientSecret,
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
}
PKCE の実装(SPA とモバイルアプリ)
シングルページアプリケーション(React)
React SPA 向けの PKCE を使用した OAuth2 の完全な実装
セットアップと設定
// src/config/oauth2.js
export const oauth2Config = {
clientId: process.env.REACT_APP_OAUTH2_CLIENT_ID,
authorizationEndpoint: 'https://auth.example.com/oauth/authorize',
tokenEndpoint: 'https://auth.example.com/oauth/token',
redirectUri: window.location.origin + '/auth/callback',
scopes: ['openid', 'profile', 'email', 'read:data'],
audience: 'https://api.example.com',
};
// PKCE utility functions
export function generateRandomString(length) {
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
const values = crypto.getRandomValues(new Uint8Array(length));
return Array.from(values)
.map(v => charset[v % charset.length])
.join('');
}
export async function generateCodeChallenge(codeVerifier) {
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(digest);
}
export function base64UrlEncode(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
認証コンテキストプロバイダー
// src/contexts/AuthContext.js
import React, { createContext, useState, useContext, useEffect } from 'react';
import { oauth2Config, generateRandomString, generateCodeChallenge } from '../config/oauth2';
const AuthContext = createContext();
export function useAuth() {
return useContext(AuthContext);
}
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [accessToken, setAccessToken] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Check for existing session
checkAuth();
}, []);
async function checkAuth() {
// Try to restore access token from memory or refresh
const storedToken = sessionStorage.getItem('access_token');
const expiresAt = sessionStorage.getItem('expires_at');
if (storedToken && expiresAt && Date.now() < parseInt(expiresAt)) {
setAccessToken(storedToken);
await fetchUserInfo(storedToken);
} else {
// Token expired or doesn't exist, try refresh
await tryRefresh();
}
setLoading(false);
}
async function login() {
// Generate PKCE parameters
const codeVerifier = generateRandomString(64);
const codeChallenge = await generateCodeChallenge(codeVerifier);
const state = generateRandomString(32);
// Store for callback
sessionStorage.setItem('code_verifier', codeVerifier);
sessionStorage.setItem('oauth2_state', state);
// Build authorization URL
const params = new URLSearchParams({
client_id: oauth2Config.clientId,
redirect_uri: oauth2Config.redirectUri,
response_type: 'code',
scope: oauth2Config.scopes.join(' '),
state: state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
// Redirect to authorization server
window.location.href = `${oauth2Config.authorizationEndpoint}?${params}`;
}
async function handleCallback(code, state) {
// Validate state
const savedState = sessionStorage.getItem('oauth2_state');
if (state !== savedState) {
throw new Error('Invalid state parameter - possible CSRF attack');
}
// Get code verifier
const codeVerifier = sessionStorage.getItem('code_verifier');
if (!codeVerifier) {
throw new Error('Code verifier not found');
}
// Exchange code for tokens
const response = await fetch(oauth2Config.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: oauth2Config.redirectUri,
client_id: oauth2Config.clientId,
code_verifier: codeVerifier,
}),
});
if (!response.ok) {
throw new Error('Token exchange failed');
}
const tokens = await response.json();
// Store tokens
setAccessToken(tokens.access_token);
sessionStorage.setItem('access_token', tokens.access_token);
sessionStorage.setItem('expires_at', Date.now() + (tokens.expires_in * 1000));
// Store refresh token in httpOnly cookie via backend
if (tokens.refresh_token) {
await storeRefreshToken(tokens.refresh_token);
}
// Fetch user info
await fetchUserInfo(tokens.access_token);
// Clean up
sessionStorage.removeItem('code_verifier');
sessionStorage.removeItem('oauth2_state');
}
async function fetchUserInfo(token) {
const response = await fetch('https://auth.example.com/oauth/userinfo', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
const userInfo = await response.json();
setUser(userInfo);
}
}
async function storeRefreshToken(refreshToken) {
// Store refresh token via backend (httpOnly cookie)
await fetch('/api/auth/store-refresh-token', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refreshToken }),
});
}
async function tryRefresh() {
try {
// Call backend to refresh using httpOnly cookie
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include',
});
if (response.ok) {
const tokens = await response.json();
setAccessToken(tokens.access_token);
sessionStorage.setItem('access_token', tokens.access_token);
sessionStorage.setItem('expires_at', Date.now() + (tokens.expires_in * 1000));
await fetchUserInfo(tokens.access_token);
return true;
}
} catch (error) {
console.error('Token refresh failed:', error);
}
return false;
}
async function logout() {
// Revoke tokens
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
});
// Clear state
setUser(null);
setAccessToken(null);
sessionStorage.clear();
}
const value = {
user,
accessToken,
loading,
login,
logout,
handleCallback,
isAuthenticated: !!accessToken,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
コールバックコンポーネント
// src/components/AuthCallback.js
import React, { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
export function AuthCallback() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { handleCallback } = useAuth();
const [error, setError] = useState(null);
useEffect(() => {
const code = searchParams.get('code');
const state = searchParams.get('state');
const errorParam = searchParams.get('error');
const errorDescription = searchParams.get('error_description');
if (errorParam) {
setError(errorDescription || errorParam);
return;
}
if (code && state) {
handleCallback(code, state)
.then(() => {
navigate('/dashboard');
})
.catch((err) => {
console.error('Authentication failed:', err);
setError(err.message);
});
} else {
setError('Missing authorization code or state');
}
}, [searchParams, handleCallback, navigate]);
if (error) {
return (
<div className="auth-error">
<h2>Authentication Failed</h2>
<p>{error}</p>
<button onClick={() => navigate('/')}>Return Home</button>
</div>
);
}
return (
<div className="auth-loading">
<h2>Completing authentication...</h2>
<div className="spinner"></div>
</div>
);
}
保護されたルートコンポーネント
// src/components/ProtectedRoute.js
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
export function ProtectedRoute({ children }) {
const { isAuthenticated, loading } = useAuth();
if (loading) {
return <div>Loading...</div>;
}
if (!isAuthenticated) {
return <Navigate to="/login" />;
}
return children;
}
トークン管理機能を備えた API クライアント
// src/utils/apiClient.js
import { oauth2Config } from '../config/oauth2';
class ApiClient {
constructor() {
this.baseUrl = 'https://api.example.com';
}
async request(endpoint, options = {}) {
const accessToken = sessionStorage.getItem('access_token');
const expiresAt = sessionStorage.getItem('expires_at');
// Check if token needs refresh
if (!accessToken || Date.now() >= parseInt(expiresAt)) {
await this.refreshToken();
}
const token = sessionStorage.getItem('access_token');
const response = await fetch(`${this.baseUrl}${endpoint}`, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (response.status === 401) {
// Token invalid, try refresh once
await this.refreshToken();
const newToken = sessionStorage.getItem('access_token');
// Retry request
const retryResponse = await fetch(`${this.baseUrl}${endpoint}`, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${newToken}`,
'Content-Type': 'application/json',
},
});
if (!retryResponse.ok) {
throw new Error('API request failed after token refresh');
}
return retryResponse.json();
}
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json();
}
async refreshToken() {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include',
});
if (!response.ok) {
// Refresh failed, redirect to login
window.location.href = '/login';
throw new Error('Token refresh failed');
}
const tokens = await response.json();
sessionStorage.setItem('access_token', tokens.access_token);
sessionStorage.setItem('expires_at', Date.now() + (tokens.expires_in * 1000));
}
async get(endpoint) {
return this.request(endpoint);
}
async post(endpoint, data) {
return this.request(endpoint, {
method: 'POST',
body: JSON.stringify(data),
});
}
async put(endpoint, data) {
return this.request(endpoint, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async delete(endpoint) {
return this.request(endpoint, {
method: 'DELETE',
});
}
}
export const apiClient = new ApiClient();
クライアントクレデンシャルフロー
バックエンドサービスの認証
サービスおよび API 向けのマシン間認証
Node.js の実装
// Service-to-service authentication
class OAuth2Client {
constructor(config) {
this.clientId = config.clientId;
this.clientSecret = config.clientSecret;
this.tokenEndpoint = config.tokenEndpoint;
this.audience = config.audience;
this.scopes = config.scopes || [];
this.accessToken = null;
this.tokenExpiry = null;
}
async getAccessToken() {
// Return cached token if still valid
if (this.accessToken && Date.now() < this.tokenExpiry - 60000) {
return this.accessToken;
}
// Request new token
const response = await fetch(this.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: this.clientId,
client_secret: this.clientSecret,
audience: this.audience,
scope: this.scopes.join(' '),
}),
});
if (!response.ok) {
throw new Error('Failed to obtain access token');
}
const data = await response.json();
// Cache token
this.accessToken = data.access_token;
this.tokenExpiry = Date.now() + (data.expires_in * 1000);
return this.accessToken;
}
async callApi(endpoint, options = {}) {
const token = await this.getAccessToken();
const response = await fetch(endpoint, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`,
},
});
if (response.status === 401) {
// Token might be invalid, force refresh and retry
this.accessToken = null;
const newToken = await this.getAccessToken();
const retryResponse = await fetch(endpoint, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${newToken}`,
},
});
return retryResponse;
}
return response;
}
}
// Usage
const oauth2Client = new OAuth2Client({
clientId: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET,
tokenEndpoint: 'https://auth.example.com/oauth/token',
audience: 'https://api.example.com',
scopes: ['read:data', 'write:data'],
});
// Make API calls
async function fetchData() {
const response = await oauth2Client.callApi('https://api.example.com/data');
return response.json();
}
Python の実装
# client_credentials_oauth2.py
import requests
import time
from datetime import datetime, timedelta
class OAuth2Client:
def __init__(self, client_id, client_secret, token_endpoint, audience=None, scopes=None):
self.client_id = client_id
self.client_secret = client_secret
self.token_endpoint = token_endpoint
self.audience = audience
self.scopes = scopes or []
self.access_token = None
self.token_expiry = None
def get_access_token(self):
"""Get cached token or request new one"""
# Return cached token if valid
if self.access_token and datetime.now() < self.token_expiry - timedelta(minutes=1):
return self.access_token
# Request new token
data = {
'grant_type': 'client_credentials',
'client_id': self.client_id,
'client_secret': self.client_secret,
}
if self.audience:
data['audience'] = self.audience
if self.scopes:
data['scope'] = ' '.join(self.scopes)
response = requests.post(
self.token_endpoint,
data=data,
headers={'Content-Type': 'application/x-www-form-urlencoded'}
)
if not response.ok:
raise Exception(f'Failed to obtain access token: {response.text}')
token_data = response.json()
# Cache token
self.access_token = token_data['access_token']
self.token_expiry = datetime.now() + timedelta(seconds=token_data['expires_in'])
return self.access_token
def call_api(self, url, method='GET', **kwargs):
"""Make authenticated API request"""
token = self.get_access_token()
headers = kwargs.pop('headers', {})
headers['Authorization'] = f'Bearer {token}'
response = requests.request(method, url, headers=headers, **kwargs)
# Handle token expiration
if response.status_code == 401:
# Force token refresh and retry
self.access_token = None
token = self.get_access_token()
headers['Authorization'] = f'Bearer {token}'
response = requests.request(method, url, headers=headers, **kwargs)
return response
# Usage
client = OAuth2Client(
client_id='your_client_id',
client_secret='your_client_secret',
token_endpoint='https://auth.example.com/oauth/token',
audience='https://api.example.com',
scopes=['read:data', 'write:data']
)
# Make API requests
response = client.call_api('https://api.example.com/data')
data = response.json()
OpenID Connect の実装
OIDC の完全な統合
ID 検証を伴う認証
ID トークンの検証
// id-token-validator.js
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');
class IDTokenValidator {
constructor(config) {
this.issuer = config.issuer;
this.audience = config.audience;
this.jwksUri = config.jwksUri;
// JWKS client for fetching public keys
this.client = jwksClient({
jwksUri: this.jwksUri,
cache: true,
cacheMaxAge: 86400000, // 24 hours
});
}
async getSigningKey(kid) {
const key = await this.client.getSigningKey(kid);
return key.getPublicKey();
}
async validate(idToken) {
try {
// Decode token header to get key ID
const decoded = jwt.decode(idToken, { complete: true });
if (!decoded) {
throw new Error('Invalid token format');
}
// Get public key
const publicKey = await this.getSigningKey(decoded.header.kid);
// Verify and decode token
const payload = jwt.verify(idToken, publicKey, {
issuer: this.issuer,
audience: this.audience,
algorithms: ['RS256', 'ES256'],
});
// Additional validations
this.validateClaims(payload);
return payload;
} catch (error) {
throw new Error(`ID token validation failed: ${error.message}`);
}
}
validateClaims(payload) {
// Check required claims
if (!payload.sub) {
throw new Error('Missing sub claim');
}
if (!payload.iat || !payload.exp) {
throw new Error('Missing iat or exp claim');
}
// Check token is not expired (already checked by jwt.verify, but double-check)
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) {
throw new Error('Token expired');
}
// Check token was not issued in the future
if (payload.iat > now + 60) {
throw new Error('Token issued in the future');
}
// Optional: Check nonce if provided
// if (payload.nonce && payload.nonce !== expectedNonce) {
// throw new Error('Nonce mismatch');
// }
return true;
}
}
// Usage
const validator = new IDTokenValidator({
issuer: 'https://auth.example.com',
audience: 'your_client_id',
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
});
async function validateIDToken(idToken) {
try {
const payload = await validator.validate(idToken);
console.log('User ID:', payload.sub);
console.log('Email:', payload.email);
console.log('Name:', payload.name);
return payload;
} catch (error) {
console.error('ID token validation failed:', error);
throw error;
}
}
UserInfo エンドポイントの統合
// userinfo-client.js
class UserInfoClient {
constructor(userInfoEndpoint) {
this.endpoint = userInfoEndpoint;
}
async getUserInfo(accessToken) {
const response = await fetch(this.endpoint, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/json',
},
});
if (!response.ok) {
throw new Error(`UserInfo request failed: ${response.status}`);
}
return response.json();
}
async getUserInfoWithValidation(accessToken, idTokenPayload) {
const userInfo = await this.getUserInfo(accessToken);
// Validate that sub matches ID token
if (userInfo.sub !== idTokenPayload.sub) {
throw new Error('UserInfo sub does not match ID token sub');
}
return userInfo;
}
}
// Usage
const userInfoClient = new UserInfoClient('https://auth.example.com/oauth/userinfo');
async function getCompleteUserProfile(accessToken, idToken, validator) {
// Validate ID token
const idTokenPayload = await validator.validate(idToken);
// Fetch additional user info
const userInfo = await userInfoClient.getUserInfoWithValidation(accessToken, idTokenPayload);
// Combine information
return {
userId: userInfo.sub,
email: userInfo.email,
emailVerified: userInfo.email_verified,
name: userInfo.name,
givenName: userInfo.given_name,
familyName: userInfo.family_name,
picture: userInfo.picture,
locale: userInfo.locale,
// Any custom claims
...userInfo,
};
}
トークン管理のベストプラクティス
安全なトークン保存
ブラウザー内ストレージの比較
// Token storage strategies for web applications
// ❌ BAD: localStorage (vulnerable to XSS)
localStorage.setItem('access_token', token); // DON'T DO THIS
// ❌ BAD: sessionStorage (also vulnerable to XSS)
sessionStorage.setItem('access_token', token); // DON'T DO THIS
// ✅ GOOD: In-memory storage (cleared on page refresh)
class TokenStore {
constructor() {
this.accessToken = null;
this.refreshToken = null;
}
setTokens(accessToken, refreshToken) {
this.accessToken = accessToken;
// Refresh token stored in httpOnly cookie via backend
// this.refreshToken = refreshToken; // Don't store in memory
}
getAccessToken() {
return this.accessToken;
}
clear() {
this.accessToken = null;
this.refreshToken = null;
}
}
// ✅ BEST: Backend For Frontend (BFF) Pattern
// - All tokens stored on backend
// - Session cookie identifies user
// - No tokens exposed to browser
モバイルのトークン保存(React Native)
// Secure token storage for React Native
import * as SecureStore from 'expo-secure-store';
// or
// import EncryptedStorage from 'react-native-encrypted-storage';
class MobileTokenStore {
async saveTokens(accessToken, refreshToken) {
try {
await SecureStore.setItemAsync('access_token', accessToken);
await SecureStore.setItemAsync('refresh_token', refreshToken);
await SecureStore.setItemAsync('token_expiry', Date.now().toString());
} catch (error) {
console.error('Failed to store tokens:', error);
throw error;
}
}
async getAccessToken() {
try {
return await SecureStore.getItemAsync('access_token');
} catch (error) {
console.error('Failed to retrieve access token:', error);
return null;
}
}
async getRefreshToken() {
try {
return await SecureStore.getItemAsync('refresh_token');
} catch (error) {
console.error('Failed to retrieve refresh token:', error);
return null;
}
}
async clear() {
try {
await SecureStore.deleteItemAsync('access_token');
await SecureStore.deleteItemAsync('refresh_token');
await SecureStore.deleteItemAsync('token_expiry');
} catch (error) {
console.error('Failed to clear tokens:', error);
}
}
async isTokenExpired() {
try {
const expiry = await SecureStore.getItemAsync('token_expiry');
if (!expiry) return true;
return Date.now() > parseInt(expiry);
} catch (error) {
return true;
}
}
}
export const tokenStore = new MobileTokenStore();
トークン更新戦略
自動トークン更新(事前)
// Proactive token refresh before expiration
class TokenRefreshManager {
constructor(refreshCallback, expiresIn) {
this.refreshCallback = refreshCallback;
this.expiresIn = expiresIn;
this.refreshTimer = null;
}
scheduleRefresh() {
// Refresh 5 minutes before expiration
const refreshIn = (this.expiresIn - 300) * 1000;
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
}
this.refreshTimer = setTimeout(async () => {
try {
await this.refreshCallback();
} catch (error) {
console.error('Token refresh failed:', error);
// Optionally: Redirect to login
}
}, refreshIn);
}
cancelRefresh() {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
}
}
// Usage
const refreshManager = new TokenRefreshManager(
async () => {
// Refresh token logic
const newTokens = await refreshTokens();
setAccessToken(newTokens.access_token);
refreshManager.expiresIn = newTokens.expires_in;
refreshManager.scheduleRefresh();
},
3600 // Initial expires_in
);
// Start automatic refresh
refreshManager.scheduleRefresh();
オンデマンドのトークン更新(事後)
// Refresh token only when access token is expired
class ReactiveTokenManager {
constructor() {
this.accessToken = null;
this.expiresAt = null;
this.refreshPromise = null; // Prevent concurrent refreshes
}
async getValidToken() {
// Check if token is still valid
if (this.accessToken && Date.now() < this.expiresAt - 60000) {
return this.accessToken;
}
// Token expired, refresh it
if (!this.refreshPromise) {
this.refreshPromise = this.refreshToken()
.finally(() => {
this.refreshPromise = null;
});
}
await this.refreshPromise;
return this.accessToken;
}
async refreshToken() {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include', // Send refresh token cookie
});
if (!response.ok) {
throw new Error('Token refresh failed');
}
const tokens = await response.json();
this.accessToken = tokens.access_token;
this.expiresAt = Date.now() + (tokens.expires_in * 1000);
return this.accessToken;
}
clearTokens() {
this.accessToken = null;
this.expiresAt = null;
this.refreshPromise = null;
}
}
// Usage in API client
const tokenManager = new ReactiveTokenManager();
async function makeApiRequest(endpoint) {
const token = await tokenManager.getValidToken();
const response = await fetch(endpoint, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
return response;
}
トークンの失効
// Implementing token revocation
async function revokeToken(token, tokenTypeHint = 'access_token') {
const response = await fetch('https://auth.example.com/oauth/revoke', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
token: token,
token_type_hint: tokenTypeHint, // 'access_token' or 'refresh_token'
client_id: oauth2Config.clientId,
client_secret: oauth2Config.clientSecret, // If confidential client
}),
});
if (!response.ok) {
throw new Error('Token revocation failed');
}
return true;
}
// Logout with token revocation
async function logout() {
try {
// Revoke access token
if (accessToken) {
await revokeToken(accessToken, 'access_token');
}
// Revoke refresh token
if (refreshToken) {
await revokeToken(refreshToken, 'refresh_token');
}
// Clear local state
clearTokens();
// Optional: Redirect to logout endpoint for session cleanup
window.location.href = 'https://auth.example.com/logout?redirect_uri=' +
encodeURIComponent(window.location.origin);
} catch (error) {
console.error('Logout failed:', error);
// Clear tokens anyway
clearTokens();
}
}
OAuth2 サーバーの実装
認可サーバーの構築
OAuth2 プロバイダー機能の実装
認可エンドポイント
// Express.js authorization endpoint
const express = require('express');
const crypto = require('crypto');
const router = express.Router();
router.get('/oauth/authorize', async (req, res) => {
const {
client_id,
redirect_uri,
response_type,
scope,
state,
code_challenge,
code_challenge_method,
} = req.query;
// Validate required parameters
if (!client_id || !redirect_uri || !response_type) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'Missing required parameters',
});
}
// Validate client_id and redirect_uri
const client = await getClient(client_id);
if (!client) {
return res.status(400).json({
error: 'invalid_client',
error_description: 'Unknown client',
});
}
if (!client.redirect_uris.includes(redirect_uri)) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'Invalid redirect_uri',
});
}
// Validate response_type
if (response_type !== 'code') {
return redirectWithError(redirect_uri, state, 'unsupported_response_type',
'Only authorization code flow is supported');
}
// Validate PKCE parameters
if (code_challenge) {
if (!code_challenge_method) {
return redirectWithError(redirect_uri, state, 'invalid_request',
'code_challenge_method is required when using PKCE');
}
if (code_challenge_method !== 'S256' && code_challenge_method !== 'plain') {
return redirectWithError(redirect_uri, state, 'invalid_request',
'Unsupported code_challenge_method');
}
}
// Check if user is authenticated
if (!req.session.userId) {
// Store authorization request and redirect to login
req.session.authRequest = {
client_id,
redirect_uri,
scope,
state,
code_challenge,
code_challenge_method,
};
return res.redirect('/login?continue=' + encodeURIComponent(req.originalUrl));
}
// User is authenticated, show consent screen
res.render('consent', {
client: client,
scopes: scope ? scope.split(' ') : [],
state: state,
});
});
// Consent endpoint
router.post('/oauth/consent', async (req, res) => {
const { approved } = req.body;
const authRequest = req.session.authRequest;
if (!authRequest) {
return res.status(400).send('No pending authorization request');
}
if (approved !== 'true') {
// User denied consent
return redirectWithError(
authRequest.redirect_uri,
authRequest.state,
'access_denied',
'User denied consent'
);
}
// Generate authorization code
const authorizationCode = crypto.randomBytes(32).toString('hex');
// Store authorization code with metadata
await storeAuthorizationCode(authorizationCode, {
client_id: authRequest.client_id,
redirect_uri: authRequest.redirect_uri,
user_id: req.session.userId,
scope: authRequest.scope,
code_challenge: authRequest.code_challenge,
code_challenge_method: authRequest.code_challenge_method,
expires_at: Date.now() + 600000, // 10 minutes
});
// Clear auth request from session
delete req.session.authRequest;
// Redirect to client with authorization code
const redirectUrl = new URL(authRequest.redirect_uri);
redirectUrl.searchParams.set('code', authorizationCode);
if (authRequest.state) {
redirectUrl.searchParams.set('state', authRequest.state);
}
res.redirect(redirectUrl.toString());
});
function redirectWithError(redirectUri, state, error, errorDescription) {
const url = new URL(redirectUri);
url.searchParams.set('error', error);
url.searchParams.set('error_description', errorDescription);
if (state) {
url.searchParams.set('state', state);
}
return res.redirect(url.toString());
}
トークンエンドポイント
// Token endpoint implementation
router.post('/oauth/token', async (req, res) => {
const { grant_type } = req.body;
try {
let tokens;
switch (grant_type) {
case 'authorization_code':
tokens = await handleAuthorizationCodeGrant(req.body);
break;
case 'refresh_token':
tokens = await handleRefreshTokenGrant(req.body);
break;
case 'client_credentials':
tokens = await handleClientCredentialsGrant(req.body);
break;
default:
return res.status(400).json({
error: 'unsupported_grant_type',
error_description: `Grant type '${grant_type}' is not supported`,
});
}
res.json(tokens);
} catch (error) {
console.error('Token endpoint error:', error);
res.status(400).json({
error: error.code || 'invalid_request',
error_description: error.message,
});
}
});
async function handleAuthorizationCodeGrant(params) {
const {
code,
redirect_uri,
client_id,
client_secret,
code_verifier,
} = params;
// Validate required parameters
if (!code || !redirect_uri || !client_id) {
throw { code: 'invalid_request', message: 'Missing required parameters' };
}
// Retrieve authorization code
const authCode = await getAuthorizationCode(code);
if (!authCode) {
throw { code: 'invalid_grant', message: 'Invalid authorization code' };
}
// Check expiration
if (Date.now() > authCode.expires_at) {
await deleteAuthorizationCode(code);
throw { code: 'invalid_grant', message: 'Authorization code expired' };
}
// Validate client
if (authCode.client_id !== client_id) {
throw { code: 'invalid_grant', message: 'Client mismatch' };
}
// Validate redirect URI
if (authCode.redirect_uri !== redirect_uri) {
throw { code: 'invalid_grant', message: 'Redirect URI mismatch' };
}
// Authenticate client
const client = await getClient(client_id);
if (client.client_type === 'confidential') {
// Confidential client must provide client_secret
if (client.client_secret !== client_secret) {
throw { code: 'invalid_client', message: 'Invalid client credentials' };
}
}
// Validate PKCE if used
if (authCode.code_challenge) {
if (!code_verifier) {
throw { code: 'invalid_request', message: 'code_verifier is required' };
}
const isValid = validatePKCE(
code_verifier,
authCode.code_challenge,
authCode.code_challenge_method
);
if (!isValid) {
throw { code: 'invalid_grant', message: 'Invalid code_verifier' };
}
}
// Delete authorization code (single use)
await deleteAuthorizationCode(code);
// Generate tokens
const accessToken = await generateAccessToken({
user_id: authCode.user_id,
client_id: client_id,
scope: authCode.scope,
});
const refreshToken = await generateRefreshToken({
user_id: authCode.user_id,
client_id: client_id,
scope: authCode.scope,
});
// Generate ID token if openid scope was requested
let idToken = null;
if (authCode.scope && authCode.scope.includes('openid')) {
idToken = await generateIDToken({
user_id: authCode.user_id,
client_id: client_id,
scope: authCode.scope,
});
}
const response = {
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
refresh_token: refreshToken,
};
if (idToken) {
response.id_token = idToken;
}
return response;
}
function validatePKCE(codeVerifier, codeChallenge, method) {
if (method === 'plain') {
return codeVerifier === codeChallenge;
}
if (method === 'S256') {
const hash = crypto.createHash('sha256').update(codeVerifier).digest();
const computed = base64UrlEncode(hash);
return computed === codeChallenge;
}
return false;
}
async function handleRefreshTokenGrant(params) {
const { refresh_token, client_id, client_secret, scope } = params;
// Validate refresh token
const storedToken = await getRefreshToken(refresh_token);
if (!storedToken) {
throw { code: 'invalid_grant', message: 'Invalid refresh token' };
}
// Check if revoked
if (storedToken.revoked) {
throw { code: 'invalid_grant', message: 'Refresh token has been revoked' };
}
// Validate client
if (storedToken.client_id !== client_id) {
throw { code: 'invalid_grant', message: 'Client mismatch' };
}
const client = await getClient(client_id);
if (client.client_type === 'confidential' && client.client_secret !== client_secret) {
throw { code: 'invalid_client', message: 'Invalid client credentials' };
}
// Validate scope (requested scope must be subset of original)
const requestedScopes = scope ? scope.split(' ') : storedToken.scope.split(' ');
const originalScopes = storedToken.scope.split(' ');
const isValidScope = requestedScopes.every(s => originalScopes.includes(s));
if (!isValidScope) {
throw { code: 'invalid_scope', message: 'Requested scope exceeds original grant' };
}
// Generate new access token
const accessToken = await generateAccessToken({
user_id: storedToken.user_id,
client_id: client_id,
scope: requestedScopes.join(' '),
});
// Optional: Refresh token rotation
let newRefreshToken = refresh_token;
if (shouldRotateRefreshToken()) {
// Revoke old refresh token
await revokeRefreshToken(refresh_token);
// Generate new refresh token
newRefreshToken = await generateRefreshToken({
user_id: storedToken.user_id,
client_id: client_id,
scope: requestedScopes.join(' '),
});
}
return {
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
refresh_token: newRefreshToken,
scope: requestedScopes.join(' '),
};
}
async function handleClientCredentialsGrant(params) {
const { client_id, client_secret, scope } = params;
// Authenticate client
const client = await getClient(client_id);
if (!client || client.client_secret !== client_secret) {
throw { code: 'invalid_client', message: 'Invalid client credentials' };
}
// Validate scope
if (scope) {
const requestedScopes = scope.split(' ');
const allowedScopes = client.allowed_scopes || [];
const isValidScope = requestedScopes.every(s => allowedScopes.includes(s));
if (!isValidScope) {
throw { code: 'invalid_scope', message: 'Requested scope not allowed for this client' };
}
}
// Generate access token (no refresh token for client credentials)
const accessToken = await generateAccessToken({
client_id: client_id,
scope: scope || client.default_scope,
});
return {
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
scope: scope || client.default_scope,
};
}
トークン生成(JWT)
// JWT token generation
const jwt = require('jsonwebtoken');
const fs = require('fs');
const privateKey = fs.readFileSync('path/to/private-key.pem');
const publicKey = fs.readFileSync('path/to/public-key.pem');
async function generateAccessToken(payload) {
const token = jwt.sign(
{
sub: payload.user_id || payload.client_id,
client_id: payload.client_id,
scope: payload.scope,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour
iss: 'https://auth.example.com',
aud: 'https://api.example.com',
},
privateKey,
{
algorithm: 'RS256',
keyid: 'key-id-1',
}
);
return token;
}
async function generateRefreshToken(payload) {
const refreshToken = crypto.randomBytes(32).toString('hex');
// Store refresh token in database
await storeRefreshToken(refreshToken, {
user_id: payload.user_id,
client_id: payload.client_id,
scope: payload.scope,
created_at: Date.now(),
expires_at: Date.now() + (30 * 24 * 60 * 60 * 1000), // 30 days
revoked: false,
});
return refreshToken;
}
async function generateIDToken(payload) {
const user = await getUser(payload.user_id);
const idToken = jwt.sign(
{
iss: 'https://auth.example.com',
sub: payload.user_id,
aud: payload.client_id,
exp: Math.floor(Date.now() / 1000) + 3600,
iat: Math.floor(Date.now() / 1000),
// Standard claims
name: user.name,
email: user.email,
email_verified: user.email_verified,
picture: user.picture,
// Custom claims based on scope
...getScopedClaims(user, payload.scope),
},
privateKey,
{
algorithm: 'RS256',
keyid: 'key-id-1',
}
);
return idToken;
}
function getScopedClaims(user, scope) {
const claims = {};
const scopes = scope ? scope.split(' ') : [];
if (scopes.includes('profile')) {
claims.given_name = user.given_name;
claims.family_name = user.family_name;
claims.locale = user.locale;
claims.updated_at = user.updated_at;
}
if (scopes.includes('phone')) {
claims.phone_number = user.phone_number;
claims.phone_number_verified = user.phone_number_verified;
}
return claims;
}
ベストプラクティスとセキュリティ
セキュリティのベストプラクティス
-
常に HTTPS を使用する
- すべての OAuth2 エンドポイントで HTTPS を使用する必要があります
- トークンは暗号化された接続経由でのみ送信します
- HTTP 経由で OAuth2 を使用しないでください(localhost での開発を除く)
-
リダイレクト URI を厳密に検証する
- 完全一致が必要です(ワイルドカードは使用できません)
- すべてのリダイレクト URI を事前に登録します
- すべての認可リクエストで検証します
-
パブリッククライアントでは PKCE を使用する
- SPA とモバイルアプリでは必須です
- S256 コードチャレンジ方式を使用します(plain は使用しません)
- 認可コードの傍受を防ぎます
-
state パラメーターを実装する
- CSRF 攻撃を防ぎます
- 暗号論的に安全なランダム値を生成します
- コールバック時に検証します
-
有効期間の短いアクセストークン
- 通常は 15 分から 1 時間です
- トークン窃取による影響を軽減します
- セッションを長期間維持するには、リフレッシュトークンを使用します
-
トークンを安全に保存する
- トークンを localStorage に保存しないでください(XSS のリスクがあります)
- リフレッシュトークンには httpOnly Cookie を使用します
- モバイル:プラットフォームのセキュアストレージ(Keychain、Keystore)を使用します
-
トークンローテーションを実装する
- 使用するたびにリフレッシュトークンをローテーションします
- 複数回のリフレッシュ試行によってトークン窃取を検知します
- 不正利用されたリフレッシュトークンに関連するすべてのトークンを失効させます
-
すべてのトークンを検証する
- JWT の署名を検証します
- 有効期限を確認します
- 発行者とオーディエンスを検証します
- 必要なスコープを確認します
-
スコープベースのアクセス制御
- 必要最小限のスコープを要求します
- API リクエストでスコープを検証します
- 利用可能なすべてのスコープを文書化します
-
監視とログ記録
- すべての認証試行を記録します
- 不審なパターンを監視します
- 失敗が複数回発生した場合にアラートを出します
- トークンの使用状況と失効を追跡します
避けるべきよくある落とし穴
-
インプリシットフローの使用
- 非推奨で、安全ではありません
- 代わりに、PKCE を使用した認可コードフローを使用します
-
トークンを localStorage に保存する
- XSS 攻撃に対して脆弱です
- 代わりに、メモリまたはセキュアストレージを使用します
-
リダイレクト URI を検証しない
- オープンリダイレクト攻撃を可能にします
- 登録済み URI と常に照合して検証します
-
トークンの有効期限を無視する
- 期限切れのトークンは拒否する必要があります
- 適切なリフレッシュ処理を実装します
-
state パラメーターを使用しない
- CSRF 攻撃に対して脆弱です
- state を常に生成して検証します
-
有効期間の長いアクセストークン
- セキュリティリスクを高めます
- アクセストークンの有効期間は短くします(15~60 分)
-
アプリケーション間でトークンを共有する
- トークンはアプリケーションごとに固有である必要があります
- 各アプリには独自の client_id を設定します
-
PKCE のコード検証子が弱い
- 暗号論的に安全なランダム生成を使用します
- 43 文字以上にします
-
トークンの失効を実装しない
- ユーザーがアクセスを取り消せません
- 不正利用されたトークンが有効なまま残ります
-
ログ記録が不十分
- セキュリティインシデントを検知または調査できません
- すべての認証イベントと認可イベントを記録します
パフォーマンスの最適化
トークンのキャッシュ
// Efficient token caching with automatic refresh
class OptimizedTokenManager {
constructor() {
this.tokenCache = new Map();
this.refreshPromises = new Map();
}
async getToken(cacheKey, fetchCallback) {
// Check cache
const cached = this.tokenCache.get(cacheKey);
if (cached && Date.now() < cached.expiresAt - 60000) {
return cached.token;
}
// Check if refresh is in progress
if (this.refreshPromises.has(cacheKey)) {
return this.refreshPromises.get(cacheKey);
}
// Fetch new token
const promise = fetchCallback()
.then(({ token, expiresIn }) => {
this.tokenCache.set(cacheKey, {
token,
expiresAt: Date.now() + (expiresIn * 1000),
});
this.refreshPromises.delete(cacheKey);
return token;
})
.catch(error => {
this.refreshPromises.delete(cacheKey);
throw error;
});
this.refreshPromises.set(cacheKey, promise);
return promise;
}
clearCache(cacheKey) {
if (cacheKey) {
this.tokenCache.delete(cacheKey);
} else {
this.tokenCache.clear();
}
}
}
コネクションプーリング
// Reuse HTTP connections for token requests
const https = require('https');
const axios = require('axios');
const httpsAgent = new https.Agent({
keepAlive: true,
maxSockets: 50,
maxFreeSockets: 10,
timeout: 60000,
});
const apiClient = axios.create({
httpsAgent,
timeout: 30000,
});
// Use for all OAuth2 requests
async function fetchTokens(params) {
const response = await apiClient.post(tokenEndpoint, params);
return response.data;
}
リソースと参考資料
公式仕様
- RFC 6749 - OAuth 2.0 認可フレームワーク
- RFC 7636 - PKCE
- RFC 8252 - ネイティブアプリ向け OAuth 2.0
- OpenID Connect Core 1.0
- OAuth 2.0 セキュリティのベストプラクティス
よく使われる OAuth2 ライブラリ
JavaScript/Node.js:
passport- 認証ミドルウェアoauth4webapi- モダンな OAuth2 クライアントnode-oauth2-server- OAuth2 サーバーの実装jsonwebtoken- JWT の作成と検証jwks-rsa- JWKS キーの取得
Python:
authlib- 包括的な OAuth ライブラリpython-oauth2- OAuth2 プロバイダーツールキットPyJWT- JWT の実装
PHP:
league/oauth2-client- OAuth2 クライアントleague/oauth2-server- OAuth2 サーバー
Java:
- Spring Security OAuth
- Apache Oltu
OAuth2 プロバイダー
- Auth0
- Okta
- Amazon Cognito
- Google Identity Platform
- Azure Active Directory
- Keycloak(オープンソース)
- ORY Hydra(オープンソース)
スキルのバージョン:1.0.0 最終更新日:2025年10月 スキルのカテゴリ:認証、認可、セキュリティ 対応対象:Web アプリケーション、モバイルアプリ、API、マイクロサービス