こんにちは。会員システムグループの三浦です。
最近社内ツールを作成する機会があり、React(TypeScript)を用いたSPAを作成し、認証基盤にAWS Cognitoを利用する、という実装を行いました。この組み合わせはプラクティスも豊富でサンプルコードがたくさん紹介されています。よく見かけるのはAmplify用の認証パッケージを使う方法で、configの設定のみで簡単に動作させることができます。
一方で、認証済みのSPAから同じCognitoを認証に使ったAPIを呼び出す、というのは実装例があまり見つかりませんでした。いろいろ試してみたところ、Cognito認証付きAPIを作成するのにAPI Gatewayを用いるのが簡単でしたので、ご紹介したいと思います。
AWSの知識やReact, TypeScriptの知識については触れたことのある方を対象としています。AWS初心者、未経験の方はCognitoやAPI Gatewayの構築に関する知識も並行して学んでみてください。
この記事を読んでためになる人
- Reactを用いたSPAをAWS上に構築しようとしている人
- 認証にAmazon Cognitoを利用しようとしている人
- SPAから認証付きAPIを呼び出す必要がある人
要件
- React(TypeScript)でSPAを作成する
- ログイン画面から、Cognitoの認証を経由してログインする
- ログイン後のトップページで認証付きAPIをコールする
- 認証情報はReactで行なった認証と同じにする必要がある
- API自体は既にEC2にデプロイされているため、認証機能は分離したい
Cognitoを設定する
ユーザープールの作成
まず初めにCognitoでユーザープールを作成し、アプリクライアント、ドメイン、テスト用に使うユーザーの設定を行う必要があります。AWSのコンソールから以下のスクリーンショットのように設定していきましょう。
まずユーザープールを作成します。
「デフォルトを確認する」→以下設定で「プールの作成」
ユーザープール作成後、アプリクライアントを以下の設定のように作成します。
アプリクライアント作成後、以下のように設定します。
Cognitoのログインページを表示するためのドメインを設定します。ドメインのプレフィックスには好きな文字列を入れてください。
テスト用のユーザーを作ります。ユーザー名と仮パスワードを入力し、Eメールは私用のものなどを入れてください。
基本的にはデフォルトの設定ですが、重要なポイントはアプリクライアントの設定でクライアントシークレットをオフにすることです。
そもそもクライアントシークレットとは?
クライアントシークレットは、クライアント認証の際に利用するシークレットキーです。クライアント認証はリクエストしているクライアントの正当性確認の為に実施します。
クライアント認証のフローはいくつかありますが、基本的にはクライアントIDとクライアントシークレットを組み合わせた情報を送ることで、クライアントが信頼できると判定します。
今回作成するReactSPAでは最終的にビルドされたjsファイルをクライアントがダウンロードして実行するのですが、jsファイルにシークレットを入れてはシークレット扱いとならないですよね。SPAではクライアントシークレットは入れないのが一般的です。
逆に、サーバーサイドレンダリングなどのクライアントシークレットが漏れない環境ではクライアントシークレットを利用して正当性確認を行うことで、不正なクライアントからのアクセスを防ぐことができます。
アプリの作成
フロントでの認証には先ほどご紹介したAmplify用のパッケージを利用します。こちらを利用すれば、config設定を行うことで簡単にCognitoでの認証を利用することができます。
▼今回使用するソースコードはこちら(クリックすると開きます)
1 2 3 4 5 6 |
<!DOCTYPE html> <html> <body> <div id="root"></div> </body> </html> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
import { Auth } from "aws-amplify"; export interface PetData { id: string; type: string; price: string; } export const getApi = async () => { const url = "<作成してデプロイしたAPIGatewayのエンドポイントURL>/pets"; const petData : PetData[] = await Auth.currentSession() .then((session) => session.getIdToken().getJwtToken()) .then((accessToken) => fetch(url, { mode: "cors", method: "GET", credentials: "include", headers: { Authorization: `Bearer ${accessToken}`, }, }) ) .then((response) => response.json()) .then((data) => data); return petData; } |
1 2 3 4 5 6 7 8 9 10 11 |
import { SamplePage } from "./SamplePage"; function App() { return ( <div> <SamplePage></SamplePage> </div> ); } export default App; |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
import { Amplify, Auth } from "aws-amplify"; import { CognitoUser } from "@aws-amplify/auth"; interface UserAttributes { sub: string; email: string; email_verified: string; name: string; } interface CognitoUserExt extends CognitoUser { attributes: UserAttributes } Amplify.configure({ Auth: { region: "ap-northeast-1", userPoolId: "<作成したユーザープールID>", userPoolWebClientId: "<作成したアプリクライアントID>", oauth: { domain: "< 作成したcognitoドメイン>", scope: ["openid", "email", "profile"], redirectSignIn: "https://localhost:3000/", redirectSignOut: "https://localhost:3000/", responseType: "code" } } }) export const signIn = async () => { Auth.federatedSignIn(); } export const signOut = async () => { Auth.signOut(); } export const getUser = async () => { try { const userData: CognitoUserExt = await Auth.currentAuthenticatedUser(); return userData; } catch (e) { return console.log("サインインしていません"); } } |
1 2 3 4 5 6 7 8 9 10 |
import React from "react"; import ReactDOM from "react-dom"; import App from "./App"; ReactDOM.render( <React.StrictMode> <App /> </React.StrictMode>, document.getElementById("root") ) |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
import { Hub } from "aws-amplify"; import { useEffect, useState} from "react"; import { getApi, PetData } from "./Api"; import {signIn, signOut, getUser} from "./Auth"; export const SamplePage = () => { const [user, setUser] = useState<any | null>(null); const [data, setData] = useState<PetData[]>([]); useEffect(() => { Hub.listen('auth', ({ payload: { event, data } }) => { switch (event) { case 'signIn': case 'cognitoHostedUI': getUser().then(userData => setUser(userData)); break; case 'signOut': setUser(null); break; case 'signIn_failure': case 'cognitoHostedUI_failure': console.log('Sign in failure', data); break; } }); getUser().then(userData => setUser(userData)); getApi().then(apiData => setData(apiData)); }, []); return ( <div> <div> <p>state: {user ? user.username : "sign out"}</p> {data.map((value, index) => ( <p key={index}>id: {value.id}, type: {value.type}, price: {value.price}</p> ))} </div> <div> <p>サインインする</p> <button onClick={() => signIn()}>Sign In</button> </div> <div> <p>サインアウトする</p> <button onClick={() => signOut()}>Sign Out</button> </div> </div> ); } |
先ほど申し上げたようにクライアントシークレットの設定項目はありません。Amplify.Authパッケージのconfigではクライアントシークレットが設定できず、またCognito側でクライアントシークレットは無効化する必要があるとAmplifyのドキュメントにも記載があります。
create-react-app <アプリ名> --template typescript でアプリを作成してから、ソースコードを配置し、Cognitoのパラメーターを埋めていきます。
npm install react aws-amplify react-dom @aws-amplify/auth で必要なパッケージをインストールして、
npm start すれば、以下のような画面が出てくるはずです。
先ほど作成したユーザーでログインすれば、認証完了。ユーザー名が表示されました。
ただここではまだAPIを呼び出していないのでdataが特に表示されていません。続けてAPIを作成していきましょう
API GatewayにCognitoを仕掛ける
APIを作成してCognitoでの認証機能をくっつけますが、Amazon CognitoとAPI Gatewayが特に相性がよかったです。
API Gatewayの設定
まずAPI Gatewayを立ち上げます。今回はただGETできるAPIを立ち上げたいのですが、PetStoreというサンプルのAPIが簡単に立ち上げられるため、それを利用していきます。
API Gatewayの画面から、REST APIの「構築」で、API Gatewayの構築画面に進めます。
ここで「APIの例」を選択したまま「インポート」をすると、PetStoreというAPI Gatewayが立ち上がり、簡単に機能を試すことができます。
CORSの設定
次にCORSの設定を行います。今回ReactSPAはhttps://localhost:3000で起動していますが、API Gatewayのドメインはlocalhostではありません。そのためAPI Gateway側にCORSの設定を組み込む必要があります。
API Gatewayでは一括でCORSを有効化することができるようになっています。アクションから「CORSの有効化」を選択します。
Access-Control-Allow-Originは '*' だと動きません。 'https://localhost:3000' を指定します。また、Access-Control-Allow-Credentialsは 'true' にします。
これだと設定しきれないところがあるので、設定を追加します。GETメソッドの設定にAccess-Control-Allow-Credentialsが足りないので、 Access-Control-Allow-Credentials: 'true' となるように設定します。
オーソライザーの設定
次に、オーソライザーでCognitoの認証を設定します。オーソライザーは認証設定のテンプレートのようなものです。
作成したオーソライザーをGET APIに設定し、認証付きAPIに仕立てます。
アプリに設定を追加
最後にAPIをデプロイし、出力されたURLをApi.tsの中に記載して、再度画面を表示します。
ログイン後に、APIから情報を取得して中身を出力できました。
まとめ
私自身SPAや認証の作成は初めてだったのですが、Cognitoを利用することで簡単に認証基盤を準備・実装することができました。ただ実はさらっと流したCORS設定で何箇所もハマっています。。。簡単に基盤を用意できるのと、技術仕様の理解を深めてセキュアかつ綺麗に実装するのは別だなと感じました。書き始めるとキリがないので説明を飛ばしましたが、ハマったポイントは次の機会に解説させてください。
認証は実装が難しい、大変で管理も複雑、と思っているあなたも一度チャレンジしてみてはいかがでしょうか。