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


先日書いた [Laravel+Vue&PKCEのSPA環境構築メモ](https://qiita.com/miyapei/items/ed1efcf19c2062490bc4) 

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

のReact版も作成してみました。
※ Laravel側は全く同じなので割愛し、見出しを合わせて差分のところだけ記載しています

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

## 構成

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

（割愛）

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

- Vite
- React 17
- TypeScript
- react-router
- js-pkce

## React+Vite+TypeScript環境の構築

Vue版との違いは、react-tsのテンプレートを使用しているというだけです。
あとはおなじ。

```bash:bash
yarn
yarn create vite temp --template react-ts
```

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

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


### SPA側の実装

#### react-router, js-pkceをインストール

```bash:bash
yarn add react-router-dom@6 js-pkce
```

#### ルーティングの設定

```tsx:client/src/routes/Router.tsx
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { About } from '../views/About';
import { AuthorizationCallback } from '../views/Auth/AuthorizationCallback';
import { Home } from '../views/Home';

export const Router = () => {
  return (
    <BrowserRouter>
      <Routes>
        <Route path='/' element={<Home />} />
        <Route path='/about' element={<About />} />
      </Routes>
    </BrowserRouter>
  );
};
```

App.tsxのコンテンツはreact-routerの中身（&lt;router&gt;）のみにしておきます
※ Vue版とファイル構成も合わせているだけで、別にApp.tsxを使う必要はありません

```tsx:client/src/App.tsx
import './App.css';
import { Router } from './routes/Router';

function App() {
  return <Router />;
}

export default App;
```

##### MasterPage.tsx(Layout)を追加しておく


未認証時に丸ごと非表示にしたいため、  
別途レイアウト用のVueを作成してその中にコンテンツページを表示するようにしておきます。

Vue版ではLayout.vueになっています。
※ ant-designを入れた際にLayoutというコンポーネント名が被ったため…

```tsx:client/src/views/MasterPage.tsx
import { Link, Outlet } from 'react-router-dom';

export const MasterPage = () => {
  return (
    <div id="nav">
      <Link to='/'>Home</Link> |
      <Link to='/about'>About</Link>
    </div>
    <Outlet />
  );
};
```

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

```tsx:client/src/routes/Router.tsx
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { About } from '../views/About';
import { Home } from '../views/Home';
import { MasterPage } from '../views/MasterPage';

export const Router = () => {
  return (
    <BrowserRouter>
      <Routes>
        <Route path='/' element={<MasterPage />}>
          <Route path='/' element={<Home />} />
          <Route path='/about' element={<About />} />
        </Route>
      </Routes>
    </BrowserRouter>
  );
};
```

ネストされたルートについての詳細はこちら
https://reactrouter.com/docs/en/v6/getting-started/tutorial#nested-routes


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

Vue版ではProvide/Injectという機能を使用しました。
Reactにも同様のContextという機能があり、これを使用します。

```tsx:client/src/store/AuthContext.tsx
import { createContext, useContext, useState } from 'react';

class AuthTokens {
  constructor(public accessToken = '', public refreshToken = '') {}
}

// ProviderProps
const authProps = (
  tokens = new AuthTokens(),
  handleSetTokens = (accessToken = '', refreshToken = '') => {}
) => {
  const hasToken = () => tokens.accessToken !== '';

  return { tokens, handleSetTokens, hasToken };
};
export type AuthProps = ReturnType<typeof authProps>;

// Context
let AuthContext = createContext(authProps());

// Provider
export const AuthProvider = ({ children }: any) => {
  const [tokens, setTokens] = useState(new AuthTokens());
  const handleSetTokens = (accessToken = '', refreshToken = '') => {
    setTokens({ ...tokens, ...new AuthTokens(accessToken, refreshToken) });
  };
  const props = authProps(tokens, handleSetTokens);

  return <AuthContext.Provider value={props}>{children}</AuthContext.Provider>;
};

// Consumer
export const useAuthContext = () => useContext(AuthContext);
```

Vue版だと上記をプラグインの機能を使用してアプリ全体に適用しました。
ReactではProviderの子要素で使用することができるので、こちらも最上位から適用します

```tsx:client/src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import { AuthProvider } from './store/AuthContext';    // ← 追記

ReactDOM.render(
  <React.StrictMode>
    <AuthProvider>    {/* ← 追記 */}
      <App />
    </AuthProvider>
  </React.StrictMode>,
  document.getElementById('root')
);
```

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

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

を作成します。

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

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

というコンポーネント

```tsx:client/src/components/Auth/AuthGuard.tsx
import PKCE from 'js-pkce';
import { useAuthContext } from '../../store/AuthContext';

export const AuthGuard = ({ children }: any) => {
  const { hasToken } = useAuthContext();

  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());
    return <></>;
  } else {
    return <>{children}</>;
  }
};
```

を作成。

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

```tsx:client/src/views/MasterPage.tsx
import { Link, Outlet } from 'react-router-dom';
import { AuthGuard } from '../components/Auth/AuthGuard';

export const MasterPage = () => {
  return (
    <AuthGuard>
      <div id="nav">
        <Link to='/'>Home</Link> |
        <Link to='/about'>About</Link>
      </div>
      <Outlet />
    </AuthGuard>
  );
};
```

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

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

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

```tsx:client/src/views/Auth/AuthorizationCallback.tsx
import PKCE from 'js-pkce';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuthContext } from '../../store/AuthContext';

export const AuthorizationCallback = () => {
  const pkce = new PKCE({
    client_id: '1',
    redirect_uri: location.origin + '/auth/callback',
    token_endpoint: 'http://localhost/server/oauth/token',
  });
  const { handleSetTokens } = useAuthContext();
  const navigate = useNavigate();

  useEffect(() => {
    pkce.exchangeForAccessToken(document.location.href).then((response) => {
      handleSetTokens(response.access_token, response.refresh_token);
      // 認証後に遷移するページへ
      navigate('/', { replace: true });
    });
  }, []);

  return <></>;
};
```

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


```tsx:client/src/routes/Router.tsx
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { About } from '../views/About';
import { AuthorizationCallback } from '../views/Auth/AuthorizationCallback'; // ← 追加
import { Home } from '../views/Home';
import { MasterPage } from '../views/MasterPage';

export const Router = () => {
  return (
    <BrowserRouter>
      <Routes>
        <Route path='/auth/callback' element={<AuthorizationCallback />} /> {/* ← 追加  */}
        <Route path='/' element={<MasterPage />}>
          <Route path='/' element={<Home />} />
          <Route path='/about' element={<About />} />
        </Route>
      </Routes>
    </BrowserRouter>
  );
};
```

## 一応完了
というわけでとりあえずこれでVue版同様、
React側にアクセスするとトークンがないためLaravelに遷移、
認証・承認後トークンをゲットして、ページがみられるようになると思います。
![laravel-react-spa-pkce.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222509/25759887-e22c-e9a8-581c-b9fe1362ee25.gif)
（これはAntDesignなどが入ってるので見た目違うと思いますが…）

## 締め
Reactはまだ全然でして…
おかしな点などがありましたらこっそりご教示ください…orz