League OAuth2 Client

認証ライブラリPHPOAuth2OpenID ConnectセキュリティREST API

認証ライブラリ

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仕様の理解が必要
  • プロバイダー依存: 各プロバイダーの独自仕様に対応が必要
  • エラーハンドリング: プロバイダー固有のエラー処理が複雑

主要リンク

書き方の例

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";
}
?>