Oktaを使ってReactにログイン機能を実装する

# 成果物
サイトトップ
![a_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/349281/2ccf1670-7da8-922b-13b0-a4b2aa588917.png)
「ログイン」 -> Oktaのサイイン画面
![a_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/349281/8c2bcaf5-5b38-e8a5-01f6-15bbfeb3dde6.png)
サイトトップに戻り（リダイレクト）、Oktaのユーザー情報が表示される。
![a_3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/349281/49932ae0-2089-94da-a28b-476969e4ca68.png)

## 環境

- React
    - ^18.2.0
- react-router-dom
    - ^6.9.0
- @okta/okta-react
    - ^6.7.0
- @okta/okta-auth-js
    - ^7.2.0

※主なもののみ抜粋

# 想定する読者

- Oktaのみでログイン機能を実装してみたい人
- 次に紹介する「ハマったポイント」にハマっている人

※Okta+CognitoでReactみたいな事をしたい方は↓↓

https://qiita.com/gossan/items/c2352c6696c9527f1327

# ハマったポイント

## idTokenに含まれるユーザー情報が少ない問題

### 事象

OktaユーザーのProfile情報が全て含まれている事を期待していたが違った。

例えば下記OktaユーザーのFirst nameとLast nameを取得したかったがidTokenの中に無かった。

↓ユーザー情報↓
![a_5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/349281/696cba74-68f1-0b3c-50ba-c902ce7e2139.png)
↓idToken↓
![a_4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/349281/4743fc0e-0880-5405-8572-e121ce9d9707.png)

### 解決方法

サイイン後に`getUserInfo()`という関数を呼ぶ必要があった。

## OktaのAPIを叩く際にCORSではねられる問題
### 事象

サインアウト時に下記エラーが発生してしまった。
![a_6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/349281/0ed4f058-318b-40fd-6e2f-5372e2ee0cf1.png)

### 解決方法

Oktaの設定画面からSecurity > API > Trusted OriginsでCORSの許可設定をする。

## 何やらWarningが出てる問題（動くものの）
### 事象

> Two custom restoreOriginalUri callbacks are detected. The one from the OktaAuth configuration will be overridden by the provided restoreOriginalUri prop from the Security component.

![スクリーンショット 2023-03-25 14.21.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/349281/870dfcf1-64e4-9be9-55ff-80a2d52bca30.png)

### 解決方法

https://github.com/okta/okta-react/issues/227

ReactのStrictModeでコンポーネントが2回描画される事に起因する問題だった。上記リンクを参考に、useEffectのクリーンアップ関数でゴニョゴニョする。

# Oktaの設定

## 基本設定

okta developerトップ ->　左メニュー「Applications」 -> 「Create App Integration」
![o_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/349281/6f35db40-33d9-09ce-c352-f87b09f0dadc.png)
- [Sign-in method]
    - OIDC - OpenID Connect
- [Application type]
    - Single-Page Application
    
![o_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/349281/655755c0-c6b0-5b5e-b1e2-a921dd005d3e.png)

- [App integration name]
    - My React App（※任意でOK）
- [Grant type]
    - Authorization Code
- [Sign-in redirect URIs]
    - http\://localhost:3000/login/callback
- [Sign-out redirect URIs]
    - http\://localhost:3000

![o_3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/349281/c2024fb9-0b6b-4169-facd-33d0edc107ac.png)

- Base URIs
    - 空でOK
- Assignments
    - Skip group assignment for now
  
![o_4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/349281/a900a2db-fc23-7ef5-ada4-8c1bff510fbb.png)

## ユーザーをアサインする

上記で作ったサイインの仕組みを使えるOktaユーザーをアサインします。

「My React App」 -> 「Assignments」タブ -> 「Assign」ボタン　-> 「Assign to People」
![o_5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/349281/42c3791d-1a2b-4dee-cb6f-57ee8e289e89.png)
アカウントに紐付いたユーザー一覧が出てくるので、ユーザーを「Assign」します。Assignしたユーザーが、これから作るReactアプリにログイン出来るユーザーになります。
![o_6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/349281/9faa96e9-8d2e-9b69-6a80-a7e1369f25a3.png)
↓「Assign」後のイメージ↓
![o_7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/349281/f715df87-840d-2aef-196a-029d812056f9.png)

## CORSの許可

OktaのAPIを叩けるドメインの設定をします。

「ハマったポイント」で紹介した「サインアウト時に下記エラーが発生してしまった」問題の原因です。

これをしないとOktaのAPIを上手く叩けずにアプリケーション側（React側）でエラーが吐き出されます。

左メニュー「Security」 -> 「API」 -> 「Trusted Origins」 タブ -> 「+ Add origin」
![スクリーンショット 2023-03-26 8.54.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/349281/41c5fdc7-0414-1522-3841-c2a32f5fb422.png)

![o_10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/349281/edb57b5c-eac1-b888-af39-ffea4324721a.png)

- [Origin name]
    - localhost:3000
- [Origin URL]
    - http\://localhost:3000
- [Choose Type]
    - 下記２つをチェック
        - Cross-Origin Resource Sharing(CORS)
        - Redirect

Oktaの設定は以上になります。

# Reactアプリの作成

create-react-appで作成します。

VITEなど他のツールで作成する場合は、ポートが変わるので先ほど「Add Origin」で設定したコールバックURLの変更が必要になりますので注意してください。

ご自身の作業ディレクトリでReactプロジェクトを作成します。

```
npx create-react-app react-okta-20230326
```
## 追加ライブラリ
上記で作成したReactプロジェクトに必要なライブラリを追加していきます。
```
yarn add @okta/okta-react @okta/okta-auth-js react-router-dom
```
## ディレクトリ構成
完成品のディレクトリ構成です。
```
.
├── node_modules/
├── package-lock.json
├── package.json
├── public
│   ├── favicon.ico
│   ├── index.html
│   ├── logo192.png
│   ├── logo512.png
│   ├── manifest.json
│   └── robots.txt
├── src
│   ├── App.css
│   ├── App.js
│   ├── App.test.js
│   ├── config
│   │   └── auth.js
│   ├── hooks
│   │   └── use-auth.js
│   ├── index.css
│   ├── index.js
│   ├── logo.svg
│   ├── reportWebVitals.js
│   └── setupTests.js
└── yarn.lock

```
## 実装
下記手順で進めます。

1. config/auth.jsを作成
1. hooks/use-auth.jsを作成
1. index.jsを編集
1. App.jsを編集
1. App.cssを編集

## config/auth.jsを作成
### Client ID
![o_12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/349281/603a1942-d1a1-eb23-3c14-5796dd706b3e.png)
### アカウントのドメイン
![o_13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/349281/394292c0-05a1-d9dd-9c30-2f45d5ac4a09.png)
```js:config/auth.js
// 例）0123456789abcdefghij
const CLIENT_ID = 'Client ID';
// 例）https://dev-12345678.okta.com/oauth2/default
const ISSUER = 'https://アカウントのドメイン/oauth2/default';
const REDIRECT_URI = `${window.location.origin}/login/callback`;

// eslint-disable-next-line
export default {
  oidc: {
    clientId: CLIENT_ID,
    issuer: ISSUER,
    redirectUri: REDIRECT_URI,
    scopes: [
      "openid",
      "email",
      "profile",
    ],
    pkce: true,
  }
};
```
## hooks/use-auth.jsを作成
```js:hooks/use-auth.js
import React, { createContext, useContext, useEffect, useState } from 'react';
import { useOktaAuth } from '@okta/okta-react';
import { OktaAuth, toRelativeUrl } from '@okta/okta-auth-js';
import config  from '../config/auth';

export const oktaAuth = new OktaAuth(config.oidc);

const authContext = createContext({});

export const ProvideAuth = ({children}) => {

  const auth = useProvideAuth();
  return (
    <authContext.Provider value={auth}>
      {children}
    </authContext.Provider>
    )
  ;
}

export const useAuth = () => {
  return useContext(authContext);
}

const useProvideAuth = () => {

  const { authState } = useOktaAuth();

  const [isAuthenticated, setIsAuthenticated] = useState(false);
  const [username, setUserName] = useState('ゲスト');
  const [user, setUser] = useState(null);

  useEffect(() => {
    if(!authState || !authState.isAuthenticated) return;
    console.log(authState);
    oktaAuth.token.getUserInfo()
      .then(e => {
        setUser(e);
        setUserName(e.name);
        setIsAuthenticated(true);
      });
  }, [authState]);

  const signIn = () => {
    const originalUri = toRelativeUrl(window.location.href, window.location.origin);
    oktaAuth.setOriginalUri(originalUri);
    oktaAuth.signInWithRedirect();
  };

  const signOut = async () => {
    oktaAuth.signOut();
    setUser(null);
    setIsAuthenticated(false);
  };

  return {
    isAuthenticated,
    user,
    username,
    signIn,
    signOut
  }
}
```
ポイントは`useProvideAuth`の`useEffect`で`oktaAuth.token.getUserInfo()`を使ってユーザー情報を取得している点です。

「ハマったポイント」で挙げた「idTokenに含まれるユーザー情報が少ない問題」の解決方法になります。

とは言え、idTokenにもユーザー名やemailなど基本的な情報は入っているので、それで事足りる場合は、上記関数を呼ばずともidTokenから取得出来ます。

ちなみに、Oktaでユーザー情報の追加は、 左メニュー「Directory」->「People」からユーザーを選択後、「Profile」タブで出来ます。
![スクリーンショット 2023-03-26 9.55.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/349281/de8e2a32-5c87-21ca-0083-f73b808771ae.png)

## index.jsを編集
```js:index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { BrowserRouter } from 'react-router-dom';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </React.StrictMode>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
```
## App.jsを編集
```js:App.js
import './App.css';
import { useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { toRelativeUrl } from '@okta/okta-auth-js';
import { Security } from '@okta/okta-react';
import { Routes, Route } from 'react-router-dom';
import { LoginCallback } from '@okta/okta-react';

import { useAuth, oktaAuth, ProvideAuth } from './hooks/use-auth';

const Profile = () => {

  const { isAuthenticated, user, username, signIn, signOut } = useAuth();

  return (
    <>
      <main className='App'>
        <p>ようこそ、{ username }さん</p>
        {
          isAuthenticated ? (
            <>
              <ul>
                { 
                  Object.keys(user)
                    .filter(key => key !== 'headers')
                    .map(key => (
                      <li key={key}> {key} : {user[key]} </li>
                    ))
                }
              </ul>
              <button onClick={() => signOut()}>ログアアウト</button>
            </>
          ) : (
            <button onClick={() => signIn()}>ログイン</button>
          )
        }
        
      </main>
    </>
  );
}

const App = () => {

  const navigate = useNavigate();
  const restoreOriginalUri = useCallback((_oktaAuth,  originalUri) => {
    navigate(toRelativeUrl(originalUri || '/', window.location.origin));
  }, [navigate])

  useEffect(() => {
    return () => {
      oktaAuth.options.restoreOriginalUri = undefined
    }
  }, [])

  return (
    <>
      <Security oktaAuth={oktaAuth} restoreOriginalUri={restoreOriginalUri}>
        <ProvideAuth>
          <Routes>
            <Route path="/" exact={true} element={<Profile />}/>
            <Route path="login/callback" element={<LoginCallback />}/>
          </Routes>
        </ProvideAuth>
      </Security>
    </>
  );

};
export default App;
```
ポイントは`useEffect`でクリーンアップ関数を設定している点です。

「ハマったポイント」で挙げた「何やらWarningが出てる問題（動くものの）」の解決方法になります。

## App.cssを編集
```css:App.css
.App {
  background-color: #282c34;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  font-size: calc(10px + 2vmin);
  color: white;
}

button {
  font-size: calc(10px + 2vmin);
}

p {
  margin-bottom: 0;
}

ul {
  list-style: none;
  font-size: 16px;
  border: 1px solid #fff;
  padding: 1em;
  margin: 1em 0;
}
```
以上です。

http\://localhost:3000にアクセスすると下図画面が表示されるはずです。
![a_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/349281/cf83147a-bed1-ceb0-f83f-7a1b75211214.png)
ログインする際は「Oktaの設定」>「ユーザーをアサインする」でアサインしたユーザーでログインして下さい。

ログイン成功すると下記のように自分の情報が表示されるはずです。
![a_3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/349281/99a4c94c-04db-3524-fe00-64766d4ffe53.png)

# おわりに
「idTokenに含まれるユーザー情報が少ない問題」の解決はちょっと大変でした。

休日に手を動かしていると、どうしても楽しようとしてしまい、何も考えずにアレやこれやと手を動かしてしまう、結果だいぶ時間を溶かした気がします。

休日でも（ ~~業務中でも~~ ）公式ドキュメントをサボらずに読む強い心が欲しいですー。