Amazon CognitoのHostedUIで認証を入れたReactアプリのMyテンプレート

# 概要

Cognito UserPoolの"Hosted UI"（ホストされたUI）という組み込みのログイン画面で認証を入れたReactアプリを構築する自分用テンプレート（バックエンド構築用のSAMテンプレート＋シンプルなReactアプリ）を作成したので公開したものです。
ある程度AWSの知識がある方向けの記事となっております。
構築後の全体イメージとしては下記のようになります。
![draw-20221030.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2531623/57ecf1a8-02d4-b947-be16-15d9ca84df99.png)

バックエンドをLambdaで処理するHTTP API(API Gateway)へのAPIリクエストを行うまでの最小限の実装を含んだテンプレートとなっています。

SAM（サーバーレスアプリケーションモデル）のCLIでビルドとデプロイすることでAWSのバックエンド側リソースが構築され、NodeJSがインストールされている環境で"npm start"コマンドを実行することでフロント側のSPA（Reactアプリ）をlocalhostで起動可能です。

フロントアプリのログイン認証はHosted UIの組み込みのログイン画面とOIDC(OpenID Connect)の認可コードフローで認証が行われます。
ログイン後のAPI呼び出しはIAM認証で保護されており、UserPoolを認証プロバイダーとするIdenityPoolのフェデレーション機能によりIAMロールの権限設定に基づくフロントアプリへのAPI呼び出しの認可が行われます。
フロントアプリからのAPI呼び出しにはAWS Amplifyのライブラリ（ライブラリのみ）を使用しています。

[参考: AWS サーバーレスアプリケーションモデル（SAM）](https://aws.amazon.com/jp/serverless/sam/)
[参考: create-react-app](https://ja.reactjs.org/docs/create-a-new-react-app.html)
[参考: AWS Amplify](https://aws.amazon.com/jp/amplify/)
[参考: ユーザープール OIDC とホストされた UI API エンドポイントのリファレンス](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/cognito-userpools-server-contract-reference.html)


---

# SAM(≒CloudFormation)テンプレート

バックエンド側リソースの一式はSAMのテンプレートからSAMのCLIを使用してビルドとデプロイを行うことで構築できます。
これはSAMのテンプレートですがLambda以外の定義はCloudFormationテンプレートと同じです。
ですので、CloudFormationの定義サンプルとしてもご参考にして頂けるかと思います(自分用にもそういう目的で記事として残しているところがあります)。


```yaml
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Description: >-
  My sample. HTTP API(IAM Auth and Lambda backend) and Cognito for SPA.

Parameters:
  CallbackURL:
    Type: String
    Default: http://localhost:3000

Resources:
  SampleFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: !Sub '${AWS::StackName}-SampleFunction'
      CodeUri: sample/
      Handler: app.lambda_handler
      Runtime: python3.9
      MemorySize: 128
      Timeout: 15
      Role: !GetAtt SampleFunctionRole.Arn
      Architectures:
        - x86_64

  LambdaPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !GetAtt SampleFunction.Arn
      Principal: apigateway.amazonaws.com
      SourceArn: !Sub "arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${HttpApi}/*/*/*"

  UserPool:
    Type: AWS::Cognito::UserPool
    Properties:
      UserPoolName: !Sub ${AWS::StackName}-userpool
      UsernameAttributes:
        - email
      AdminCreateUserConfig:
        AllowAdminCreateUserOnly: true
      Schema:
        - Name: email
          Required: true

  UserPoolDomain:
    Type: AWS::Cognito::UserPoolDomain
    Properties:
      Domain: !Sub ${AWS::StackName}-${AWS::AccountId}
      UserPoolId: !Ref UserPool

  UserPoolClient:
    Type: AWS::Cognito::UserPoolClient
    Properties:
      UserPoolId: !Ref UserPool
      ClientName: !Sub ${AWS::StackName}-client
      SupportedIdentityProviders:
        - COGNITO
      AllowedOAuthFlowsUserPoolClient: true
      AllowedOAuthFlows:
        - code
      GenerateSecret: false
      AllowedOAuthScopes:
        - email
        - openid
        - profile
      CallbackURLs:
        - !Ref CallbackURL
      LogoutURLs:
        - !Ref CallbackURL

  IdentityPool:
    Type: AWS::Cognito::IdentityPool
    Properties:
      AllowUnauthenticatedIdentities: true
      IdentityPoolName: !Sub ${AWS::StackName}-identitypool
      CognitoIdentityProviders:
        - ClientId: !Ref UserPoolClient
          ProviderName: !GetAtt UserPool.ProviderName

  IdentityPoolRoleAttachment:
    Type: AWS::Cognito::IdentityPoolRoleAttachment
    Properties:
      IdentityPoolId: !Ref IdentityPool
      Roles:
        unauthenticated: !GetAtt CognitoUnauthRole.Arn
        authenticated: !GetAtt CognitoAuthedRole.Arn

  HttpApi:
    Type: AWS::ApiGatewayV2::Api
    Properties:
      Name: !Sub ${AWS::StackName}-api
      ProtocolType: HTTP
      # CORS Settings refs https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/http-api-cors.html
      CorsConfiguration:
        AllowOrigins:
          - !Ref CallbackURL
        AllowCredentials: false
        AllowHeaders:
          - Content-Type
          - X-Amz-Date
          - X-Amz-Security-Token
          - Authorization
          - X-Api-Key'
        AllowMethods:
          - GET
          - HEAD
        MaxAge: 300

  HttpApiRoute:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      ApiId: !Ref HttpApi
      RouteKey: "GET /sample"
      AuthorizationType: AWS_IAM
      Target: !Sub integrations/${HttpApiIntegration}

  HttpApiIntegration:
    Type: AWS::ApiGatewayV2::Integration
    Properties:
      ApiId: !Ref HttpApi
      ConnectionType: INTERNET
      IntegrationType: AWS_PROXY
      IntegrationUri: !GetAtt SampleFunction.Arn
      PayloadFormatVersion: 2.0

  HttpApiStage:
    Type: AWS::ApiGatewayV2::Stage
    Properties:
      ApiId: !Ref HttpApi
      AutoDeploy: true
      StageName: $default

  SampleFunctionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${AWS::StackName}-SampleFunction-role'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
            Effect: 'Allow'
            Principal:
              Service:
                - 'lambda.amazonaws.com'
            Action:
              - 'sts:AssumeRole'
      ManagedPolicyArns:
        - 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole'

  CognitoUnauthRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${AWS::StackName}-Cognito-Unauth-role'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: 'Allow'
            Principal:
              Federated:
                - 'cognito-identity.amazonaws.com'
            Action:
              - 'sts:AssumeRoleWithWebIdentity'
            Condition:
              StringEquals:
                cognito-identity.amazonaws.com:aud: !Ref IdentityPool
              ForAnyValue:StringLike:
                cognito-identity.amazonaws.com:amr: unauthenticated
      Policies:
        - PolicyName: !Sub '${AWS::StackName}-Cognito-Unauth-policy'
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Sid: 'DefaultOfAUnauthRole'
                Effect: 'Allow'
                Action:
                  - 'mobileanalytics:PutEvents'
                  - 'cognito-sync:*'
                Resource: '*'

  CognitoAuthedRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${AWS::StackName}-Cognito-Authed-role'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: 'Allow'
            Principal:
              Federated:
                - 'cognito-identity.amazonaws.com'
            Action:
              - 'sts:AssumeRoleWithWebIdentity'
            Condition:
              StringEquals:
                cognito-identity.amazonaws.com:aud: !Ref IdentityPool
              ForAnyValue:StringLike:
                cognito-identity.amazonaws.com:amr: authenticated
      Policies:
        - PolicyName: !Sub '${AWS::StackName}-Cognito-Authed-policy'
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Sid: 'DefaultOfAuthedRole'
                Effect: 'Allow'
                Action:
                  - 'mobileanalytics:PutEvents'
                  - 'cognito-sync:*'
                  - 'cognito-identity:*'
                Resource: '*'
              - Sid: 'AllowInvokeApi'
                Effect: 'Allow'
                Action:
                  - 'execute-api:Invoke'
                Resource: !Sub "arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${HttpApi}/*"

Outputs:
  UserPoolId:
    Value: !Ref UserPool
    Export:
      Name: !Sub ${AWS::StackName}-UserPoolId
  UserPoolClientId:
    Value: !Ref UserPoolClient
    Export:
      Name: !Sub ${AWS::StackName}-UserPoolClientId
  UserPoolDomain:
    Value: !Ref UserPoolDomain
    Export:
      Name: !Sub ${AWS::StackName}-UserPoolDomain
  IdentityPoolId:
    Value: !Ref IdentityPool
    Export:
      Name: !Sub ${AWS::StackName}-IdentityPool
  HttpApiEndpoint:
    Value: !GetAtt HttpApi.ApiEndpoint
    Export:
      Name: !Sub ${AWS::StackName}-HttpApiEndpoint
```

## テンプレートのポイント
- "HttpApiRoute"のリソースでAPIのパスと、認証（IAM）と統合先（Lambda関数）の定義をしています
- "HttpApi"のリソースでCORS(Cross-Origin Resource Sharing)の定義をしています(※)
- "LambdaPermission"のリソースでAPI Gatewayの任意のHTTPメソッドとパスからのLambda関数への呼び出し許可を設定しています
- "UserPool*"のリソースでCognito UserPoolのリソースを定義し、"IdentityPool"のリソースでUserPoolを認証プロバイダーとするCognito IdentityPoolのリソースを定義しています
  - "UserPoolClient"のリソースでOIDCの認可コードフローの設定を行っています
- "CognitoUnauthRole"と"CognitoAuthedRole"でIdentityPoolにアタッチする未認証と認証済ユーザー用のIAMロールのリソース定義を行い、後者にのみAPIの実行(invoke)許可を行っています
  - これらのIAMロールの定義内容は管理コンソール画面上からロールを作成した場合に生成される内容をベースにしています


:::note warn
※ HTTP API使用時のCORS設定の注意点
HTTP APIの場合はCORSの設定に従いPreflightのOPTIONSリクエストのレスポンスをAPI Gatewayが自動的に返してくれます。
しかし、"$default"のパスでANYのHTTPメソッドを定義するなどOPTIONSのリクエストを拾ってしまうようなIAMやJWT認証入りのルートを定義した場合はOPTIONSのPreflightリクエストが認証でブロックされてしまうので注意が必要です。
このような場合はOPTIONSリクエストを認証なしで通すためのルートを別途用意してあげる必要が生じます。
詳しくは公式ドキュメントを参照下さい。
[参考: HTTP API の CORS の設定](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/http-api-cors.html)
ちなみに、当テンプレートでは"/sample"のパスのGETメソッドのルートを明示的に定義しているので上記の問題は発生しません。
:::

## ビルドとデプロイの手順(バックエンドリソース)

Cloud9などのSAM-CLIが利用できてCloudFormationでのデプロイが可能なIAMの権限を持っている環境上で、[Github](https://github.com/chs-k-kinoshita/qiita-demo-api-with-cog)からソースをcloneして下記のようなコマンドでビルドとデプロイを行います。
※ [Cloud9](https://aws.amazon.com/jp/cloud9/)はブラウザ上で利用可能な統合開発環境のAWSサービスです（awsやsamのCLI、gitのコマンドが初期状態で利用可能）。

```
git clone https://github.com/chs-k-kinoshita/qiita-demo-api-with-cog
cd qiita-demo-api-with-cog
sam build --use-container
sam deploy --resolve-s3
```

:::note warn
Cloud9環境を使う場合の注意事項として、ビルド時に"--use-container"オプションを指定する場合Amazon ECR(Elastic Container Registry)のパブリックリポジトリからLambda関数をビルドするためのコンテナイメージをダウンロードしようとします。Cloud9環境作成後のデフォルト状態のストレージ容量(デフォルト10GB)では容量が不足し、buildが失敗します。
下記参考URLで紹介されている方法(resize.sh)で容量を拡張する必要があります(手順は簡単です)。
[参考: AWS Cloud9ユーザーガイド: 環境で使用されている Amazon EBS ボリュームのサイズ変更](https://docs.aws.amazon.com/ja_jp/cloud9/latest/user-guide/move-environment.html#move-environment-resize)
:::

SAMテンプレートからのビルドとデプロイが成功すると、CloudFormationのスタックによりバックエンド側のリソースが全て構築されます。
テンプレート定義内容を更新した場合、ビルドとデプロイをやり直すことでAWSリソースに反映されます。

ちなみに、デフォルトでは"us-east-1"(バージニア北部)リージョンにリソースを作成する設定になっているので、変更したい場合は"samconfig.toml"ファイルの"region"パラメータを変更して下さい。
各種AWSリソース名のプリフィックスにはスタック名をつけるようになっています。スタック名を変更したい場合は"samconfig.toml"ファイルの"stack_name"パラメータを変更して下さい（ただし、各種リソースやCognito UserPoolのドメイン名のネーミングルールに違反する様なスタック名をつけるとdeploy時にエラーになります）。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2531623/ff0e69d7-d1c1-c60b-b3c3-2f143d7c25af.png)
# Reactアプリの起動(フロント側リソース)

こちらの作業はCloud9上ではなくローカルPC上で行います。
リソースは[GitHub](https://github.com/chs-k-kinoshita/qiita-demo-react-with-hostedui)から取得して下さい。
前述の手順でSAMを使ってAWSリソースをデプロイすると、CloudForamtionスタックの出力タブにフロント側となるReactアプリ用の設定値が出力されているはずです。
![cfn_output.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2531623/bfd04e8e-dc84-9234-3e7d-91a5d490e416.png)
それらの値を転記して".env"ファイルの各種設定を更新します。

```
PORT=3000

REACT_APP_Region=us-east-1
REACT_APP_OIDC_Scope=email+openid+profile
REACT_APP_SignInUrl=http://localhost:3000

# fix this by CFn outputs
REACT_APP_HttpApiEndpoint=(HttpApiEndpointの値)
REACT_APP_IdentityPoolId=(IdentityPoolIdの値)
REACT_APP_UserPoolClientId=(UserPoolClientIdの値)
REACT_APP_UserPoolDomain=(UserPoolDomainの値)
REACT_APP_UserPoolId=(UserPoolIdの値)
```

設定ファイルを更新後、"npm install"コマンドで依存ライブラリをインストールして"npm start"コマンドで開発用ローカルWebサーバ上で動作させることができます。

```bash
npm install
npm start
```

(フロントアプリ起動後のブラウザ画面表示)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2531623/2fa0dd72-6fa0-68b9-7cfb-5711128e49fc.png)

デフォルトのポートは"3000"となっており、起動したアプリにはlocalhostの3000番ポートでブラウザアクセス可能です。
ポートは変更可能（※）ですがAWS Cognitoのアプリクライアント設定も "http://localhost:3000" をコールバックURLとして許可する設定となっているので、一緒に変更する必要があります(要変更箇所は前述のSAMテンプレートを"localhost:3000"で検索して下さい)。
※ 変更したい場合は".env"ファイル内の"PORT=3000"の設定を変更して下さい。

「Sign-In with UserPool Hosted-UI」のリンクをクリックするとHostedUIのログイン画面に遷移するようになっています。
ちなみに、Hosted UI画面のURLや表示はAWSコンソールのCognito UserPool ＞ 「アプリケーションの統合」タブ ＞ 「アプリケーションクライアントのリスト」からクライアントを選択 ＞ 「ホストされた UIを表示」ボタン より確認ができます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2531623/6c82003d-302b-3756-97ee-ddeb0802eb4b.png)
Hosted UIのログイン画面
（素の状態だとそっけない感じですが、ロゴを入れたり色味を変更したりすることはできます。また認証プロバイダーの種類や数、自己サインアップ可否など、UserPoolの設定によってログイン画面のUIも変化します）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2531623/7e059389-cfee-ed22-4e37-8ea0ea24d0f3.png)

UserPoolを認証基盤としたログイン認証が成功すると、Reactアプリの画面(localhost:3000)にcode=認可コードのクエリパラメータ付きでリダイレクトされます(OIDCの認可コードフローの流れ)。

## Reactアプリ実装のポイント

[GitHub上の差分比較](https://github.com/chs-k-kinoshita/qiita-demo-react-with-hostedui/compare/8a0100b...8030462)で"create-react-app"の初期状態からの差分を確認可能です。
初期テンプレート状態から大した変更を加えていないことがお判り頂けるかと思います。
大雑把に言うと"Sample"というコンポーネントを追加しており、追加コンポートでは下記処理をしています。

- 未認証の場合にはログイン認証画面(Cognito HostedUIの画面)への導線となるリンクを表示
- 認証済の場合には、下記を表示
  - ログインユーザーのemail
  - 「サインアウト」ボタン
  - 「サンプルAPI Call」ボタン

（ログイン後のブラウザ画面表示）
![signin_screen.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2531623/301cfca3-8c49-58cc-f894-207e00393c8c.png)

「サンプルAPI Call」ボタンをクリックすると、API Gateway(HTTP API)のエンドポイントを通じてバックエンドのLambda関数までリクエストが届くようになっています。画面表示に変化はありませんので、API呼び出しの成否はブラウザのコンソールより確認下さい。
APIはIAM認証で保護されており、Cognito IdentityPoolと関連付けられたIAM RoleのポリシーでフロントアプリからのAPIの呼び出し許可（認可）が行われます。

"Sample"コンポーネント実装のポイントは、Amplify設定のAuth部分とcomponentDidMountメソッド内で実行している"Auth.currentCredentials()"呼び出しの部分です。
```js
Amplify.configure({
  Auth: {
    region: REGION,
    userPoolId: process.env.REACT_APP_UserPoolId,
    userPoolWebClientId: USERPOOL_CLIENT_ID,
    identityPoolId: process.env.REACT_APP_IdentityPoolId,
    oauth: {
      domain: ENDPOINT_DOMAIN,
      scope: OIDC_SCOPE.split('+'),
      redirectSignIn: SINGIN_URL,
      redirectSignOut: SINGIN_URL,
      responseType: 'code'
    }
  },
...省略...
});
...省略...

class Sample extends Component {
...省略...
  componentDidMount = async () => {
    const awsCred = await Auth.currentCredentials();
    if (!awsCred.authenticated) {
      console.log('未認証');
```

Amplifyの設定と、たった一行のコード（Auth.currentCredentials()の部分）の裏でAmplifyのライブラリが魔法のようにいろいろなこと(※)をやってくれているようで、アプリ側で認証周りの複雑な実装をしなくても簡単にログイン認証を入れることができています。

※ Auth.currentCredentials()呼び出しが裏でやっていることの例
- OIDC(OpenID Connect)の認可コードフローに基づく認可コードと各種token(IdToken,AccessToken,RefreshToken)の交換と保存
- Cognito IdentityPoolを通じたAWSの一時的な認証(AccessKey,SecretKey,SessionToken等)の取得と保存 ※一時認証を発行すのはSTS(SecurityTokenService)
- tokenが有効期限切れの場合RefreshTokenから更新

---

# ログイン可能なユーザーの作成方法

SAMテンプレートで作成したCognito UserPoolの設定では自己サインアップを許可しない設定になっており、動作確認用のユーザーはAWS管理コンソール上のCognito UserPoolの画面上から手作業で追加する必要があります。
![cognito_1.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2531623/2cf190a7-29d4-4018-61d9-f8624890fcac.png)

---

# 最後に

当記事で紹介しているテンプレートですが、個人的にはちょっとしたデモやPoCの作成などの目的で最小限のユーザー認証とAPIアクセス付きのSPAをサクッと作成したい場合などに便利かなと思っています。

当記事での認証はCognito UserPoolが内部に持つデータデータベースでの認証を入れていますが、Hosted UIの機能を利用すればSAMLやOIDC(OpenID Connect)に対応した任意の認証プロバイダー(例えばAzure AD、Google、Facebook等)でのユーザー認証を入れることも比較的容易に可能です（自前で認証周りのクライアント側実装を行うことに比べればかなり楽、という意味で"比較的容易"と表現してます）。
しかし、Hosted UIの利用は組み込みのログイン認証画面であるが故にカスタマイズできる部分が限定的であるという制限もあります（例えば現状では表示文言の日本語化ができません）ので、必ずしも有効な選択ではないと思います。

[参考: 組み込みのサインインおよびサインアップウェブページのカスタマイズ](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/cognito-user-pools-app-ui-customization.html)

HostedUIを使ってSPA（Reactアプリ）にSAMLやOIDCの認証を簡単に入れる、というテーマについては当記事をベースに別記事で書いてみるかもしれません。
以上、何かのご参考になれば幸いです。