laravel ✕ OAuth2.0 Socialiteを使って取得したトークンの有効期限をチェックしてリフレッシュする

## 概要

Socialiteを使って取得したOAuthのアクセストークンは有効期限があり、期限が切れたらリフレッシュトークンを用いて新たなアクセストークンを取得する必要がある。
GoogleのAPIを実行する前にアクセストークンの有効期限を確認し、有効期限が切れていたらリフレッシュトークンを用いてアクセストークンを更新する処理を追加してみる。

## 前提

説明で使用するコードは下記の内容完了後のものとする。

https://qiita.com/miriwo/items/02dc7886cfb663754300

## 方法

1. まずはアクセストークンとリフレッシュトークンの有効期限をlaravel側で保持できるようにする。そのためにマイグレーションファイルを下記の様に修正（マイグレーションファイルの構成が、フレッシュマイグレーションに対応していることを前提にテーブル作成のマイグレーションファイルをあえて変更している。）

    ```database/migrations/2025_06_04_054743_create_oauth_tokens_table.php
    <?php
    
    use Illuminate\Database\Migrations\Migration;
    use Illuminate\Database\Schema\Blueprint;
    use Illuminate\Support\Facades\Schema;
    
    return new class extends Migration
    {
        /**
         * Run the migrations.
         */
        public function up(): void
        {
            Schema::create('oauth_tokens', function (Blueprint $table) {
                // カラム定義
                $table->id();
                $table->unsignedBigInteger('user_id');
                $table->string('access_token');
                $table->string('refresh_token')->nullable();
                $table->datetime('access_token_expires_at');
                $table->datetime('refresh_token_expires_at')->nullable();
                $table->timestamps();
            
                // 外部キー制約
                $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
            });
        }
    
        /**
         * Reverse the migrations.
         */
        public function down(): void
        {
            Schema::dropIfExists('oauth_tokens');
        }
    };
    ```

1. 当該テーブルに関連するモデルを下記のように修正

    ```app/Models/OauthToken.php
    <?php
    
    namespace App\Models;
    
    use Illuminate\Database\Eloquent\Factories\HasFactory;
    use Illuminate\Database\Eloquent\Model;
    
    class OauthToken extends Model
    {
        /** @use HasFactory<\Database\Factories\OauthTokenFactory> */
        use HasFactory;
    
        protected $fillable = [
            'user_id',
            'access_token',
            'refresh_token',
            'access_token_expires_at',
            'refresh_token_expires_at',
        ];
    }
    ```

1. 下記を実行してフレッシュマイグレーションを実施

    ```
    php artisan migrate:fresh
    ```

1. ルーティングファイルの行数が増えてきたので今回からコントローラーを新設する。下記の内容が記述されたOauthController.phpを作成（何度もexistsを使ってチェックしてしまったりしており効率が良いコードとは言えないが動作を見たいだけなので一旦これでよしとする）「☆」のコメント部分が今回新規で追加した内容

    ```app/Http/Controllers/OauthController.php
    <?php
    
    namespace App\Http\Controllers;
    
    use App\Models\OauthToken;
    use Illuminate\Http\Request;
    use App\Models\User;
    use Carbon\CarbonImmutable;
    use Illuminate\Support\Facades\Auth;
    use Illuminate\Support\Facades\Hash;
    use Illuminate\Support\Facades\Log;
    use Illuminate\Support\Str;
    use Laravel\Socialite\Facades\Socialite;
    use Illuminate\Http\RedirectResponse;
    use Illuminate\Support\Facades\DB;
    use Illuminate\Support\Facades\Http;
    
    class OauthController extends Controller
    {
    
        public function __construct(protected OauthToken $oauthToken)
        {
                
        }
    
        /**
         * Googleの認証ページへのリダイレクトを行う
         *
         * @return RedirectResponse Googleの認証ページへのリダイレクトレスポンス
         */
        public function redirectToGoogle(): RedirectResponse
        {
            return Socialite::driver('google')
                ->scopes(['email', 'profile', 'https://www.googleapis.com/auth/cloud-platform.read-only', 'https://www.googleapis.com/auth/drive.readonly'])
                ->with(['access_type' => 'offline', 'prompt' => 'consent'])
                ->redirect();
        }
    
        /**
         * Googleからのコールバックを受け取る
         *
         * @return RedirectResponse リダイレクトレスポンス
         */
        public function handleGoogleCallback(): RedirectResponse
        {
            try {
                $googleUser = Socialite::driver('google')->user();
                $accessToken = $googleUser->token;
                $refreshToken = $googleUser->refreshToken;
            } catch (\Exception $e) {
                Log::error($e);
                return redirect(route('home'));
            }
        
            // ユーザーが既に存在するか確認
            $user = User::where('email', $googleUser->getEmail())->first();
        
            if (!$user) {
                // ユーザーが存在しない場合、新規作成
                $user = User::create([
                    'name' => $googleUser->getName(),
                    'email' => $googleUser->getEmail(),
                    'google_id' => $googleUser->getId(),
                    'password' => Hash::make(Str::random(16)),
                ]);
            }
        
            // ユーザーをログインさせる
            Auth::login($user);
        
            try {
                DB::beginTransaction();
                    // 同様のuser_idのレコードがあったら削除
                    $this->oauthToken->where('user_id', $user->id)->delete();
            
                    // ☆ 有効期限の算出とDBへの格納
                    $now = CarbonImmutable::now();
                    $accessTokenExpiresAt = $now->addSeconds($googleUser->expiresIn);
                    // 下記だと今は必ずnullになるかも、レスポンスと$googleUserの中身を見てみる
                    $refreshTokenExpiresAt = $googleUser->refreshTokenExpiresIn ? $now->addSeconds($googleUser->refreshTokenExpiresIn) : null;
            
                    // 新しいトークンを挿入
                    $this->oauthToken->create([
                        'user_id' => $user->id,
                        'access_token' => $accessToken,
                        'refresh_token' => $refreshToken ?? null,
                        'access_token_expires_at' => $accessTokenExpiresAt,
                        'refresh_token_expires_at' => $refreshTokenExpiresAt,
                    ]);
                DB::commit();
            } catch (\Exception $e) {
                DB::rollBack();
                Log::error('トークンの保存中にエラーが発生しました', ['exception' => $e]);
                return redirect(route('home'))->with('error', 'トークンの保存中にエラーが発生しました');
            }
        
            // リダイレクト先を指定
            return redirect(route('dashboard'));
        }
    
        public function googleCloudStorageInfos()
        {
            // ☆ トークンチェック
            $redirect = $this->verifyOauthToken();
            if ($redirect) {
                return $redirect;
            }
        
            $oauthToken = $this->oauthToken->where('user_id', Auth::user()->id)->first();
            $accessToken = $oauthToken->access_token;
        
            try {
                $response = Http::withToken($accessToken)->get('https://www.googleapis.com/storage/v1/b', [
                    'project' => 'Google CloudのプロジェクトID' // .envで持ちたい
                ]);
            
                if ($response->successful()) {
                    $buckets = $response->json();
                    return response()->json($buckets);
                } else {
                    Log::error('Google Cloud API request failed', ['response' => $response->body()]);
                    return response()->json(['error' => 'Failed to retrieve Google Cloud resources'], 500);
                }
            } catch (\Exception $e) {
                Log::error('Exception occurred while fetching Google Cloud resources', ['exception' => $e]);
                return response()->json(['error' => 'An error occurred while fetching Google Cloud resources'], 500);
            }
        }
    
        public function googleDriveInfos()
        {
            // ☆ トークンチェック
            $redirect = $this->verifyOauthToken();
            if ($redirect) {
                return $redirect;
            }
        
            $oauthToken = $this->oauthToken->where('user_id', Auth::user()->id)->first();
            $accessToken = $oauthToken->access_token;
        
            try {
                $response = Http::withToken($accessToken)->get('https://www.googleapis.com/drive/v3/files?q=\'me\' in owners&fields=files(id, name)');
            
                if ($response->successful()) {
                    $buckets = $response->json();
                    return response()->json($buckets);
                } else {
                    Log::error('Google Cloud API request failed', ['response' => $response->body()]);
                    return response()->json(['error' => 'Failed to retrieve Google Cloud resources'], 500);
                }
            } catch (\Exception $e) {
                Log::error('Exception occurred while fetching Google Cloud resources', ['exception' => $e]);
                return response()->json(['error' => 'An error occurred while fetching Google Cloud resources'], 500);
            }
        }
    
        /**
         * ☆ OAuth系トークンの確認
         */
        private function verifyOauthToken()
        {
            if (!$this->oauthToken->where('user_id', Auth::user()->id)->exists()) {
                Auth::guard('web')->logout();
                return redirect()->route('google.redirect');
            }
        
            if ($this->isExpirationAccessToken()) {
                if ($this->oauthToken->where('user_id', Auth::user()->id)->whereNotNull('refresh_token')->exists()) {
                    $this->refreshAccessToken();
                } else {
                    Auth::guard('web')->logout();
                    return redirect()->route('google.redirect');
                }
            }
        
            return null;
        }
    
        /**
         * ☆ アクセストークンの有効期限切れ確認
         *
         * @return bool 有効期限切れの場合true、有効期限内の場合false
         */
        private function isExpirationAccessToken(): bool
        {
            $oauthToken = $this->oauthToken->where('user_id', Auth::user()->id)->first();
            $accessTokenExpiresAt = $oauthToken->access_token_expires_at;
            $now = CarbonImmutable::now();
            return $now->greaterThan($accessTokenExpiresAt);
        }
    
    
        /**
         * ☆ アクセストークンの更新
         */
        private function refreshAccessToken()
        {
            $oauthToken = $this->oauthToken->where('user_id', Auth::user()->id)->first();
            $refreshToken = $oauthToken->refresh_token;
        
            $response = Http::post('https://oauth2.googleapis.com/token', [
                'client_id' => config('services.google.client_id'),
                'client_secret' => config('services.google.client_secret'),
                'refresh_token' => $refreshToken,
                'grant_type' => 'refresh_token',
            ]);
        
            if ($response->successful()) {
                $oauthToken->update([
                    'access_token' => $response->json()['access_token'],
                    'access_token_expires_at' => CarbonImmutable::now()->addSeconds($response->json()['expires_in']),
                ]);
            } else {
                Log::error('Failed to refresh access token', ['response' => $response->body()]);
                return redirect()->route('google.redirect');
            }
        }
    }    
    ```

1. これでGoogleのAPIの実行に下記のようなふるまいの追加かんりょう
    - ログイン中ユーザーのIDに紐づくoauth_tokensテーブルのレコードがなかった場合 → ログアウト後にGoogleの認証画面へリダイレクト
    - ログイン中ユーザーのIDに紐づくoauth_tokensテーブルのレコードのアクセストークンが有効期限切れ
        - かつ、ログイン中ユーザーのIDに紐づくoauth_tokensテーブルのレコードのリフレッシュトークンがnullの場合 → ログアウト後にGoogleの認証画面へリダイレクト
        - かつ、ログイン中ユーザーのIDに紐づくoauth_tokensテーブルのレコードのリフレッシュトークンがnullではない場合 → アクセストークンの再取得処理を実行