概要
Cognito UserPoolの"Hosted UI"(ホストされたUI)という組み込みのログイン画面で認証を入れたReactアプリを構築する自分用テンプレート(バックエンド構築用のSAMテンプレート+シンプルなReactアプリ)を作成したので公開したものです。
ある程度AWSの知識がある方向けの記事となっております。
構築後の全体イメージとしては下記のようになります。
バックエンドを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)
参考: create-react-app
参考: AWS Amplify
参考: ユーザープール OIDC とホストされた UI API エンドポイントのリファレンス
SAM(≒CloudFormation)テンプレート
バックエンド側リソースの一式はSAMのテンプレートからSAMのCLIを使用してビルドとデプロイを行うことで構築できます。
これはSAMのテンプレートですがLambda以外の定義はCloudFormationテンプレートと同じです。
ですので、CloudFormationの定義サンプルとしてもご参考にして頂けるかと思います(自分用にもそういう目的で記事として残しているところがあります)。
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ロールの定義内容は管理コンソール画面上からロールを作成した場合に生成される内容をベースにしています
※ HTTP API使用時のCORS設定の注意点
HTTP APIの場合はCORSの設定に従いPreflightのOPTIONSリクエストのレスポンスをAPI Gatewayが自動的に返してくれます。
しかし、"$default"のパスでANYのHTTPメソッドを定義するなどOPTIONSのリクエストを拾ってしまうようなIAMやJWT認証入りのルートを定義した場合はOPTIONSのPreflightリクエストが認証でブロックされてしまうので注意が必要です。
このような場合はOPTIONSリクエストを認証なしで通すためのルートを別途用意してあげる必要が生じます。
詳しくは公式ドキュメントを参照下さい。
参考: HTTP API の CORS の設定
ちなみに、当テンプレートでは"/sample"のパスのGETメソッドのルートを明示的に定義しているので上記の問題は発生しません。
ビルドとデプロイの手順(バックエンドリソース)
Cloud9などのSAM-CLIが利用できてCloudFormationでのデプロイが可能なIAMの権限を持っている環境上で、Githubからソースをcloneして下記のようなコマンドでビルドとデプロイを行います。
※ 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
Cloud9環境を使う場合の注意事項として、ビルド時に"--use-container"オプションを指定する場合Amazon ECR(Elastic Container Registry)のパブリックリポジトリからLambda関数をビルドするためのコンテナイメージをダウンロードしようとします。Cloud9環境作成後のデフォルト状態のストレージ容量(デフォルト10GB)では容量が不足し、buildが失敗します。
下記参考URLで紹介されている方法(resize.sh)で容量を拡張する必要があります(手順は簡単です)。
参考: AWS Cloud9ユーザーガイド: 環境で使用されている Amazon EBS ボリュームのサイズ変更
SAMテンプレートからのビルドとデプロイが成功すると、CloudFormationのスタックによりバックエンド側のリソースが全て構築されます。
テンプレート定義内容を更新した場合、ビルドとデプロイをやり直すことでAWSリソースに反映されます。
ちなみに、デフォルトでは"us-east-1"(バージニア北部)リージョンにリソースを作成する設定になっているので、変更したい場合は"samconfig.toml"ファイルの"region"パラメータを変更して下さい。
各種AWSリソース名のプリフィックスにはスタック名をつけるようになっています。スタック名を変更したい場合は"samconfig.toml"ファイルの"stack_name"パラメータを変更して下さい(ただし、各種リソースやCognito UserPoolのドメイン名のネーミングルールに違反する様なスタック名をつけるとdeploy時にエラーになります)。
Reactアプリの起動(フロント側リソース)
こちらの作業はCloud9上ではなくローカルPC上で行います。
リソースはGitHubから取得して下さい。
前述の手順でSAMを使ってAWSリソースをデプロイすると、CloudForamtionスタックの出力タブにフロント側となるReactアプリ用の設定値が出力されているはずです。
それらの値を転記して".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サーバ上で動作させることができます。
npm install
npm start
デフォルトのポートは"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を表示」ボタン より確認ができます。
Hosted UIのログイン画面
(素の状態だとそっけない感じですが、ロゴを入れたり色味を変更したりすることはできます。また認証プロバイダーの種類や数、自己サインアップ可否など、UserPoolの設定によってログイン画面のUIも変化します)
UserPoolを認証基盤としたログイン認証が成功すると、Reactアプリの画面(localhost:3000)にcode=認可コードのクエリパラメータ付きでリダイレクトされます(OIDCの認可コードフローの流れ)。
Reactアプリ実装のポイント
GitHub上の差分比較で"create-react-app"の初期状態からの差分を確認可能です。
初期テンプレート状態から大した変更を加えていないことがお判り頂けるかと思います。
大雑把に言うと"Sample"というコンポーネントを追加しており、追加コンポートでは下記処理をしています。
- 未認証の場合にはログイン認証画面(Cognito HostedUIの画面)への導線となるリンクを表示
- 認証済の場合には、下記を表示
- ログインユーザーのemail
- 「サインアウト」ボタン
- 「サンプルAPI Call」ボタン
「サンプルAPI Call」ボタンをクリックすると、API Gateway(HTTP API)のエンドポイントを通じてバックエンドのLambda関数までリクエストが届くようになっています。画面表示に変化はありませんので、API呼び出しの成否はブラウザのコンソールより確認下さい。
APIはIAM認証で保護されており、Cognito IdentityPoolと関連付けられたIAM RoleのポリシーでフロントアプリからのAPIの呼び出し許可(認可)が行われます。
"Sample"コンポーネント実装のポイントは、Amplify設定のAuth部分とcomponentDidMountメソッド内で実行している"Auth.currentCredentials()"呼び出しの部分です。
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の画面上から手作業で追加する必要があります。
最後に
当記事で紹介しているテンプレートですが、個人的にはちょっとしたデモやPoCの作成などの目的で最小限のユーザー認証とAPIアクセス付きのSPAをサクッと作成したい場合などに便利かなと思っています。
当記事での認証はCognito UserPoolが内部に持つデータデータベースでの認証を入れていますが、Hosted UIの機能を利用すればSAMLやOIDC(OpenID Connect)に対応した任意の認証プロバイダー(例えばAzure AD、Google、Facebook等)でのユーザー認証を入れることも比較的容易に可能です(自前で認証周りのクライアント側実装を行うことに比べればかなり楽、という意味で"比較的容易"と表現してます)。
しかし、Hosted UIの利用は組み込みのログイン認証画面であるが故にカスタマイズできる部分が限定的であるという制限もあります(例えば現状では表示文言の日本語化ができません)ので、必ずしも有効な選択ではないと思います。
参考: 組み込みのサインインおよびサインアップウェブページのカスタマイズ
HostedUIを使ってSPA(Reactアプリ)にSAMLやOIDCの認証を簡単に入れる、というテーマについては当記事をベースに別記事で書いてみるかもしれません。
以上、何かのご参考になれば幸いです。