League OAuth2 Client
認証ライブラリ
League OAuth2 Client
概要
League OAuth2 Clientは、PHPアプリケーション向けの軽量で拡張可能なOAuth 2.0クライアントライブラリで、主要なOAuth2プロバイダーとの統合を簡単に実現します。
詳細
League OAuth2 Clientは、The PHP Leagueによって開発されたPHP用のOAuth 2.0クライアントライブラリです。RFC 6749に準拠したOAuth 2.0仕様の完全な実装を提供し、Google、Facebook、GitHub、Slack、Twitterなど100以上のOAuth2プロバイダーをサポートしています。ライブラリの核となるGenericProviderクラスは、標準的なOAuth2フローを実装し、Authorization Code Grant、Client Credentials Grant、Refresh Token Grant、Resource Owner Password Credentials Grantの各フローをサポートしています。PKCE(Proof Key for Code Exchange)にも対応し、モバイルアプリケーションやSPA(Single Page Application)でのセキュリティを強化します。プロバイダー固有の実装は独立したパッケージとして提供され、必要な機能のみをインストールできる軽量設計となっています。カスタムプロバイダーの作成も容易で、AbstractProviderクラスを継承することで独自のOAuth2サービスとも連携可能です。
メリット・デメリット
メリット
- 標準準拠: RFC 6749完全準拠でセキュリティ標準を満たした実装
- 豊富なプロバイダー: 100以上の公式・コミュニティプロバイダーをサポート
- 軽量設計: 必要なプロバイダーのみインストール可能
- 拡張性: カスタムプロバイダーの実装が容易
- PSRアダプター: PSR-7 HTTPメッセージインターフェース対応
- PKCE対応: モバイル・SPAアプリケーションでのセキュリティ強化
- コンポーザー管理: 依存関係管理が簡単
デメリット
- PHP専用: PHP言語以外では利用不可
- 非同期非対応: 同期処理のみで非同期リクエストは未サポート
- 学習コスト: OAuth2仕様の理解が必要
- プロバイダー依存: 各プロバイダーの独自仕様に対応が必要
- エラーハンドリング: プロバイダー固有のエラー処理が複雑
主要リンク
- League OAuth2 Client公式ページ
- League OAuth2 Client公式ドキュメント
- OAuth2プロバイダー一覧
- The PHP League
- PHP-FIG PSR-7
- RFC 6749 - OAuth 2.0仕様
書き方の例
Hello World(基本的なGoogle OAuth認証)
<?php
require_once 'vendor/autoload.php';
use League\OAuth2\Client\Provider\Google;
// Google OAuth2プロバイダーの設定
$provider = new Google([
'clientId' => 'your-google-client-id',
'clientSecret' => 'your-google-client-secret',
'redirectUri' => 'https://your-app.com/callback',
]);
session_start();
if (!isset($_GET['code'])) {
// 認証URLを生成してリダイレクト
$authUrl = $provider->getAuthorizationUrl([
'scope' => ['openid', 'profile', 'email']
]);
$_SESSION['oauth2state'] = $provider->getState();
header('Location: ' . $authUrl);
exit;
} elseif (empty($_GET['state']) || ($_GET['state'] !== $_SESSION['oauth2state'])) {
// CSRF攻撃対策
unset($_SESSION['oauth2state']);
exit('Invalid state');
} else {
try {
// アクセストークンを取得
$token = $provider->getAccessToken('authorization_code', [
'code' => $_GET['code']
]);
// ユーザー情報を取得
$user = $provider->getResourceOwner($token);
echo 'Hello ' . $user->getName() . '!';
echo '<br>Email: ' . $user->getEmail();
} catch (\Exception $e) {
exit('OAuth authentication failed: ' . $e->getMessage());
}
}
?>
GenericProviderを使用したカスタムOAuth2実装
<?php
require_once 'vendor/autoload.php';
use League\OAuth2\Client\Provider\GenericProvider;
// カスタムOAuth2プロバイダーの設定
$provider = new GenericProvider([
'clientId' => 'your-client-id',
'clientSecret' => 'your-client-secret',
'redirectUri' => 'https://your-app.com/oauth/callback',
'urlAuthorize' => 'https://api.example.com/oauth/authorize',
'urlAccessToken' => 'https://api.example.com/oauth/token',
'urlResourceOwnerDetails' => 'https://api.example.com/user',
'scopes' => 'read write',
]);
class CustomOAuthHandler
{
private $provider;
public function __construct(GenericProvider $provider)
{
$this->provider = $provider;
}
public function initiateLogin(): string
{
session_start();
$authUrl = $this->provider->getAuthorizationUrl([
'scope' => ['read', 'write', 'admin']
]);
$_SESSION['oauth2state'] = $this->provider->getState();
return $authUrl;
}
public function handleCallback(): array
{
session_start();
if (empty($_GET['state']) || ($_GET['state'] !== $_SESSION['oauth2state'])) {
throw new \Exception('CSRF validation failed');
}
if (!isset($_GET['code'])) {
throw new \Exception('Authorization code not received');
}
try {
$token = $this->provider->getAccessToken('authorization_code', [
'code' => $_GET['code']
]);
$resourceOwner = $this->provider->getResourceOwner($token);
return [
'access_token' => $token->getToken(),
'refresh_token' => $token->getRefreshToken(),
'expires_at' => $token->getExpires(),
'user_data' => $resourceOwner->toArray()
];
} catch (\Exception $e) {
throw new \Exception('Token exchange failed: ' . $e->getMessage());
}
}
public function refreshToken(string $refreshToken): array
{
try {
$newToken = $this->provider->getAccessToken('refresh_token', [
'refresh_token' => $refreshToken
]);
return [
'access_token' => $newToken->getToken(),
'refresh_token' => $newToken->getRefreshToken(),
'expires_at' => $newToken->getExpires()
];
} catch (\Exception $e) {
throw new \Exception('Token refresh failed: ' . $e->getMessage());
}
}
}
// 使用例
$oauthHandler = new CustomOAuthHandler($provider);
if (!isset($_GET['code'])) {
$authUrl = $oauthHandler->initiateLogin();
header('Location: ' . $authUrl);
exit;
} else {
try {
$result = $oauthHandler->handleCallback();
echo 'Authentication successful!';
echo '<pre>' . print_r($result, true) . '</pre>';
} catch (\Exception $e) {
echo 'Error: ' . $e->getMessage();
}
}
?>
PKCE対応OAuth2実装
<?php
require_once 'vendor/autoload.php';
use League\OAuth2\Client\Provider\GenericProvider;
class PKCEOAuthClient
{
private $provider;
public function __construct()
{
$this->provider = new GenericProvider([
'clientId' => 'your-public-client-id',
'clientSecret' => '', // PKCEでは不要
'redirectUri' => 'https://your-app.com/oauth/callback',
'urlAuthorize' => 'https://api.example.com/oauth/authorize',
'urlAccessToken' => 'https://api.example.com/oauth/token',
'urlResourceOwnerDetails' => 'https://api.example.com/user',
'pkceMethod' => GenericProvider::PKCE_METHOD_S256,
]);
}
public function getAuthorizationUrl(): string
{
session_start();
$authUrl = $this->provider->getAuthorizationUrl([
'scope' => ['read', 'write']
]);
// PKCEコードとstateを保存
$_SESSION['oauth2state'] = $this->provider->getState();
$_SESSION['oauth2pkceCode'] = $this->provider->getPkceCode();
return $authUrl;
}
public function exchangeCodeForToken(string $code): array
{
session_start();
// PKCE検証
if (empty($_SESSION['oauth2pkceCode'])) {
throw new \Exception('PKCE code not found in session');
}
// PKCEコードを復元
$this->provider->setPkceCode($_SESSION['oauth2pkceCode']);
try {
$token = $this->provider->getAccessToken('authorization_code', [
'code' => $code
]);
// セッションからPKCE情報をクリア
unset($_SESSION['oauth2pkceCode']);
unset($_SESSION['oauth2state']);
return [
'access_token' => $token->getToken(),
'token_type' => 'Bearer',
'expires_in' => $token->getExpires() - time(),
'refresh_token' => $token->getRefreshToken(),
'scope' => implode(' ', $token->getValues()['scope'] ?? [])
];
} catch (\Exception $e) {
throw new \Exception('PKCE token exchange failed: ' . $e->getMessage());
}
}
}
// SPA(Single Page Application)向けの使用例
$pkceClient = new PKCEOAuthClient();
if (!isset($_GET['code'])) {
$authUrl = $pkceClient->getAuthorizationUrl();
// JavaScriptで処理する場合
echo json_encode([
'auth_url' => $authUrl,
'message' => 'Redirect to this URL for authentication'
]);
} else {
try {
$tokens = $pkceClient->exchangeCodeForToken($_GET['code']);
echo json_encode([
'success' => true,
'tokens' => $tokens
]);
} catch (\Exception $e) {
http_response_code(400);
echo json_encode([
'error' => $e->getMessage()
]);
}
}
?>
複数プロバイダー対応統合システム
<?php
require_once 'vendor/autoload.php';
use League\OAuth2\Client\Provider\Google;
use League\OAuth2\Client\Provider\Facebook;
use League\OAuth2\Client\Provider\Github;
class MultiProviderOAuthManager
{
private $providers = [];
private $config;
public function __construct(array $config)
{
$this->config = $config;
$this->initializeProviders();
}
private function initializeProviders(): void
{
// Google Provider
if (isset($this->config['google'])) {
$this->providers['google'] = new Google([
'clientId' => $this->config['google']['client_id'],
'clientSecret' => $this->config['google']['client_secret'],
'redirectUri' => $this->config['google']['redirect_uri'],
]);
}
// Facebook Provider
if (isset($this->config['facebook'])) {
$this->providers['facebook'] = new Facebook([
'clientId' => $this->config['facebook']['app_id'],
'clientSecret' => $this->config['facebook']['app_secret'],
'redirectUri' => $this->config['facebook']['redirect_uri'],
'graphApiVersion' => 'v19.0',
]);
}
// GitHub Provider
if (isset($this->config['github'])) {
$this->providers['github'] = new Github([
'clientId' => $this->config['github']['client_id'],
'clientSecret' => $this->config['github']['client_secret'],
'redirectUri' => $this->config['github']['redirect_uri'],
]);
}
}
public function getProvider(string $name)
{
if (!isset($this->providers[$name])) {
throw new \InvalidArgumentException("Provider '{$name}' not configured");
}
return $this->providers[$name];
}
public function getAuthorizationUrl(string $providerName, array $options = []): string
{
$provider = $this->getProvider($providerName);
session_start();
$authUrl = $provider->getAuthorizationUrl($options);
$_SESSION['oauth2state'] = $provider->getState();
$_SESSION['oauth2provider'] = $providerName;
return $authUrl;
}
public function handleCallback(): array
{
session_start();
if (empty($_GET['state']) || ($_GET['state'] !== $_SESSION['oauth2state'])) {
throw new \Exception('CSRF validation failed');
}
if (empty($_SESSION['oauth2provider'])) {
throw new \Exception('Provider information missing from session');
}
$providerName = $_SESSION['oauth2provider'];
$provider = $this->getProvider($providerName);
try {
$token = $provider->getAccessToken('authorization_code', [
'code' => $_GET['code']
]);
$resourceOwner = $provider->getResourceOwner($token);
// プロバイダー固有の情報を統一フォーマットに変換
$userData = $this->normalizeUserData($providerName, $resourceOwner);
return [
'provider' => $providerName,
'access_token' => $token->getToken(),
'refresh_token' => $token->getRefreshToken(),
'expires_at' => $token->getExpires(),
'user' => $userData
];
} catch (\Exception $e) {
throw new \Exception("OAuth callback failed for {$providerName}: " . $e->getMessage());
} finally {
unset($_SESSION['oauth2state']);
unset($_SESSION['oauth2provider']);
}
}
private function normalizeUserData(string $provider, $resourceOwner): array
{
$userData = $resourceOwner->toArray();
switch ($provider) {
case 'google':
return [
'id' => $userData['sub'] ?? $userData['id'],
'name' => $userData['name'],
'email' => $userData['email'],
'avatar' => $userData['picture'],
'verified' => $userData['email_verified'] ?? false
];
case 'facebook':
return [
'id' => $userData['id'],
'name' => $userData['name'],
'email' => $userData['email'] ?? null,
'avatar' => $userData['picture']['data']['url'] ?? null,
'verified' => true // Facebookは認証済みと仮定
];
case 'github':
return [
'id' => $userData['id'],
'name' => $userData['name'] ?? $userData['login'],
'email' => $userData['email'],
'avatar' => $userData['avatar_url'],
'verified' => !empty($userData['email'])
];
default:
return $userData;
}
}
public function getAvailableProviders(): array
{
return array_keys($this->providers);
}
}
// 設定例
$config = [
'google' => [
'client_id' => 'your-google-client-id',
'client_secret' => 'your-google-client-secret',
'redirect_uri' => 'https://your-app.com/oauth/callback'
],
'facebook' => [
'app_id' => 'your-facebook-app-id',
'app_secret' => 'your-facebook-app-secret',
'redirect_uri' => 'https://your-app.com/oauth/callback'
],
'github' => [
'client_id' => 'your-github-client-id',
'client_secret' => 'your-github-client-secret',
'redirect_uri' => 'https://your-app.com/oauth/callback'
]
];
$oauthManager = new MultiProviderOAuthManager($config);
// ログイン選択画面
if (!isset($_GET['provider']) && !isset($_GET['code'])) {
echo '<h2>Login with:</h2>';
foreach ($oauthManager->getAvailableProviders() as $provider) {
echo "<a href='?provider={$provider}'>Login with " . ucfirst($provider) . "</a><br>";
}
exit;
}
// プロバイダー選択後の認証開始
if (isset($_GET['provider']) && !isset($_GET['code'])) {
$provider = $_GET['provider'];
$options = [];
// プロバイダー固有のオプション
if ($provider === 'google') {
$options['scope'] = ['openid', 'profile', 'email'];
} elseif ($provider === 'facebook') {
$options['scope'] = ['email', 'public_profile'];
} elseif ($provider === 'github') {
$options['scope'] = ['user:email'];
}
$authUrl = $oauthManager->getAuthorizationUrl($provider, $options);
header('Location: ' . $authUrl);
exit;
}
// コールバック処理
if (isset($_GET['code'])) {
try {
$result = $oauthManager->handleCallback();
echo '<h2>Authentication Successful!</h2>';
echo '<pre>' . print_r($result, true) . '</pre>';
} catch (\Exception $e) {
echo '<h2>Authentication Failed</h2>';
echo '<p>' . htmlspecialchars($e->getMessage()) . '</p>';
echo '<a href="/">Try again</a>';
}
}
?>
Client Credentialsとリソース保護
<?php
require_once 'vendor/autoload.php';
use League\OAuth2\Client\Provider\GenericProvider;
class ApiResourceManager
{
private $provider;
private $accessToken;
public function __construct(array $config)
{
$this->provider = new GenericProvider([
'clientId' => $config['client_id'],
'clientSecret' => $config['client_secret'],
'redirectUri' => '', // Client Credentialsでは不要
'urlAuthorize' => '', // Client Credentialsでは不要
'urlAccessToken' => $config['token_url'],
'urlResourceOwnerDetails' => '', // Client Credentialsでは不要
]);
}
public function authenticateClient(): void
{
try {
$this->accessToken = $this->provider->getAccessToken('client_credentials', [
'scope' => 'api:read api:write'
]);
} catch (\Exception $e) {
throw new \Exception('Client authentication failed: ' . $e->getMessage());
}
}
public function makeApiRequest(string $method, string $url, array $data = []): array
{
if (!$this->accessToken) {
$this->authenticateClient();
}
// トークンの有効期限チェック
if ($this->accessToken->hasExpired()) {
$this->authenticateClient();
}
$request = $this->provider->getAuthenticatedRequest(
$method,
$url,
$this->accessToken,
[
'headers' => [
'Content-Type' => 'application/json',
'Accept' => 'application/json'
],
'body' => json_encode($data)
]
);
try {
$response = $this->provider->getParsedResponse($request);
return $response;
} catch (\Exception $e) {
throw new \Exception('API request failed: ' . $e->getMessage());
}
}
public function getUsers(): array
{
return $this->makeApiRequest('GET', 'https://api.example.com/users');
}
public function createUser(array $userData): array
{
return $this->makeApiRequest('POST', 'https://api.example.com/users', $userData);
}
public function updateUser(int $userId, array $userData): array
{
return $this->makeApiRequest('PUT', "https://api.example.com/users/{$userId}", $userData);
}
public function deleteUser(int $userId): array
{
return $this->makeApiRequest('DELETE', "https://api.example.com/users/{$userId}");
}
}
// 使用例
$config = [
'client_id' => 'your-service-client-id',
'client_secret' => 'your-service-client-secret',
'token_url' => 'https://api.example.com/oauth/token'
];
$apiManager = new ApiResourceManager($config);
try {
// ユーザー一覧取得
$users = $apiManager->getUsers();
echo "Users: " . json_encode($users, JSON_PRETTY_PRINT) . "\n";
// 新規ユーザー作成
$newUser = [
'name' => 'John Doe',
'email' => '[email protected]',
'role' => 'user'
];
$createdUser = $apiManager->createUser($newUser);
echo "Created User: " . json_encode($createdUser, JSON_PRETTY_PRINT) . "\n";
} catch (\Exception $e) {
echo "Error: " . $e->getMessage() . "\n";
}
?>
エラーハンドリングとレート制限対応
<?php
require_once 'vendor/autoload.php';
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use League\OAuth2\Client\Provider\GenericProvider;
class RobustOAuthClient
{
private $provider;
private $maxRetries;
private $retryDelay;
public function __construct(array $config, int $maxRetries = 3, int $retryDelay = 1000)
{
$this->provider = new GenericProvider($config);
$this->maxRetries = $maxRetries;
$this->retryDelay = $retryDelay; // milliseconds
}
public function getAccessTokenWithRetry(string $grant, array $options = []): ?object
{
$lastException = null;
for ($attempt = 1; $attempt <= $this->maxRetries; $attempt++) {
try {
return $this->provider->getAccessToken($grant, $options);
} catch (IdentityProviderException $e) {
$lastException = $e;
$response = $e->getResponseBody();
// レート制限の場合はリトライ
if ($this->isRateLimited($e)) {
$retryAfter = $this->getRetryAfter($e);
$delay = $retryAfter ? $retryAfter * 1000 : $this->retryDelay * $attempt;
error_log("Rate limited, retrying after {$delay}ms (attempt {$attempt}/{$this->maxRetries})");
usleep($delay * 1000); // microseconds
continue;
}
// 一時的なエラーの場合はリトライ
if ($this->isTemporaryError($e)) {
$delay = $this->retryDelay * $attempt;
error_log("Temporary error, retrying after {$delay}ms (attempt {$attempt}/{$this->maxRetries})");
usleep($delay * 1000);
continue;
}
// 永続的なエラーの場合は即座に失敗
throw $e;
} catch (\Exception $e) {
$lastException = $e;
error_log("Unexpected error (attempt {$attempt}/{$this->maxRetries}): " . $e->getMessage());
if ($attempt < $this->maxRetries) {
$delay = $this->retryDelay * $attempt;
usleep($delay * 1000);
continue;
}
throw $e;
}
}
throw $lastException;
}
private function isRateLimited(IdentityProviderException $e): bool
{
$statusCode = $e->getResponseStatus();
$response = $e->getResponseBody();
// HTTPステータスコードでチェック
if ($statusCode === 429) {
return true;
}
// レスポンスボディでチェック
if (is_array($response)) {
$error = $response['error'] ?? '';
return in_array($error, ['rate_limit_exceeded', 'too_many_requests']);
}
return false;
}
private function isTemporaryError(IdentityProviderException $e): bool
{
$statusCode = $e->getResponseStatus();
// 5xx系は一時的なエラーとして扱う
return $statusCode >= 500 && $statusCode < 600;
}
private function getRetryAfter(IdentityProviderException $e): ?int
{
$headers = $e->getResponseHeaders();
// Retry-Afterヘッダーから取得
if (isset($headers['Retry-After'])) {
return (int) $headers['Retry-After'];
}
// X-Rate-Limit-Reset系ヘッダーから計算
if (isset($headers['X-Rate-Limit-Reset'])) {
$resetTime = (int) $headers['X-Rate-Limit-Reset'];
return max(0, $resetTime - time());
}
return null;
}
public function safeApiCall(callable $apiCall, array $context = []): array
{
try {
return $apiCall();
} catch (IdentityProviderException $e) {
$error = [
'type' => 'identity_provider_error',
'status_code' => $e->getResponseStatus(),
'message' => $e->getMessage(),
'response' => $e->getResponseBody(),
'context' => $context
];
error_log('OAuth API Error: ' . json_encode($error));
return [
'success' => false,
'error' => $error
];
} catch (\Exception $e) {
$error = [
'type' => 'general_error',
'message' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'context' => $context
];
error_log('General API Error: ' . json_encode($error));
return [
'success' => false,
'error' => $error
];
}
}
}
// 使用例
$config = [
'clientId' => 'your-client-id',
'clientSecret' => 'your-client-secret',
'redirectUri' => 'https://your-app.com/callback',
'urlAuthorize' => 'https://api.example.com/oauth/authorize',
'urlAccessToken' => 'https://api.example.com/oauth/token',
'urlResourceOwnerDetails' => 'https://api.example.com/user',
];
$robustClient = new RobustOAuthClient($config, 3, 2000);
// エラーハンドリング付きトークン取得
$result = $robustClient->safeApiCall(function() use ($robustClient) {
$token = $robustClient->getAccessTokenWithRetry('client_credentials');
return [
'success' => true,
'token' => $token->getToken(),
'expires_at' => $token->getExpires()
];
}, ['operation' => 'get_access_token']);
if ($result['success']) {
echo "Token obtained successfully: " . $result['token'] . "\n";
} else {
echo "Failed to obtain token: " . json_encode($result['error'], JSON_PRETTY_PRINT) . "\n";
}
?>