😺

ReactからCognitoで認証認可されたAPI Gatewayを呼び出す

2023/06/15に公開

背景

私の専門はアプリケーションロジック(特に機械学習)ですが、個人でWebアプリを作りたくなったのでフロントエンドやセキュリティも勉強しています。その一環で、表題の件の実現方法を理解したのでまとめます。

記事を書こうと思った動機

このあたりの解説記事は充実していると感じる一方で、AWS側の発展によって既存の解説記事の通りでは動かず苦労したところが多々あったため、自分なりのまとめを記します。本記事は2023年6月時点で有効だった方法を記載しています。

やりたいこと

  • ユーザ管理にはCognitoユーザプールを使う。
  • APIはAPI Gatewayでホストし、認証手段としてCognitoと連携をする。
  • 呼び出したユーザに応じた結果を返したい。今回はその実験として、ユーザ識別子をそのまま返すAPIを作ってみる。
  • フロントはReactで書く。
  • AWS Amplifyは使わない。

手順

Cognitoユーザプールの作成

AWSコンソールのCognitoから新規ユーザプールを作成します。
ウィザードの最初の画面です。サインインに使用するユーザ属性はEmailだけにしました。

次の画面。MFAをなしに変更し、ほかはデフォルトのままです。

次の画面。全てデフォルトのままです。

次の画面。メール配信はCognitoを利用にしました。

次の画面。ユーザプールの名前とアプリケーションクライアントの名前を設定しました。

アプリケーションクライアントというのは、このユーザプールに認証リクエストを出してくるアプリケーション(要はフロントエンド)を見分けるために用意するもので、まあ適当な名前で1つ用意しておけば良いでしょう。

"ホストされた認証ページ"は外しています。他の解説記事ではCognitoが用意する認証ページを使う例も多いですが、今回はReact側で用意するので不要です。

ウィザードは以上で作成ボタンを押すとユーザプールが出来上がります。ユーザプールIDは後で使います。

アプリケーションの統合タブに表示されている、クライアントIDも後で使います。

Lambdaの作成

APIが呼ばれた時にレスポンスを返すLambdaを作っておきましょう。名前はhello_userとしておきます。

フロントからのHTTPリクエストのヘッダにはAuthorizationが入っています。それはJWT形式でエンコードされているのですが、解読することでユーザ識別子(username)を拾うことができます。それをbodyに入れて返すだけの構成です。

ちなみに、単にbase64モジュールでデコードすればいいだけかと思いきや、文字列の長さが4の倍数になるようにパディングが必要とのことで、その対応ロジックも入っています。

API Gatewayの設定

API Gatewayの作成ウィザードから作成します。タイプは昨今新しく導入された"HTTP API"を選びます。REST APIの比べるとやや自由度が低い(?)代わりに、認証周りが簡単になるようです。

作成ウィザードを進めます。統合では先程作成したLambdaを追加しておきます。

次の画面。ルートとして適当に/abcを用意し、上記統合をアタッチします。

次の画面。自動デプロイ機能に関するものでしょうか。そのままとします。

これでAPIは作成されましたが、ウィザードはできていない設定を追加していきます。

CORS

まずはCORSです。本番稼働時はどうなるかわかりませんが、開発時はフロントをローカルで開発する一方で、APIのURLはAWSが用意するものになりますから、URLが異なるAPI呼び出しを禁止する仕組み(=CORS)が働きます。これを乗り越えるために設定が必要なのです。

CORSにはざっくり言うとシンプルなやつと、シンプルじゃないやつの二種類がありまして、シンプルなやつならAccess-Control-Allow-Origin*にすれば動きます。呼び出し元のURLが何でも受け入れますよという意味ですね。
ところが今回は認証をかませようとしておりまして、そうするとシンプルじゃないやつに該当してしまうのです。。こいつをやっつけるのに苦労しましたが、試行錯誤の末に導いた設定がこちらです。

  • Access-Control-Max-Age: デフォルトの0でもいいんですが、0だと任意時間のキャッシュが働いて、再現性のない挙動を示す恐れがあるので、開発中は-1にしてキャッシュ無効化にしておきます。
  • Access-Control-Allow-Headers: フロントからのリクエストでヘッダに乗せる要素を記入します。今回はauthorizationcontent-typeです。
  • Access-Control-Allow-Methods: すべての意味で*にしていますが、実際使うメソッド(GET, POSTなど)に絞ってもいいかと思います。ただ絞る場合でもOPTIONSは含める必要がありそうです。このメソッドは聞き慣れないものですが、後述の仕組みで使われます。
  • Access-Control-Allow-Credentials (右下のチェック): をtrueにします。認証でクレデンシャルを乗せるからですね。
  • Access-Control-Allow-Origin: シンプルに*としたいところですが、Access-Control-Allow-Credentialsがtrueだと*は受け付けないという制約があります。じゃあローカルから呼び出すのでlocalhostを入力としたいところですが、localhostという文字はホスト名の書式に合わないためか弾かれます。そこで便利なハックがありまして、localho.stと入力すると、localhostと同じ意味ながら書式を満たすことができます。また、ポート名まで含める必要があるので:3000も忘れずに。

ルート/にOPTIONSメソッド設定

CORS設定の一環になりますが、シンプルじゃないリクエストは、実際のリクエストを飛ばす前にプリフライトリクエストと言って、受付可否を尋ねるリクエストを飛ばします。なので、プリフライトリクエストを受け付けるためのAPIエンドポイントを用意してあげなければなりません。それは、公式ドキュメントによると、ルート/でメソッドはOPTIONSです。

統合をアタッチする必要はありません。先程作った/abcにはLambda統合がアタッチされている一方で、今作った/には何もアタッチしていません。それから後述しますが、オーソライザも/には付けてはいけません。

オーソライザ

当初の目的どおり、/abcは認可が必要なエンドポイントとします。認可のメニューから新しくオーソライザを作成し、アタッチします。

タイプはJWT。名前は適当。IDソースはデフォルトのまま。
発行者URLはCognitoで作成したユーザプールのIDから一意に決まるURLです。

https://cognito-idp.{リージョン名}.amazonaws.com/{user_pool_id}

対象者にはユーザプールに対して作成したアプリクライアントのIDを入れます。

それから認可スコープを設定します。スコープは

aws.cognito.signin.user.admin

とします。この文字列で固定です。本来、フロントとAPI側で合ってさえいれば自由に決めてよい文字列のはずですが、フロント側のモジュールaws-amplifyの怠慢により上記文字列が変えられない状況です。2019年に指摘され今なお放置されているようです。
https://github.com/aws-amplify/amplify-js/issues/3732
よって複数のスコープを使い分けて・・といった複雑なことができないですが、さしあたり今回は困りません。

Reactコード

Reactアプリの雛形を生成

npx create-react-app some-app

モジュールを導入

Cognitoとやり取りするために必要なモジュールを導入します。Amplifyの名が付いていますが、別にAmplifyを前提としなくても使えます。

npm install aws-amplify @aws-amplify/ui-react

コード編集

App.jsを以下のように編集します。

App.js
import React, { useState } from 'react';
import { Amplify, Auth } from 'aws-amplify';
import { withAuthenticator } from '@aws-amplify/ui-react'
import '@aws-amplify/ui-react/styles.css';

Amplify.configure({
  Auth: {
    region: 'ap-northeast-1',
    userPoolId: 'ap-northeast-1_xxxxxx', // 作成したuser-poolのIDを記入
    userPoolWebClientId: '3h1v0xxxxxxxxx', // 作成したアプリケーションクライアントのIDを記入
  }
});


const getResponse = async () => {
  const session = await Auth.currentSession();
  const accessToken = session.getAccessToken();
  
  // API Gatewayで作成したAPIエンドポイント
  const api_url = 'https://xxxxxx.execute-api.ap-northeast-1.amazonaws.com/abc';
  
  const response = await fetch(api_url, {
    method: 'get',
    headers: {
      Authorization: accessToken.getJwtToken(),
    },
  });
  return await response.json();
}


function App({ signOut, user }) {
  const [username, setUsername] = useState(null);

  const onClickHandler = async () => {
    const res = await getResponse();
    setUsername(res);
  };

  return (
    <div className="App">
      <header className="App-header">
        <h1>Signed-in as {user.attributes.email}</h1>
        <h1>Username is {username}</h1>
        <button onClick={signOut}>Sign out</button>
        <button onClick={onClickHandler}>Click me</button>
      </header>
    </div>
  );
}


export default withAuthenticator(App);

ポイントを解説しますと・・

  • 最初のAmplify.configureで、構築したCognitoユーザプールの情報を設定しています。
  • Click meボタンを押すとAPIを呼び出し、ログイン中ユーザの識別子を取得して表示します。
  • Appの引数に{signOut, user}を追加し、最後にwithAuthenticatorで包むことで、ログインしていたらコンテンツを表示し、していなければログインボックスを表示する仕組みをいい具合にやってくれます。

起動

npm run start

で起動します。localhost:3000でブラウザが立ち上がると思いますが、アドレスを手打ちでlocalho.st:3000に変えて開き直します。localhostlocalho.stも全く同じですが、前述のCORSハックの際、後者をオリジンとして入力したので揃える必要があります。

このような画面が表示されます。

Create accountタブからEmailとパスワードを打ち込みます。Verificationコードが届くのでそれも入力すればアカウントの準備は完了です。

Cognitoのユーザプール管理画面でもユーザが出来ているのがわかります。

さて、ログイン状態になったことでブラウザ表示はこのようになりました。

Username isの後が空になっていますが、Click meを押すと、ユーザ識別子を取得して表示します。はじめに準備したCognitoやAPI Gatewayの設定が完璧であれば表示されるはずです。では実際に押してみましょう。

無事表示されました! 実際のアプリケーションではこの識別子を表示しても意味ないですが、バックエンド側でユーザを識別できていることが確認できたので、ユーザ毎に表示コンテンツを分けるなどの制御に使うことができます。今回やりたかったことは以上です。

まとめ

CloudFormationあるいはCDKにまとめておきたい。

Discussion