React + Redux Toolkit の構成でcognitoの認証をやってみたメモ

## 概要
aws-amplifyの認証をシンプルに実装してみたメモ。
cognitoは[前回の疎通確認したメモ](https://qiita.com/hibohiboo/items/e9953ed2ba139c23b5bf)で作成したものに手を加えて使う。

[ソースコード(client)](https://github.com/hibohiboo/aws-cdk-v2/tree/id/5/client)
[ソースコード(api)](https://github.com/hibohiboo/aws-cdk-v2/blob/id/5/src/handler/api/hello.ts)
[ソースコード(cdk)](https://github.com/hibohiboo/aws-cdk-v2/blob/id/5/cdk/lib/LambdaWithCognitoStack.ts)
[cognitユーザプール作成コマンド](https://github.com/hibohiboo/aws-cdk-v2/blob/id/5/bin/cognito/cognito-add-user.sh.sample)

## 環境
+ Windows10
+ gitbash
+ aws-cli/2.7.21 Python/3.9.11 Windows/10 exe/AMD64 prompt/off
* cdk v2.35
* node v16.16.0


## 変更点
### ユーザプールクライアント
[amazon-cognito-identity-jsではクライアントシークレットをサポートしないので false に設定](https://github.com/aws-amplify/amplify-js/tree/main/packages/amazon-cognito-identity-js#configuration)
> When creating the App, the generate client secret box must be unchecked because the JavaScript SDK doesn't support apps that have a client secret.

```diff:cdk/lib/LambdaWithCognitoStack.ts
    const userPoolClient = userPool.addClient('client', {
      oAuth: {
        scopes: [
          cognito.OAuthScope.EMAIL,
          cognito.OAuthScope.OPENID,
          cognito.OAuthScope.PROFILE,
        ],

        callbackUrls: props.callbackUrls,
        logoutUrls: props.logoutUrls,
        flows: { authorizationCodeGrant: true },

      },
+      generateSecret: false,
      idTokenValidity: Duration.minutes(5)
    })
```

### ApiGateway

ブラウザから`fetch`APIでアクセスするため、ヘッダとクレデンシャルの設定を追加で行う。
(参考:[Access-Control-Allow-Credentials](https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials))


```diff:cdk/lib/LambdaWithCognitoStack.ts
    const httpApi = new apigw.HttpApi(this, `${props.projectId}-apigw`, {
      createDefaultStage: false,
      corsPreflight: {
        allowOrigins: '*',
        allowMethods: [apigw.CorsHttpMethod.ANY],
+        allowHeaders: ['authorization', ' Content-Type', 'X-Api-Key',],
+        allowCredentials: true
      },
    })

    httpApi.addRoutes({
      methods: [apigw.HttpMethod.GET],
      path: '/scenario',
      integration: new intg.HttpLambdaIntegration(`${props.projectId}-scenarioIntegration`,handler,),
      authorizer,
    })
```

### Lambdaの修正
cors用のヘッダを追加

```diff:src/handler/api/hello.ts
import { APIGatewayProxyHandlerV2 } from 'aws-lambda';
import { format } from 'date-fns';
import { formatDate } from '@/common/index';


+ const corsHeaders = {
+   'Content-type': 'application/json',
+   'Access-Control-Allow-Origin': '*'
+ }

export const handler: APIGatewayProxyHandlerV2 = async (event) => {
  const datetime = formatDate(new Date(event.requestContext.timeEpoch));

  return {
    'statusCode': 200,
+     headers: corsHeaders,
    'body': JSON.stringify({
      message: `hello world. ${datetime} = ${event.requestContext.timeEpoch}. now: ${format(new Date(), 'yyyy-MM-dd HH:mm:ss.SSS')}`,
    })
  };
};
```


## ユーザプールの作成
今回はawsconsoleではなく、awscliから作成を行った。([参考](https://zenn.dev/longbridge/articles/56678cbb919d61))

```bash
#!/bin/bash

COGNITO_CLIENT_ID=hoge
COGNITO_USER_NAME=test@example.com
COGNITO_USER_PASSWORD=SamplePassw0rd!
COGNITO_UER_MAIL_ADDRESS=test@example.com
COGNITO_USER_POOL_ID=ap-northeast-1_xxxxxx

# ステータスが FORCE_CHANGE_PASSWORD のユーザを作成
# --message-action SUPPRESS ： メッセージをメールアドレスに送信しない
aws cognito-idp admin-create-user  \
--user-pool-id $COGNITO_USER_POOL_ID \
--username  $COGNITO_USER_NAME \
--message-action SUPPRESS \
--user-attributes Name=email,Value=$COGNITO_UER_MAIL_ADDRESS

# オプション --permanent を指定することで、パスワードは恒久的なパスワードとなりステータスが CONFIRMED となる
aws cognito-idp admin-set-user-password \
--user-pool-id $COGNITO_USER_POOL_ID \
--username $COGNITO_USER_NAME \
--password $COGNITO_USER_PASSWORD \
--permanent 
```

## クライアントからの問い合わせ
基本は、[Amplify を使わず React で AWS Cognito 認証を使う](https://mseeeen.msen.jp/react-auth-with-ready-made-cognito/)の通り。

### サインイン

```ts:client/src/store/slices/auth.ts
import {
  createAsyncThunk,
  createSlice,
  SerializedError,
} from '@reduxjs/toolkit'
import { Auth } from 'aws-amplify'

interface AuthState {
  username?: string
  authenticated: boolean
  error?: SerializedError
}

const initialState: AuthState = {
  username: undefined,
  authenticated: false,
  error: undefined,
}



export const signIn = createAsyncThunk<AuthState, { username: string, password: string }>(
  'signIn',
  async ({ username, password }, thunkAPI) => {
    try {
      const result = await Auth.signIn(username, password)
      return { username: result.username, authenticated: true }
    } catch (error: any) {
      return thunkAPI.rejectWithValue({ error: error.message })
    }
  },
)

export const signOut = createAsyncThunk('logout', async (_, thunkAPI) => {
  try {
    await Auth.signOut()
  } catch (error: any) {
    return thunkAPI.rejectWithValue({ error: error.message })
  }
})

export const initUser = createAsyncThunk<AuthState>(
  'initUser',
  async (req, thunkAPI) => {
    try {
      const result = await Auth.currentAuthenticatedUser()
      return { username: result.username, authenticated: true }
    } catch (error: any) {
      return thunkAPI.rejectWithValue({ error: error.message })
    }
  },
)


export const authSlice = createSlice({
  name: 'auth',
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder.addCase(signIn.fulfilled, (state, action) => {
      state.username = action.payload.username
      state.authenticated = true
    })
    builder.addCase(signIn.rejected, (state, action) => {
      state.error = action.error
      state.authenticated = false
      state.username = initialState.username
    })
    builder.addCase(signOut.fulfilled, (state) => {
      state.authenticated = false
      state.username = initialState.username
    })
    builder.addCase(signOut.rejected, (state, action) => {
      state.error = action.error
      state.authenticated = false
      state.username = initialState.username
    })
    builder.addCase(initUser.fulfilled, (state, action) => {
      state.authenticated = true
      state.username = action.payload.username
    })
  }
})
```

```ts:client/src/store/index.ts
import { configureStore, ThunkAction, Action } from '@reduxjs/toolkit'
import { authencicatedApi } from './rtkQuery/api'
import { authSlice, initUser } from './slices/auth'

export const store = configureStore({
  reducer: {
    auth: authSlice.reducer,
    [authencicatedApi.reducerPath]: authencicatedApi.reducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(authencicatedApi.middleware),
})

export type AppDispatch = typeof store.dispatch
export type RootState = ReturnType<typeof store.getState>
export type AppThunk<ReturnType = void> = ThunkAction<
  ReturnType,
  RootState,
  unknown,
  Action<string>
>

store.dispatch(initUser())
```

```tsx:client/components/pages/Login.tsx
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAppDispatch } from '@/store/hooks'
import { signIn } from '@/store/slices/auth'

const Login: React.FC = () => {
  const navigate = useNavigate()
  const [username, setUsername] = useState('')
  const [password, setPassword] = useState('')
  const dispatch = useAppDispatch()

  const executeSignIn = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    const result = await dispatch(signIn({ username, password }))
    if (result.meta.requestStatus !== 'fulfilled') {
      alert('認証に失敗しました。')
      return
    }
    navigate({ pathname: '/admin/top' })
  }

  return (
    <form noValidate onSubmit={executeSignIn}>
      <div>
        <label htmlFor="username">メールアドレス: </label>
        <input
          id="username"
          type="email"
          value={username}
          onChange={(e) => setUsername(e.target.value)}
        />
      </div>
      <div>
        <label htmlFor="password">パスワード: </label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
      </div>
      <button type="submit">ログイン</button>
    </form>
  )
}
export default Login
```

```tsx:client/src/router/context/PrivateRoute.tsx
import React from 'react'
import { Navigate, Outlet, RouteProps } from 'react-router-dom'
import { useAppSelector } from '@/store/hooks'
import { isUserAuthenticatedSelector } from '@/store/selectors/auth'

export const PrivateRoute: React.FC<RouteProps> = ({ element: _, ...rest }) => {
  const isAuthenticated = useAppSelector(isUserAuthenticatedSelector)
  if (!isAuthenticated) {
    return <Navigate to="/login" />
  }
  const isAdminPath = rest && rest.path && rest.path.indexOf('/admin') >= 0
  if (isAdminPath) {
    return <Navigate to="/admin" />
  }
  return <Outlet />
}
```


### ログイン状態に応じたページ遷移制御
* 認証状態 / 非認証状態で、閲覧できるページを制限する。
  * react-router-domはv6を使用。
  * Outlet機能を使ってグルーピング (参考: [ルーターライブラリ「React Router v6」でReactをページ分けをする](https://www.webopixel.net/javascript/1773.html))

```tsx:client/src/route/RoutesApp.tsx
import React from 'react'
import { Route, Routes } from 'react-router-dom'
import Login from '@/components/Pages/Login'
import Redirect from '@/components/Pages/Redirect'
import Test from '@/components/Pages/Test'
import Top from '@/components/Pages/Top'
import { PrivateRoute } from './context/PriveteRoute'
import { PublicRoute } from './context/PublicRoute'

const App: React.FC = () => {
  return (
    <Routes>
      <Route path="/admin" element={<PrivateRoute />}>
        <Route path="/admin/top" element={<Top />} />
      </Route>

      <Route element={<PublicRoute />}>
        <Route path="/" element={<Redirect />} />
        <Route path="/login" element={<Login />} />
        <Route path="/test" element={<Test />} />
      </Route>
    </Routes>
  )
}

export default App
```

```tsx:client/src/router/context/PublicRoute.tsx
import React from 'react'
import { Outlet, RouteProps } from 'react-router-dom'

export const PublicRoute: React.FC<RouteProps> = () => {
  return <Outlet />
}
```


```tsx:client/src/router/context/PrivateRoute.tsx
import React from 'react'
import { Navigate, Outlet, RouteProps } from 'react-router-dom'
import { useAppSelector } from '@/store/hooks'
import { isUserAuthenticatedSelector } from '@/store/selectors/auth'

export const PrivateRoute: React.FC<RouteProps> = ({ element: _, ...rest }) => {
  const isAuthenticated = useAppSelector(isUserAuthenticatedSelector)
  if (!isAuthenticated) {
    return <Navigate to="/login" />
  }

  return <Outlet />
}

```


### RTK Query
リフレッシュトークンは、`Auth.currentSession()`でよしなにやってくれるので、明示的な指定は必要ない。

(参考：[はじめてのCognito： aws-amplify のAuthでリフレッシュトークンしてる処理を追ってみた](https://zenn.dev/shimabukuromeg/articles/ddddfff9810f5b#aws-amplify-%E3%81%AEauth%E3%81%A7%E3%83%AA%E3%83%95%E3%83%AC%E3%83%83%E3%82%B7%E3%83%A5%E3%83%88%E3%83%BC%E3%82%AF%E3%83%B3%E3%81%97%E3%81%A6%E3%82%8B%E5%87%A6%E7%90%86%E3%82%92%E8%BF%BD%E3%81%A3%E3%81%A6%E3%81%BF%E3%81%9F))
```ts:client/src/store/rtkQuery/api.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import { Auth } from 'aws-amplify'

export const authencicatedApi = createApi({
  reducerPath: 'authencicatedApi',
  baseQuery: fetchBaseQuery({
    baseUrl: import.meta.env.VITE_APP_API_DOMAIN,
    prepareHeaders: async (headers, _) => {
      const session = await Auth.currentSession()
      const token = session?.getIdToken().getJwtToken()
      headers.set('authorization', token)
      return headers
    },
    mode: 'cors', // Fetch API では mode cors を設定する必要がある
  }),
  endpoints: (builder) => ({
    getScenario: builder.query<{ message: string }, void>({
      query: () => `api/scenario`,
    }),
  }),
})
export const { useGetScenarioQuery } = authencicatedApi
```

## 参考

[Amplify を使わず React で AWS Cognito 認証を使う](https://mseeeen.msen.jp/react-auth-with-ready-made-cognito/)
[AWS cognito と React でログインを実装する](https://zenn.dev/dove/articles/63494de652511c)
[AWS CDKv2 で Cognito 認証された API Gateway を構築して swaggerui で疎通確認したメモ](https://qiita.com/hibohiboo/items/e9953ed2ba139c23b5bf)
[AWS Cli で Cognito に CONFIRMED ユーザーを作成し、emialを設定する](https://zenn.dev/longbridge/articles/56678cbb919d61)


## 調査



[Amplify UI を使って React アプリに Amazon Cognito の認証フォームを実装する](https://dev.classmethod.jp/articles/implement-amazon-cognito-authentication-in-react-apps-using-the-amplify-ui/)
[はじめてのcognito](https://zenn.dev/shimabukuromeg/articles/ddddfff9810f5b#cognito%E3%81%A8%E3%81%AF)
[Vite のプロジェクトに Amplify の設定を適用する方法](https://qiita.com/maejima_f/items/0188adbcc8f2af564153)
[Amplify で既存 Cognito と 既存 APIGateway を使ってみる](https://dev.classmethod.jp/articles/amplify-with-existing-cognito-apigateway/)
[Amplify プロジェクトに既存の Cognito ユーザープールと ID プールを使用する](https://aws.amazon.com/jp/about-aws/whats-new/2020/10/use-existing-cognito-user-pools-identity-pools-for-amplify-project/)
[Amplify UI](https://ui.docs.amplify.aws/react/connected-components/authenticator/configuration)
[ViteをVue+Amplifyプロジェクトに導入する時に困ったこと&解決策](https://qiita.com/jre233kei/items/6521f9059edb045cfc1e)


[REST API リソースの CORS を有効にする](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/how-to-cors.html)
[cdk](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_cognito.CfnUserPoolClient.html)
[API Gateway コンソールを使用してリソースで CORS を有効にする](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/how-to-cors-console.html)
[なんとなく CORS がわかる...はもう終わりにする。](https://qiita.com/att55/items/2154a8aad8bf1409db2b)
[v2 cdk](https://bobbyhadz.com/blog/aws-cdk-http-api-apigateway-v2-cors)
[allow credentias](https://docs.aws.amazon.com/cdk/api/v1/docs/@aws-cdk_aws-apigateway.CorsOptions.html)