【Laravel】SocialiteでAppleのソーシャルログインを実装

# AppleのSign in with Appleの仕様確認
まず、Appleでのソーシャルログインを使用する為の要件です。
- Apple Developer Programに加入(年会費99USD)
- クライアントシークレットはAppleから提供される秘密鍵から生成する
- クライアントシークレットは6ヶ月に1度更新しないといけない

このため、他のプロバイダー(Google,Twitter等)とは違い、動的にクライアントシークレットを生成するアプローチを行ってソーシャルログインを実装します。


※スマホアプリの作成は不要で、WebアプリのみでのSign in with Appleの利用は可能です。


## パッケージのインストール
```shell:composer
composer require laravel/socialite
composer require socialiteproviders/apple
composer require firebase/php-jwt
composer require guzzlehttp/guzzle
```

## コントローラーの作成
```shell:composer
php artisan make:controller SocialAccountController
```

## ルートの設定
※Appleのリダイレクトは他のプロバイダーとは違いPOSTでリクエストされます。
```php:routes/web.php
Route::controller(SocialAccountController::class)->prefix('oauth')->as('social_accounts.')->group(function () {
    Route::get('/{provider}', 'create')->name('create');
    Route::post('/{provider}/callback', 'store')->name('store');
});
```

## 環境変数の追加
```shell:.env
APPLE_PRIVATE_KEY_PATH=""  # Appleからダウンロードした秘密鍵(.p8ファイル)
APPLE_TEAM_ID=""           # Apple DeveloperのTeam ID
APPLE_KEY_ID=""            # AppleのKey ID
APPLE_CLIENT_ID=""         # Apple Developerでアプリを設定した際のApp ID
# Apple Developerで設定したリダイレクトURL(route.phpで設定したURL)
APPLE_REDIRECT_URL="${APP_URL}/oauth/apple/callback"
```


## configの設定
```php:config/services.php
<?php

return [
    # 省略
    # 以下を追加
    'apple' => [
        'private_key_path' => env('APPLE_PRIVATE_KEY_PATH'),
        'team_id'          => env('APPLE_TEAM_ID'),
        'key_id'           => env('APPLE_KEY_ID'),
        'client_id'        => env('APPLE_CLIENT_ID'),
        'client_secret'    => null,
        'redirect'         => env('APPLE_REDIRECT_URL'),
    ],
    # 省略
];
```
#### Laravel10以下の場合
```php:config/app.php
    # 省略
    'providers' => ServiceProvider::defaultProviders()->merge([
        # 省略
        # 以下を追加
        SocialiteProviders\Manager\ServiceProvider::class,
    ])->toArray(),
    # 省略
```

## Providerの設定
####  Laravel11.x以上
```php:App/Providers/AppServiceProvider.php
<?php

namespace App\Providers;

use Illuminate\Console\Scheduling\Event;
use Illuminate\Support\ServiceProvider;
use SocialiteProviders\Apple\Provider;
use SocialiteProviders\Manager\SocialiteWasCalled;

class AppServiceProvider extends ServiceProvider
{
    # 省略
    public function boot(): void
    {
        # 以下追加
        Event::listen(function (SocialiteWasCalled $event) {
            $event->extendSocialite('apple', Provider::class);
        });
    }
}
```

#### Laravel10.x以下
```php:App/Providers/EventServiceProvider.php

<?php

namespace App\Providers;

use SocialiteProviders\Apple\AppleExtendSocialite;

class EventServiceProvider extends ServiceProvider
{
    protected $listen = [
        # 以下に使用するパッケージを追加
        SocialiteWasCalled::class => [
            AppleExtendSocialite::class,
        ],    
    ];
    # 省略
}
```

## Middlewareの設定
Appleを経由したユーザーがPOSTでリダイレクトしてくる為、```store```アクションのルートからCSRF保護を除外します。
####  Laravel11.x以上
```php:bootstrap/app.php
<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
    # 省略
    )
    ->withMiddleware(function (Middleware $middleware) {
        # 以下にリダイレクトURLのパスを追加
        $middleware->validateCsrfTokens(except: [
            'oauth/apple/callback',
        ]);
    })
    # 省略
```

#### Laravel10.x以下
```php:App/Http/Middleware/VerifyCsrfToken.php
<?php

namespace App\Http\Middleware;

use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;

class VerifyCsrfToken extends Middleware
{
    protected $except = [
        # 以下にリダイレクトURLのパスを追加
        'oauth/apple/callback'
    ];
}
```

## Appleのクライアントシークレットを生成するクラスを作成
```php:App/Services/AppleOAuthService.php
<?php

namespace App\Services;

use Illuminate\Support\Facades\{
    Storage,
    Cache
};
use Firebase\JWT\JWT;
use GuzzleHttp\Client;

class AppleOAuthService
{
    const CLIENT_SECRET_EXPIRATION_SECONDS = 7776000; # クライアントシークレットの有効期限
    const CACHE_LIFE_TIME_SECONDS = 5184000; # キャッシュを更新する際の有効期限
    const SIGNED_URL_EXPIRATION_MINUTES = 1; # 署名付きURLのアクセス有効期限
    const AUD = 'https://appleid.apple.com';
    const ALGORITHM = 'ES256';

    private string $privateKey;
    private string $clientId;
    private string $teamId;
    private string $keyId;
    private int $issuedAt;
    private int $expirationTime;

    public static function getClientSecret()
    {
        return Cache::remember('apple.client_secret', self::CACHE_LIFE_TIME_SECONDS, function () {
            return self::generateClientSecret();
        });
    }

    private static function generateClientSecret()
    {
        $instance = new self();

        return JWT::encode($instance->getPayload(), $instance->privateKey, self::ALGORITHM, $instance->keyId);

    }

    private function __construct() {
        $this->privateKey     = $this->getPrivateKey();
        $this->clientId       = config('services.apple.client_id');
        $this->teamId         = config('services.apple.team_id');
        $this->keyId          = config('services.apple.key_id');
        $this->issuedAt       = time();
        $this->expirationTime = $this->issuedAt + self::CLIENT_SECRET_EXPIRATION_SECONDS;
    }

    private function getPayload()
    {
        return [
            'iss' => $this->teamId,
            'iat' => $this->issuedAt,
            'exp' => $this->expirationTime,
            'aud' => self::AUD,
            'sub' => $this->clientId
        ];
    }

    # S3の署名付きURLから秘密鍵を取得
    private function getPrivateKey()
    {
        $sinedUrl =  Storage::disk('s3')->temporaryUrl(
            config('services.apple.private_key_path'),
            now()->addMinutes(self::SIGNED_URL_EXPIRATION_MINUTES)
        );

        $client = new Client();
        $response = $client->request('GET', $sinedUrl);

        return $response->getBody()->getContents();
    }
}
```
S3の非公開バケットにAppleの秘密鍵を保存し、署名付きURLを使用して必要な時にだけ取得するようにしています。
S3を使用しない場合はgetPrivateKey()の処理は省き、.envに秘密鍵の内容を貼り付けconfig関数で取得するのもセキュリティ的には無しだと思いますが、最悪ありかと思います。
その際、configのservices.apple.private_keyに設定するのであれば以下のように修正してください
```php
return JWT::encode($instance->getPayload(), config('services.apple.private_key'), self::ALGORITHM, $instance->keyId);
```
秘密鍵は-----BEGIN RSA PRIVATE KEY-----から-----END RSA PRIVATE KEY-----を含んだ状態で使用します。

## SocialAccountControllerのアクション作成
```php:App/Http/Controllers/SocialAccountController.php
<?php

namespace App\Http\Controllers;

use App\Services\AppleOAuthService;
use Illuminate\Http\Request;
use Laravel\Socialite\Facades\Socialite;
use SocialiteProviders\Manager\Config;

class SocialAccountController extends Controller
{
    public function create(Request $request, string $provider)
    {
        # Laravelのconfigからではなく動的に接続情報を変える為、Configクラスのインスタンスを作成
        $config = new Config(
            config('services.apple.client_id'),
            AppleOAuthService::getClientSecret(),
            route('social_accounts.store_apple', ['provider' => $provider])
        );

        return Socialite::driver($provider)
            ->setConfig($config)
            ->scopes(['name', 'email'])
            ->redirect();
    }

    public function store(Request $request, string $provider)
    {
        # 上に同じく
        $config = new Config(
            config('services.apple.client_id'),
            AppleOAuthService::getClientSecret(),
            route('social_accounts.store_apple', ['provider' => $provider])
        );

        $providerUser = Socialite::driver($provider)
            ->setConfig($config)
            ->stateless()
            ->user();
    
    }
```
これでアクセスするとAppleでのソーシャルログインが実装できます。

## その他
#### クライアントシークレットの有効期限を6ヶ月間以上にするとエラーが起こる
当たり前ではあるのですが、エラーになります。今回はバッファーを持たせ、クライアントシークレットの有効期限は3ヶ月、キャッシュの有効期限は2ヶ月に設定し、2ヶ月に1回再発行するようにしています。
Appleも半年間使い回すよりも短い期間で再発行することを推奨していますのでこれくらいがベストかなと思います。
※Appleの秘密鍵の作成は1回きりで大丈夫です。
#### ユーザーがサービスへの情報提供を拒否できる
Appleでソーシャルログインする場合、ユーザーにはメールアドレスなどを提供するかどうかの選択肢が与えられます。
提供しないを選んだユーザーからは、存在しない適当なメールアドレスが返ってくる為、メールアドレスベースでユーザーを管理する場合は注意が必要です。