Laravel9 + Jetstream + Passport で OAuth2 サーバを構築する

## 概要
ログインが必要なWebサイトが複数存在し、それぞれでログインページを作成していましたが、ログインを扱う箇所を一つに纏める目的で OAuth2 サーバを構築することになりました。
事前調査を兼ねて Laravel9 で構築したので、備忘録としてまとめます。
GitHub: https://github.com/tomy-develop/proto-laravel-9

## 前提条件
- Apple M1 Max
- macOS Monterey (v12.3)
- Docker Desktop for Mac (v4.5.0)

## 初期構築

### Homebrew のインストール
まずはパッケージマネージャーのHomebrewをインストールします。

```sh
~ % bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
```

### Docker のインストール
先ほどインストールした Homebrew を使用してDockerをインストールします。

```sh
~ % brew install docker
```

### Laravel 9 のインストール
公式サイトに載っている手順に従って、 Laravel の新規アプリケーションを作成するコマンドを実行します。
プロジェクト名は `proto-laravel-9` で作成します。
Sail サービスは以下を指定しています。
- pgsql
- redis
- minio
- mailhog


```sh
~ % curl -s "https://laravel.build/proto-laravel-9?with=pgsql,redis,minio,mailhog" | bash
```

`https://laravel.build/proto-laravel-9?with=pgsql,redis,minio,mailhog` の中身は以下のようになっており、 Docker コンテナを立ち上げて `laravel new` で新しい Laravel アプリケーションを作成しているのがわかります。

```sh
docker info > /dev/null 2>&1

# Ensure that Docker is running...
if [ $? -ne 0 ]; then
    echo "Docker is not running."

    exit 1
fi

docker run --rm \
    -v "$(pwd)":/opt \
    -w /opt \
    laravelsail/php81-composer:latest \
    bash -c "laravel new proto-laravel-9 && cd proto-laravel-9 && php ./artisan sail:install --with=pgsql,redis,minio,mailhog "

cd proto-laravel-9

CYAN='\033[0;36m'
LIGHT_CYAN='\033[1;36m'
WHITE='\033[1;37m'
NC='\033[0m'

echo ""

if sudo -n true 2>/dev/null; then
    sudo chown -R $USER: .
    echo -e "${WHITE}Get started with:${NC} cd proto-laravel-9 && ./vendor/bin/sail up"
else
    echo -e "${WHITE}Please provide your password so we can make some final adjustments to your application's permissions.${NC}"
    echo ""
    sudo chown -R $USER: .
    echo ""
    echo -e "${WHITE}Thank you! We hope you build something incredible. Dive in with:${NC} cd proto-laravel-9 && ./vendor/bin/sail up"
fi
```

### sail エイリアスの登録
`sail` コマンドで実行できるようにエイリアスを登録します。

https://laravel.com/docs/9.x/sail#configuring-a-bash-alias

```sh
proto-laravel-9 % alias sail='[ -f sail ] && bash sail || bash vendor/bin/sail'
```

### Docker コンテナの起動
Docker コンテナが起動したら、 ブラウザで `http://localhost` にアクセスするとアプリケーションのトップ画面を確認することができます。

```sh
proto-laravel-9 % sail up -d
```

## 認証機能の実装

### Laravel Jetstream のインストール
画面と認証機能をまとめて実装するため Laravel Jetstream を導入します。

```sh
proto-laravel-9 % sail composer require laravel/jetstream
```

### Laravel Jetstream の初期設定
画面は Inertia + Vue を使用します。

```sh
proto-laravel-9 % sail artisan jetstream:install inertia
proto-laravel-9 % sail artisan migrate
```

```text
GET|HEAD  / ............................................................................................................... 
POST      _ignition/execute-solution ........ ignition.executeSolution › Spatie\LaravelIgnition › ExecuteSolutionController
GET|HEAD  _ignition/health-check .................... ignition.healthCheck › Spatie\LaravelIgnition › HealthCheckController
POST      _ignition/update-config ................. ignition.updateConfig › Spatie\LaravelIgnition › UpdateConfigController
GET|HEAD  api/user ........................................................................................................ 
GET|HEAD  dashboard ............................................................................................. dashboard
GET|HEAD  forgot-password ......................... password.request › Laravel\Fortify › PasswordResetLinkController@create
POST      forgot-password ............................ password.email › Laravel\Fortify › PasswordResetLinkController@store
GET|HEAD  login ........................................... login › Laravel\Fortify › AuthenticatedSessionController@create
POST      login .................................................... Laravel\Fortify › AuthenticatedSessionController@store
POST      logout ........................................ logout › Laravel\Fortify › AuthenticatedSessionController@destroy
GET|HEAD  register ........................................... register › Laravel\Fortify › RegisteredUserController@create
POST      register ....................................................... Laravel\Fortify › RegisteredUserController@store
POST      reset-password .................................. password.update › Laravel\Fortify › NewPasswordController@store
GET|HEAD  reset-password/{token} .......................... password.reset › Laravel\Fortify › NewPasswordController@create
GET|HEAD  sanctum/csrf-cookie ................................................. Laravel\Sanctum › CsrfCookieController@show
GET|HEAD  two-factor-challenge ........ two-factor.login › Laravel\Fortify › TwoFactorAuthenticatedSessionController@create
POST      two-factor-challenge ............................ Laravel\Fortify › TwoFactorAuthenticatedSessionController@store
DELETE    user ................................... current-user.destroy › Laravel\Jetstream › CurrentUserController@destroy
GET|HEAD  user/confirm-password ................... password.confirm › Laravel\Fortify › ConfirmablePasswordController@show
POST      user/confirm-password ..................................... Laravel\Fortify › ConfirmablePasswordController@store
GET|HEAD  user/confirmed-password-status . password.confirmation › Laravel\Fortify › ConfirmedPasswordStatusController@show
POST      user/confirmed-two-factor-authentication two-factor.confirm › Laravel\Fortify › ConfirmedTwoFactorAuthentication…
DELETE    user/other-browser-sessions other-browser-sessions.destroy › Laravel\Jetstream › OtherBrowserSessionsController@…
PUT       user/password ................................ user-password.update › Laravel\Fortify › PasswordController@update
GET|HEAD  user/profile ...................................... profile.show › Laravel\Jetstream › UserProfileController@show
PUT       user/profile-information user-profile-information.update › Laravel\Fortify › ProfileInformationController@update
DELETE    user/profile-photo .............. current-user-photo.destroy › Laravel\Jetstream › ProfilePhotoController@destroy
POST      user/two-factor-authentication .... two-factor.enable › Laravel\Fortify › TwoFactorAuthenticationController@store
DELETE    user/two-factor-authentication . two-factor.disable › Laravel\Fortify › TwoFactorAuthenticationController@destroy
GET|HEAD  user/two-factor-qr-code ................... two-factor.qr-code › Laravel\Fortify › TwoFactorQrCodeController@show
GET|HEAD  user/two-factor-recovery-codes ....... two-factor.recovery-codes › Laravel\Fortify › RecoveryCodeController@index
POST      user/two-factor-recovery-codes ................................... Laravel\Fortify › RecoveryCodeController@store
```

### NPM 依存関係のインストール

```sh
proto-laravel-9 % sail npm install
```

### コンポーネントのビルド
ビルドが完了したら、ブラウザで `http://localhost/register` にアクセスするとユーザ登録画面になります。
ユーザを登録後 `http://localhost/login` でメールアドレスとパスワードを入力してログインすることができます。

```sh
proto-laravel-9 % sail npm run dev
```

## OAuth2 機能の実装

### Laravel Passport のインストール
OAuth2 を機能を使用できる Laravel Passport を導入します。

```sh
proto-laravel-9 % sail composer require laravel/passport
```

### Laravel Passport の初期設定
ログイン時に使用するクライアントIDをUUIDで扱いたいので `--uuids` をつけて初期設定を行います。

```sh
proto-laravel-9 % sail artisan migrate
proto-laravel-9 % sail artisan passport:install --uuids
```

```php:app/Models/User.php
 - use Laravel\Sanctum\HasApiTokens;
 + use Laravel\Passport\HasApiTokens;
```

```php:app\Providers\AuthServiceProvider
+ use Laravel\Passport\Passport;

  public function boot()
  {
      $this->registerPolicies();
+     Passport::routes();
  }
```

```php:config/auth.php
  'guards' => [
      'web' => [
          'driver' => 'session',
          'provider' => 'users',
      ],
  
+     'api' => [
+         'driver' => 'passport',
+         'provider' => 'users',
+     ],
  ],
```

```
GET|HEAD  oauth/authorize ........ passport.authorizations.authorize › Laravel\Passport › AuthorizationController@authorize
POST      oauth/authorize ..... passport.authorizations.approve › Laravel\Passport › ApproveAuthorizationController@approve
DELETE    oauth/authorize .............. passport.authorizations.deny › Laravel\Passport › DenyAuthorizationController@deny
GET|HEAD  oauth/clients .............................. passport.clients.index › Laravel\Passport › ClientController@forUser
POST      oauth/clients ................................ passport.clients.store › Laravel\Passport › ClientController@store
PUT       oauth/clients/{client_id} .................. passport.clients.update › Laravel\Passport › ClientController@update
DELETE    oauth/clients/{client_id} ................ passport.clients.destroy › Laravel\Passport › ClientController@destroy
GET|HEAD  oauth/personal-access-tokens passport.personal.tokens.index › Laravel\Passport › PersonalAccessTokenController@f…
POST      oauth/personal-access-tokens passport.personal.tokens.store › Laravel\Passport › PersonalAccessTokenController@s…
DELETE    oauth/personal-access-tokens/{token_id} passport.personal.tokens.destroy › Laravel\Passport › PersonalAccessToke…
GET|HEAD  oauth/scopes ..................................... passport.scopes.index › Laravel\Passport › ScopeController@all
POST      oauth/token ................................ passport.token › Laravel\Passport › AccessTokenController@issueToken
POST      oauth/token/refresh ................ passport.token.refresh › Laravel\Passport › TransientTokenController@refresh
GET|HEAD  oauth/tokens ................. passport.tokens.index › Laravel\Passport › AuthorizedAccessTokenController@forUser
DELETE    oauth/tokens/{token_id} .... passport.tokens.destroy › Laravel\Passport › AuthorizedAccessTokenController@destroy
```

### ログインリダイレクトの対応
Inertia を使用している場合に `redirect()` を使用すると iFrame でリダイレクト先が表示されるという問題が発生しました。
iFrame ではなく通常のリダイレクトにするためには `Inertia::location(redirect())` にする必要があります。
OAuth2 のログインリダイレクトでこの現象が発生してしまうので、 Fortify のログイン後の処理をオーバーライドして、正常にリダイレクトするようにします。

```php:app/Http/Responses/LoginResponse.php
+ <?php

+ namespace App\Http\Responses;

+ use Inertia\Inertia;
+ use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
+ use Laravel\Fortify\Fortify;

+ class LoginResponse implements LoginResponseContract
+ {
+     /**
+      * Create an HTTP response that represents the object.
+      *
+      * @param  \Illuminate\Http\Request  $request
+      * @return \Symfony\Component\HttpFoundation\Response
+      */
+     public function toResponse($request)
+     {
+         return $request->wantsJson()
+                     ? response()->json(['two_factor' => false])
+                     : Inertia::location(redirect()->intended(Fortify::redirects('login')));
+     }
+ }
```

```php:app/Providers/FortifyServiceProvider.php
+ use App\Http\Responses\LoginResponse;
+ use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;

  public function register()
  {
+     $this->app->singleton(LoginResponseContract::class, LoginResponse::class);
  }
```

### トークン取得 API の CORS 許可
Laravel Passport のパスが CORS の許可対象になっていないので、 `oauth/token` を追加します。

```php:config/cors.php
- 'paths' => ['api/*', 'sanctum/csrf-cookie'],
+ 'paths' => ['api/*', 'sanctum/csrf-cookie', 'oauth/token'],
```

### ユーザ情報取得 API の準備
検証用にユーザ情報を取得する API を追加します。

```php:routes/api.php
+ Route::middleware('auth:api')->get('/user', function (Request $request) {
+     return $request->user();
+ });
```

## チーム開発用に整備
チームで開発する場合はこのままだと不便なため、整備していきます。

### .env.example の変更
.env.example をコピーしてすぐに使えるようにするため、環境変数例を修正します。

```text:.env.example
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://proto-laravel-9.test

LOG_CHANNEL=stack
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug

DB_CONNECTION=pgsql
DB_HOST=pgsql
DB_PORT=5432
DB_DATABASE=proto_laravel_9
DB_USERNAME=sail
DB_PASSWORD=password

BROADCAST_DRIVER=log
CACHE_DRIVER=file
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync
SESSION_DRIVER=file
SESSION_LIFETIME=120

MEMCACHED_HOST=memcached

REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379

MAIL_MAILER=smtp
MAIL_HOST=mailhog
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS=hello@example.com
MAIL_FROM_NAME="${APP_NAME}"

AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false

PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_APP_CLUSTER=mt1

MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
```

### 既存のアプリケーションの Composer 依存関係のインストール
`laravel new` によってアプリケーションを作成した場合は `sail` コマンドを使用することができますが、 GitHub からクローンしてくると vender フォルダが存在しないため、別途 `composer install` を実行する必要があります。

https://laravel.com/docs/9.x/sail#installing-composer-dependencies-for-existing-projects

```sh
proto-laravel-9 % docker run --rm \
    -u "$(id -u):$(id -g)" \
    -v $(pwd):/var/www/html \
    -w /var/www/html \
    laravelsail/php81-composer:latest \
    composer install --ignore-platform-reqs
```

### チーム開発用の環境構築スクリプトの作成
環境構築用のスクリプトを作成します。
スクリプトを実行すると `composer install` `php artisan key:generate` `php artisan passport:keys --force` が実行され、 .env ファイルが作成されます。

```sh:setup.sh
#!/usr/bin/env bash

docker run --rm \
    -v $(pwd):/opt \
    -w /opt \
    laravelsail/php81-composer:latest \
    bash -c "composer install --ignore-platform-reqs && cp -n .env.example .env && php artisan key:generate && php artisan passport:keys --force"
```

```sh
proto-laravel-9 % bash setup.sh
```

## 備考
実際に運用される際は CORS を適切に設定してください。
この記事の OAuth2 サーバを使用したクライアントアプリケーションを作成する記事を書く予定です。