Laravel+Vue&PKCEのSPA環境構築メモ

コンテンツ作るぞ…よりそのスタートラインに持ってくのが大変だったりしますよね…
実務でも深追いできずにコンテンツのほう入ってたりしたので
そんなこんなで学習＆アウトプットも兼ねて
ローカルで1から環境構築してみた際のメモを書いてみました。

[https://github.com/pei-miyapei/laravel-vue-spa](https://github.com/pei-miyapei/laravel-vue-spa)
（こちらは簡単なAPIの実装とUIフレームワーク（ElementPlus）を導入した状態です）

## 追記：React版も作成しました

※ SPAフロントエンド側のVueの箇所のみを、Reactで書いたものになります

https://qiita.com/miyapei/items/af448a4a9f3bcfa78d64

## 構成

### API server コンテナ（SPA バックエンド、ユーザー管理）

- nginx
- php 8
- laravel (passport, breeze)
- mysql 8

### SPA クライアントコンテナ（SPA フロントエンド）

- Vite
- Vue 3.2
- TypeScript
- vue-router
- js-pkce

### 認証

- Authorization Code Grant Flow with Proof Key for Code Exchange（PKCE 4?）

クライアント側に [js-pkce](https://www.npmjs.com/package/js-pkce) 、IdP 側に [Laravel Passport](https://readouble.com/laravel/8.x/ja/passport.html) を利用する形です

このデモではトークンを永続化していません。
リロードしたら消えますが、Laravel側のセッションが生きていれば認証は飛ばせます。
（必要があればCookieとかに保存するなど）

#### 参考
OAuthフローについて
https://qiita.com/TakahikoKawasaki/items/200951e5b5929f840a1f
認可コードフロー+PKCEについては
https://zenn.dev/zaki_yama/articles/oauth2-authorization-code-grant-and-pkce
https://www.osscons.jp/joar0xbhj-537/

すでに同様の実装を行っている方々の記事など（やや構成など異なります）
[https://wonwon-eater.com/laravel-oauth-pkce/](https://wonwon-eater.com/laravel-oauth-pkce/)
https://qiita.com/okmt_okmt_/items/f70c7552b0ecba3375f6


## コンテナ
https://github.com/pei-miyapei/laravel-vue-spa/tree/main/docker
DockerfileはURLの通りです。

開発はVSCodeで行っています
server/(laravel側)、client/(Vue側)に
それぞれリモートコンテナで接続して進めていきます。
最初の段階ではいずれも中身は.devcontainerのみの状態です

初期データベースは dev_db というDBが作成されるようになっています。

## Laravelインストール

composerを使用してLaravelをインストールします。
server/ 直下に用意しますが、空でないディレクトリにインストールできないので、
適当なディレクトリ(temp)にインストールしてから

```bash:bash
composer create-project laravel/laravel temp
```
1階層上に移動しました
（VSCode上で普通に移動しました）

http&#58;//localhost/server/
でLaravelの画面が表示されるか確認。

最初は storage フォルダのパーミッションのエラーが出るので chown します

```bash:bash
chown -R www-data:www-data storage/
```

### .envの編集
DB(コンテナ)に接続できるようにしておきます。

```text:server/.env
DB_HOST=db
DB_DATABASE=dev_db
DB_USERNAME=root
DB_PASSWORD=root
```

ちなみにDB自体の確認用には私は [Adminer](https://www.adminer.org/) をよく使います。
server/public/adminer.php を置いて接続確認したりします。

### タイムゾーンの設定
Laravelからnow()などで登録した日時が日本時間になってなかったので…


```php:server/config/app.php
-    'timezone' => 'UTC',
+    'timezone' => 'Asia/Tokyo',
```

#### その他
あと環境構築とは関係ないですが、私はフォーマッターにphp-cs-fixerを使ってるので
.php-cs-fixer.dist.php を作成しておくなど…
とりあえずLaravel本体の準備はこんなとこです

## Vue+Vite+TypeScript環境の構築
Cliは爆速なViteを使用します。
TypeScriptのテンプレートをclient/直下にインストールしますが、
Laravel同様空のディレクトリでないと…なのでこちらも適当なディレクトリ(temp)にインストールして

```bash:bash
yarn
yarn create vite temp --template vue-ts
```
１階層上に移動しました

移動したら、諸々のモジュールをインストールします

```bash:bash
yarn
```

たぶんこの状態でも実行して画面を確認できます。
デフォルトだと3000番ポートが使用されます。

```bash:bash
yarn dev --host
```

localhostの場合、--hostがないとDockerにアクセスできないぽかったですが
このあたりの設定は vite.config.ts で調整可能です
なおローカル~~で面倒~~だったのでとりあえず全体的にhttpで構成しています

```typescript:vite.config.ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()],
  server: {
    host: true,
    port: 3000,
    // https: true,
  },
});

```

上記設定後は

```bash:bash
yarn dev
```

のみで起動できます。

#### その他
##### コード補完について
補完についてはいろいろパターンがあるようですが
Vue3にはVolarが良いようなので
私はVolar + Take Over Mode を使用しています。

Take Over Modeについては下記
https://github.com/johnsoncodehk/volar/discussions/471
> Take Over Modeは、VSCodeの組み込みTypeScript拡張を使用せず、
> Vue言語サーバーを使用してVue + TS言語サポートを提供します。

* Vetur（とVue 3 snippets）は競合するため削除
* 拡張機能で `@builtin typescript` を検索して  
  `TypeScript and JavaScript Language Features` を `無効にする（ワークスペース）`  
  （=VSCodeの組み込みTypeScript拡張を無効化）

## 認証周りの構築
とりあえずここまででLaravelとVue環境は揃った状態です。

### Laravelに認証機能（Laravel Passport）を追加

ユーザー認証、トークン認証を使えるようにするため [Laravel Passport](https://readouble.com/laravel/8.x/ja/passport.html) をインストールします。
おおよそ上記URLの「インストール」の項をなぞっていくだけです。
（ここでは `passport:install` 使っていないなど多少異なります）

Laravel側のコンテナで

```bash:bash
composer require laravel/passport
php artisan migrate
```

#### 暗号化キーの生成
次に、暗号化キーを生成します

```bash:bash
php artisan passport:keys
```

※ `php artisan passport:install` でも生成されます。
　 ただパスワード認証やパーソナルアクセストークンのクライアントなども登録されます
　 （今回使用してない）
https://readouble.com/laravel/8.x/ja/passport.html
https://qiita.com/TakahikoKawasaki/items/200951e5b5929f840a1f

#### UserモデルとAuthServiceProviderの修正

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

AuthServiceProviderにPassport::routes()を追加します。
認証方法を制限する場合、以下のように引数で指定できます。
参考：[https://qiita.com/h1na/items/25a08122418df782d2b9](https://qiita.com/h1na/items/25a08122418df782d2b9)


```php:server/app/Providers/AuthServiceProvider.php
class AuthServiceProvider extends ServiceProvider
{
    /**
     * The policy mappings for the application.
     *
     * @var array<class-string, class-string>
     */
    protected $policies = [
        'App\Models\Model' => 'App\Policies\ModelPolicy',
    ];

    /**
     * Register any authentication / authorization services.
     */
    public function boot(): void
    {
        $this->registerPolicies();

        if (!$this->app->routesAreCached()) {
            Passport::routes(function (RouteRegistrar $router): void {
                $router->forAuthorization();
                $router->forAccessTokens();
            });
        }

        Passport::tokensExpireIn(now()->addMinutes(30));
        Passport::refreshTokensExpireIn(now()->addDays(5));
    }
}
```

#### config/auth.php の修正
`api` を追加

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

    'api' => [
        'driver' => 'passport',
        'provider' => 'users',
    ],
],
```

#### PKCE対応のクライアント生成
[https://readouble.com/laravel/8.x/ja/passport.html](https://readouble.com/laravel/8.x/ja/passport.html)
こちらの「PKCEを使った認可コードグラント」の項ですが、
認可リクエストする側はVueで実装しますので、クライアントの生成のみです。

ここで指定するURLは、認証後にVue側にリダイレクトするURLになります。
ここでは初期値の `/auth/callback` に戻る前提で進めます

```bash:bash
php artisan passport:client --public

 Which user ID should the client be assigned to?:
 > 1

 What should we name the client?:
 > demo

 Where should we redirect the request after authorization? [http://localhost/auth/callback]:
 > http://localhost:3000/auth/callback

New client created successfully.
Client ID: 1
Client secret: 
```

※ ただこれは oauth_clients テーブルにレコードが登録されるだけですので、
　 私は後ほどSeederで登録するようにしました

#### CORS設定
localhost同士ですがトークンを交換する際、
Vue側が3000番ポートで異なるオリジンからのアクセスになるのでCORSの設定が必要です。

```diff:server/config/cors.php
-    'paths' => ['api/*', 'sanctum/csrf-cookie'],
-    'allowed_origins' => ['*'],
+    'paths' => ['api/*', 'sanctum/csrf-cookie', 'oauth/token'],
+    'allowed_origins' => ['http://localhost:3000'],
```

#### 認証画面を作成
ユーザーの管理、認証はLaravel側が行うので認証画面を作成します。
こちらもシンプルな[Laravel Breeze](https://readouble.com/laravel/8.x/ja/starter-kits.html)を使用します。

##### Laravel Breezeをインストール

```bash:bash
composer require laravel/breeze --dev
php artisan breeze:install
npm install
npm run dev
```
超便利ですね！

##### ユーザーを作成
今のところユーザーがいないので、`/register` でユーザーを作成できます。
http&#58;//localhost/server/register

※ 後ほどSeederで登録するようにしました

/login でログイン画面が表示され、登録したユーザーでログインできます。
http&#58;//localhost/server/login


### SPA側の実装
ここからはVue側で実装していきます。

* ルーティングの設定
* トークンを一時的に保持する入れ物
* トークンチェック＆認可リクエストするガワページ  
  （レイアウトというかコンテンツのコンテナというか…囲うだけのページです）
* 認可コードコールバック用ページ  
  （Laravelで認証後戻ってくるページ。受け取った認可コードとトークンを交換します。  
  　Laravel側で指定した `/auth/callback` のページになります。）

#### vue-router, js-pkceをインストール
ルーティングにvue-router、
PKCE周りの処理は [js-pkce](https://www.npmjs.com/package/js-pkce) を使用しますのでインストールしておきます

```bash:bash
yarn add vue-router@next js-pkce
```

#### ルーティングの設定
ページを作りつつルーティングも設定していくので先に簡単に書いておきます…
パスと対応したコンポーネントを指定することでURLを設定します。

初めての方は以下が参考になります。
https://www.vuemastery.com/blog/vue-router-a-tutorial-for-vue-3/

Home.vue、About.vueはコンテンツになるページです。

```typescript:client/src/routes/router.ts
import { createWebHistory, createRouter } from 'vue-router';
import HomeVue from '../views/Home.vue';
import AboutVue from '../views/About.vue';

const routes = [
  {
    path: '/',
    name: 'Home',
    component: HomeVue,
  },
  {
    path: "/about",
    name: "About",
    component: AboutVue,
  },
];

const router = createRouter({
  history: createWebHistory(),
  routes,
});

export { router };
```

```typescript:client/src/main.ts
import { createApp } from 'vue';
import App from './App.vue';
import { router } from './routes/router';    // ← 追記

// ↓ routerを追加する形に書き換え
const app = createApp(App);
app.use(router);
app.mount('#app');
```

App.vueのコンテンツはvue-routerの中身（router-view）のみにしておきます

```html:client/src/App.vue
<template>
  <router-view />
</template>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}
</style>
```

これで / でHome.vueが /about でAbout.vue が表示されます
こんな感じです。

##### Layout.vueを追加しておく

ここでApp.vueにrouter-linkを書けばリンクが張れるわけですが、
未認証時に丸ごと非表示にしたいため、  
別途レイアウト用のVueを作成してその中にコンテンツページを表示するようにしておきます。

```html:client/src/views/Layout.vue
<template>
  <div id="nav">
    <router-link to="/">Home</router-link> |
    <router-link to="/about">About</router-link>
  </div>
  <router-view />
</template>
```

このコンポーネントの入れ子のルーティングは以下のように書けます

```typescript:client/src/routes/router.ts
import { createWebHistory, createRouter } from 'vue-router';
import HomeVue from '../views/Home.vue';
import AboutVue from '../views/About.vue';
import LayoutVue from '../views/Layout.vue';

const routes = [
  {
    path: '/',
    component: LayoutVue,
    children: [
      {
        path: '',
        name: 'Home',
        component: HomeVue,
      },
      {
        path: '/about',
        name: 'About',
        component: AboutVue,
      },
    ],
  },
];

const router = createRouter({
  history: createWebHistory(),
  routes,
});

export { router };
```

なおコンポーネントがネストされることと、ルートからのパスは無関係に設定できます。
ネストされたルートについての詳細はこちら
https://router.vuejs.org/ja/guide/essentials/nested-routes.html



#### トークンを保持する入れ物
これに関してはCookieなどに保存する場合は不要です。
ここでは変数内に持っているだけという状態で実装するため、その入れ物です。
（タブを閉じたら消えます）

```typescript:client/src/store/authContext.ts
import { inject, InjectionKey, readonly, ref } from 'vue';

export const authProps = () => {
  const accessToken = ref('');
  const refreshToken = ref('');

  const hasToken = () => accessToken.value !== '';

  const setTokens = (newAccessToken = '', newRefreshToken = '') => {
    accessToken.value = newAccessToken;
    refreshToken.value = newRefreshToken;
  };

  return readonly({
    accessToken,
    refreshToken,
    hasToken,
    setTokens,
  });
};
export type AuthProps = ReturnType<typeof authProps>;

export const AuthStateSymbol: InjectionKey<AuthProps> = Symbol('AuthState');

export const injectAuth = (): AuthProps => {
  const auth = inject(AuthStateSymbol);
  if (auth === undefined) {
    throw new Error('auth は provide されていません。');
  }

  return auth;
};
```

これをprovideするだけのプラグインを作成し、

```typescript:client/src/plugins/auth.ts
import { authProps, AuthStateSymbol } from '../store/authContext';

export const auth = {
  install(app: any) {
    app.provide(AuthStateSymbol, authProps());
  },
};
```

追加しておきます

```typescript:client/src/main.ts
import { createApp } from 'vue';
import App from './App.vue';
import { router } from './routes/router';
import { auth } from './plugins/auth';    // ← 追記

const app = createApp(App);
app.use(router);
app.use(auth);    // ← 追記
app.mount('#app');
```

#### ページの作成
残りの

* トークンチェック＆認可リクエストするガワページ  
* 認可コードコールバック用ページ  

を作成します。

##### トークンチェック＆認可リクエストするガワページ

* 認可が必要なページにかませて使用
* トークンがなければ認可リクエストを発行（if ～）
* トークンがなければデフォルトスロットの中身（コンテンツ）の描画を行わない（v-if）
* トークンは先ほど作成した、プラグインでprovideされた入れ物を召喚して確認（injectAuth）

というコンポーネント

```html:client/src/components/Auth/AuthGuard.vue
<script setup lang="ts">
import PKCE from 'js-pkce';
import { injectAuth } from '../../store/authContext';

const { hasToken } = injectAuth();

if (!hasToken()) {
  const pkce = new PKCE({
    client_id: '1', // `php artisan passport:client --public` したときのIDです
    redirect_uri: location.origin + '/auth/callback', // 戻ってくるURL
    authorization_endpoint: 'http://localhost/server/oauth/authorize', // Laravel側の認可エンドポイント
    requested_scopes: '*',
  });
  location.replace(pkce.authorizeUrl());
}
</script>

<template v-if="hasToken()">
  <slot />
</template>
```

を作成。

認証が必要なページにこのコンポーネントを導入します。
今回はコンテンツページは全部認証が必要なページとして、
最初に作成したグローバルメニューを持ったLayout.vueに仕込みます

```html:client/src/views/Layout.vue
<script setup lang="ts">
import AuthGuardVue from '../components/Auth/AuthGuard.vue';
</script>

<template>
  <AuthGuardVue>
    <div id="nav">
      <router-link to="/">Home</router-link> |
      <router-link to="/about">About</router-link>
    </div>
    <router-view />
  </AuthGuardVue>
</template>
```

##### 認可コードコールバック用ページ

Laravelで認証後戻ってくるページ。受け取った認可コードとトークンを交換します。
Laravel側で指定した `/auth/callback` のページになります。

js-pkceのexchangeForAccessTokenというメソッドでトークンを受け取れます。
それを用意した入れ物（injectAuth）に保存（auth.setToken）し、
ここでは再度トップ画面に戻ります

```html:src/views/Auth/AuthorizationCallback.vue
<script setup lang="ts">
import PKCE from 'js-pkce';
import { useRouter } from 'vue-router';
import { injectAuth } from '../../store/authContext';

const pkce = new PKCE({
  client_id: '1',　// `php artisan passport:client
  redirect_uri: location.origin + '/auth/callback',
  token_endpoint: 'http://localhost/server/oauth/token',
});
const { setTokens } = injectAuth();
const router = useRouter();

pkce.exchangeForAccessToken(document.location.href).then((response) => {
  setTokens(response.access_token, response.refresh_token);
  // 認証後に遷移するページへ
  router.replace({ name: 'Home' });
});
</script>

<template></template>
```

このページを /auth/callback のURLで公開します

```typescript:client/src/routes/router.ts
import { createWebHistory, createRouter } from 'vue-router';
import HomeVue from '../views/Home.vue';
import AboutVue from '../views/About.vue';
import AuthorizationCallbackVue from '../views/Auth/AuthorizationCallback.vue'; // ← 追加
import LayoutVue from '../views/Layout.vue';

const routes = [
  {
    path: '/auth/callback', // このブロックを追加
    name: 'TokenCallback',
    component: AuthorizationCallbackVue,
  },
  {
    path: '/',
    component: LayoutVue,
    children: [
      {
        path: '',
        name: 'Home',
        component: HomeVue,
      },
      {
        path: '/about',
        name: 'About',
        component: AboutVue,
      },
    ],
  },
];

const router = createRouter({
  history: createWebHistory(),
  routes,
});

export { router };
```

## 一応完了
とりあえずこれで、Vue側にアクセスするとトークンがないためLaravelに遷移、
認証・承認後トークンをゲットして、ページがみられるようになると思います。

![pkce4.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222509/580137fb-be01-5670-15c5-5845bb2c3e27.gif)
（これはElementPlusなどが入ってるので見た目違うと思いますが…）

### その後
あとは
* Laravel側にAPIを実装

```php:server/routes/api.php
Route::middleware('auth:api')->group(function (): void {
    Route::prefix('demos')->group(function (): void {
        Route::get('demo', [DemoController::class, 'find']);
        Route::put('demo', [DemoController::class, 'upsert']);
    });
});

```

* Vue側でトークンを使用してAPIをリクエストする

```typescript
axios.get('http://localhost/server/api/demos/demo', {
  headers: {
    Authorization: `Bearer ${token}`,
  }
})
```
（をもっと汎用的に）書いていく感じになります

## 締め

…と、通しで書いたら長くなってしまった…
最初にメモとか書きましたが実際にはあとからまとめて書きました…抜け・漏れがあるかもしれません。
おかしな点などがありましたらこっそりご教示ください…orz