Cognito x React 実装でわかったこと(わからなかったこと)

pre-amble

まず cognito をなんのライブラリ経由で使うかの選択肢がいろいろあるようで、 AWS SDK を使うとか、 Amplify を使うとか、Amplify の auth 部分だけ切り出したライブラリを使うとか、調べるのが結構わかりにくかった。

現状の理解だと AWS SDK for Javascript v3 (v2 もあるけど v3 推奨) は各サービス (S3 とか DynamoDB とか) の API のラッパーで、一番低レイヤー。ちなみに npm i @aws-sdk/cognito のようにサービスごとにインストールできる。

Amplify については、Amplify をまるっと使う場合と Amplify の認証部分だけ切り出した amazon-cognito-identity-js だけ使う場合がある。

もろもろの比較は多分ここを読んだほうがよさそう↓

AWS SDK について補足しておくと、cognito は1つの @aws-sdk/cognito みたいにまとまっているわけではなく cognito-identity, cognito-identity-provider, cognito-sync の3つにわかれていて、ログイン・ユーザ登録をしたい場合は cognito-identity-provider を使う。しかもログインは xxSignInxx みたいな自明なメソッドではなく InitiateAuthCommand という名前のものを使わないといけない https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/cognito-identity-provider/command/InitiateAuthCommand/

紆余曲折あって、結局 Hosted UI を使うのがシンプルじゃない? となった。きっかけは↓のスライドを読んでイメージがつきやすかったのと、AWS SDK で実装していてコードを書くのが面倒になったこと。

Hosted UI

Hosted UI っていうのは、

  • Cognito 側でログインの UI を用意していてくれて
  • アプリケーション側ではこの UI のある URL にリダイレクトする ことで、認証を実現するもの

Hosted UI のスクショ

Hosted UI を使うか自前の UI を用意するかについてはこの記事がわかりやすそう。

注意点としては、Hosted UI は今のところ多言語対応をしていないので、どうしても日本語で表示させたいという要件がある場合は自前で UI の用意 + 前述のライブラリのなにかを使って認証を実装する必要がある。

また、Hosted UI は自分のアプリケーションの URL ではなく、AWS の URL にホストされていて URL は下記の通り。

`<COGNITO_DOMAIN>/login?client_id=<CLIENT_ID>&redirect_uri=<REDIRECT_URI>&response_type=code`

変数はどれも Cognito の管理画面で設定したもので、

  • COGNITO_DOMAIN: user pool の cognito domain
  • CLIENT_ID: app client の client ID

となる。

(redirect_uri はログインページのあるアプリケーションの URL)

処理の流れ

前提として、Cognito の認証は OAuth 2.0 のフローでやっていて、認可コードをもらってくるフローと、ID トークンをもらってくるフローの2つの実装が必要。

前者についてユーザを主語に記述すると、

  1. アプリのログインページに行く
  2. Hosted UI にリダイレクトされる
  3. 認証情報を入力してログインボタンを押す
  4. アプリに再度リダイレクトされて、ログイン状態になる

ここまでで認可コードがもらえるので、このあとに Cognito のトークンエンドポイントを叩いて ID トークンをもらってくる

実装: 全体のコード

事前準備としては

  • react を https で動かす
  • Cognito > User Pools > Application Client で、コールバック URL と sign out URL を https://localhost:3000 などアプリの URL に設定する
  "scripts": {
    "start:https": "HTTPS=true react-scripts start",
    "start": "react-scripts start",
    ...

依存ライブラリは

  • jwt-decode: ID トークンをデコードするために使う (ID トークンは JWT 形式だから。Auth0 のライブラリなのでよさそう)
  • react router: クエリパラメータをとってくるために雑に入れた。別に react router でなくてもいい。

各種設定値 (REACT_APP_xx) は予め .env ファイルか何かに入れておいてね。

import { jwtDecode } from "jwt-decode"
import { useEffect, useState } from "react"
import { useSearchParams } from "react-router-dom"

type IDToken = {
  [key: string]: any
}

const getIdToken = async (code: string): Promise<IDToken | null> => {
  const endpoint = `${process.env.REACT_APP_COGNITO_DOMAIN}/oauth2/token`
  const headers = { "content-type": "application/x-www-form-urlencoded" }

  const body = new URLSearchParams({
    grant_type: "authorization_code",
    client_id: process.env.REACT_APP_CLIENT_ID || "",
    code: code,
    redirect_uri: process.env.REACT_APP_REDIRECT_URI || "",
  })

  const res = await fetch(endpoint, {
    method: "POST",
    headers,
    body,
  })
  if (!res.ok) {
    return null
  }
  const data = await res.json()
  return jwtDecode(data.id_token)
}

const Root = () => {
  const [searchParams, _] = useSearchParams()
  const [idToken, setIdToken] = useState<IDToken>({})

  // Hosted UI でログインしたあと、 code つきでリダイレクトされたときに実行するコード
  useEffect(() => {
    const code = searchParams.get("code")
    if (!code) {
      return
    }
    const f = async () => {
      console.log(code)
      const res = await getIdToken(code)
      if (!res) {
        return
      }
      setIdToken(res)
    }
    f()
  }, [searchParams])

  // ボタンクリックしたときに Hosted UI にリダイレクトする
  // 初回レンダーで実行してもよさそう
  const redirectToLoginUI = () => {
    window.location.href = `${process.env.REACT_APP_COGNITO_DOMAIN}/login?client_id=${process.env.REACT_APP_CLIENT_ID}&redirect_uri=${process.env.REACT_APP_REDIRECT_URI}&response_type=code`
  }

  return (
    <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
      <button onClick={redirectToLoginUI}> ログイン </button>
      {Object.entries(idToken).map(([key, value]) => (
        <pre>{`${key}: ${value} `} </pre>
      ))}
    </div>
  )
}

export { Root }

実装: 認可コードをとってくる

ボタンを押したら Hosted UI の URL に遷移するようにした。別にボタンクリックがトリガーじゃなくてもいいと思う。

  const redirectToLoginUI = () => {
    window.location.href = `${process.env.REACT_APP_COGNITO_DOMAIN}/login?client_id=${process.env.REACT_APP_CLIENT_ID}&redirect_uri=${process.env.REACT_APP_REDIRECT_URI}&response_type=code`
  }
<button onClick={redirectToLoginUI}>ログイン</button>

ドキュメントはここ: https://docs.aws.amazon.com/cognito/latest/developerguide/login-endpoint.html

実装するときは↓の記事を参考にして進めた

実装: ID トークンをとってくる

ユーザがリダイレクトした先の Hosted UI (=ログイン画面) で認証情報を入力すると、今度は Hosted UI から自分のアプリにまたリダイレクトされる。このとき、クエリパラメータに ?code=<認可コード> が入ってくるので useEffect(...) の部分が発火する。

(https://localhost:3000https://xxx.amazoncognito.com/login?...https://localhost:3000?code=yyy)

  // Hosted UI でログインしたあと、 code つきでリダイレクトされたときに実行するコード
  useEffect(() => {
    const code = searchParams.get("code")
    if (!code) {
      return
    }
    const f = async () => {
      console.log(code)
      const res = await getIdToken(code)
      if (!res) {
        return
      }
      setIdToken(res)
    }
    f()
  }, [searchParams])

パラメータに CODE が入っていた場合 ↓の getIdToken で、トークンエンドポイントを叩く。ドキュメントはここで、https://docs.aws.amazon.com/cognito/latest/developerguide/token-endpoint.html ヘッダーは何を使ってねとかいろいろ指定してある。

const getIdToken = async (code: string): Promise<IDToken | null> => {
  const endpoint = `${process.env.REACT_APP_COGNITO_DOMAIN}/oauth2/token`
  const headers = { "content-type": "application/x-www-form-urlencoded" }

  const body = new URLSearchParams({
    grant_type: "authorization_code",
    client_id: process.env.REACT_APP_CLIENT_ID || "",
    code: code,
    redirect_uri: process.env.REACT_APP_REDIRECT_URI || "",
  })

  const res = await fetch(endpoint, {
    method: "POST",
    headers,
    body,
  })
  if (!res.ok) {
    return null
  }
  const data = await res.json()
  return jwtDecode(data.id_token)
}

あとは ID トークンが state に入ってくるので好きにできる。

参考資料