AWS Copilot で Phoenix Framework の本番環境を構築する 認証編（Auth0）

## はじめに

AWS Copilot で Phoenix Framework の本番環境を構築するシリーズです

- [コスト・料金](https://qiita.com/RyoWakabayashi/private/a170c2ce9dd3cd1fe9ff)
- [リリース用ビルド](https://qiita.com/RyoWakabayashi/private/21baa60f9fcf64835a49)
- [クラスタリング](https://qiita.com/RyoWakabayashi/private/b6888b510b20bb7579e8)
- [データベース](https://qiita.com/RyoWakabayashi/private/660b87f9deaaa179545d)
- 認証 <- 今ここ

実装したサンプルはこちらに格納しています
※今回の認証付コードは `with-auth` ブランチに入れています

https://github.com/RyoWakabayashi/phoenix-channels-samples/tree/with-auth

## Auth0

本番環境として運用するからには、認証は必要でしょう

AWS だけで完結するなら Cognito を使うところですが、今回は Auth0 を使おうと思います

https://aws.amazon.com/jp/cognito/

https://auth0.com/jp

Auth0 は認証基盤としての機能が豊富で拡張性も高く、認可にも使えます

無料で使うこともでき、ドキュメントも親切です

今回のフロントエンドで使っている React にも Auth0 用のライブラリが用意されています

というわけで、 Auth0 で認証機能を付けてみましょう

### Auth0 のアプリケーション作成

Auth0 の認証に必要なアプリケーションを作成していきます
※Auth0 のアカウント作成方法などは割愛します

Auth0 のダッシュボードから、左メニューの Applications を開き、
右上の Create Application をクリックします

![スクリーンショット 2022-06-06 9.30.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/7f32674d-a173-5d97-c96e-332a46762d89.png)

モーダルが開くので、適当な名前を入力します

今回は React でフロントエンドを実装しているので、 Single Page Web Applications を選択します

Create をクリックしてください

![スクリーンショット 2022-06-06 9.32.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/3758defa-9579-a81d-f67a-5b52cab32178.png)

以下のような画面が表示されればOKです

React のアイコンをクリックすれば React の場合の導入方法が表示されます
※今回は React に Auth0 を導入するのが主目的ではないので割愛します
※ポイントになるとこだけ解説します

![スクリーンショット 2022-06-06 9.34.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/5d99a622-1bde-2e1d-45a3-fa67cb7731ab.png)

大事なのは Settings タブに表示される Domain 、 Client ID 、 Client Secret です

この値を各所で利用します

![スクリーンショット 2022-06-06 9.39.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/3982af27-68da-cbc2-2011-05c2fdb85f2c.png)

また、以下の項目を AWS Copilot のデプロイ後に表示される ELB のエンドポイント URL
`http://xxx.ap-northeast-1.elb.amazonaws.com` に設定してください
※後で CloudFront の URL に変更することになりますが、、、

- Allowed Callback URLs
- Allowed Logout URLs

![スクリーンショット 2022-06-06 10.21.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/09a5e82c-a5b6-0c3b-1c6a-9b9dfc57803d.png)

### 環境変数の引き渡し

Auth0 の React の Quick Start には以下のようなコードが書かれています

```js
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import { Auth0Provider } from "@auth0/auth0-react";

ReactDOM.render(
  <Auth0Provider
    domain="dev-elgpyi5g.auth0.com"
    clientId="rHKZ23HXNaj1OHD3WZH9QNeI5tmpfFhE"
    redirectUri={window.location.origin}
  >
    <App />
  </Auth0Provider>,
  document.getElementById("root")
);
```

基本的にコレでいいのですが、 domain や clientId が剥き出しなのはいただけません

秘密にする値ではないのでコードに書かれていること自体は問題ありませんが、
環境によって変動する値は環境変数で渡したいものです

以下のサイトを参考に、環境変数を読むようします 

https://lucasprag.com/posts/how-to-provide-configs-to-react-on-phoenix-and-esbuild/

AWS Copilot のサービス設定で環境変数をローカルから ECS のコンテナに渡します

copilot/lb-svc/manifest.yml

```
...
variables:
  SECRET_KEY_BASE: ${SECRET_KEY_BASE}
  AUTH0_DOMAIN: ${AUTH0_DOMAIN}
  AUTH0_CLIENT_ID: ${AUTH0_CLIENT_ID}
...
```

Elixir の設定情報で環境変数を読み込みます

react_chat/config/config.exs

```exs
...

config :react_chat, ReactChatWeb.Token,
  domain: System.get_env("AUTH0_DOMAIN"),
  client_id: System.get_env("AUTH0_CLIENT_ID"),
  client_secret: System.get_env("AUTH0_CLIENT_SECRET")

...
```

ビルド時ではなく実行時の環境変数を読みたいので、 runtime.exs にも追加します

react_chat/config/runtime.exs

```exs
...

if config_env() == :prod do
  ...

  config :react_chat, ReactChatWeb.Token,
    domain: System.get_env("AUTH0_DOMAIN"),
    client_id: System.get_env("AUTH0_CLIENT_ID"),
    client_secret: System.get_env("AUTH0_CLIENT_SECRET")
end

```

Phoenix のテンプレートで設定情報を読み込み、 `PUBLIC_CONFIGS` に入れます

react_chat/lib/react_chat_web/templates/layout/root.html.heex

```eex
<!DOCTYPE html>
<html lang="en">
  <head>
    ...
    <script type="text/javascript">
      var PUBLIC_CONFIGS = {
        domain: "<%= Application.fetch_env!(:react_chat, ReactChatWeb.Token)[:domain] %>",
        client_id: "<%= Application.fetch_env!(:react_chat, ReactChatWeb.Token)[:client_id] %>",
      }
    </script>
  </head>
```

JS で `PUBLIC_CONFIGS` を読みます

react_chat/assets/js/app.js

```js
...
  <Auth0Provider
    domain={PUBLIC_CONFIGS.domain}
    clientId={PUBLIC_CONFIGS.client_id}
    redirectUri={window.location.origin}
  >
...
```

認証されたときだけチャットを表示するようにします

また、チャットに参加するときの認証情報として Auth0 のトークンを利用したいため、
トークンを取得して Chat に渡します

```js
...
function App() {
  const { isAuthenticated, loginWithRedirect, getIdTokenClaims, user, logout } = useAuth0()
  const [idToken, setIdToken] = useState("")

  if (isAuthenticated && idToken === "") {
    getIdTokenClaims().then((token) => {
      if (token) {
        setIdToken(token.__raw)
      }
    })
  }

  if (isAuthenticated && idToken !== "") {
    return (
      <div>
        <Chat userName={user.name} idToken={idToken}/>
        <button onClick={() => logout({ returnTo: window.location.origin })}>
          Log out
        </button>
      </div>
    );
  } else {
    return <button onClick={loginWithRedirect}>Log in</button>
  }
}
...
```

Chat 側では受け取ったトークンを websocket の接続時にパラメーターとして渡します

Elixir 側でのトークンの検証は後述します

```js
...
  join() {
    this.socket = new Socket("/socket", {params:
      {token: this.state.idToken}
    });
    this.socket.connect();
...
```

## CloudFront の追加

このまま ELB のエンドポイント `http://xxx.ap-northeast-1.elb.amazonaws.com` にアクセスすると、
以下のようなエラーがブラウザのコンソールに表示され、ログインボタンも表示されません

![スクリーンショット 2022-06-06 11.06.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/aa16780e-7991-3bf7-4d1f-528c7f9b3a7c.png)

```
Uncaught Error: 
      auth0-spa-js must run on a secure origin. See https://github.com/auth0/auth0-spa-js/blob/master/FAQ.md#why-do-i-get-auth0-spa-js-must-run-on-a-secure-origin for more information.
...
```

`auth0-spa-js must run on a secure origin` と言われています

つまり、　Auth0　を使うためには、プロトコルが `https` である必要があるわけです

AWS Copilot でデプロイした ELB のエンドポイント は `https` ではアクセスできません

したがって、このままでは Auth0 によるログイン画面を表示されることは不可能です

そこで、 ELB の前に CloudFront を立て、 `https` アクセスが可能なようにします

本番運用を想定した場合も CloudFront によるキャッシュはあった方が良いです

ただし、 CloudFront は AWS Copilot の機能では追加できなさそうなので、
ここだけ Web コンソールか AWS CLI や Terraform などで行うことになります

### ディストリビューションの設定

CLoudFront を設定するとき、いくつかの項目に気をつけないと websocket 通信ができません

今回は Web コンソールから設定する例を紹介しますが、 Terraform などでも同じ設定にしてください

まず、 CloudFront のディストリビューション一覧から `ディストリビューションを作成` ボタンをクリックします

![スクリーンショット 2022-06-06 10.37.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/5f092971-b2ca-621a-d92b-872cb1b2508a.png)

オリジンドメインには `react-Publi-...` のような、 AWS Copilot でデプロイした時に表示された URL と同じ
ELB のエンドポイントが選択肢に表示されるはずなので、それを選択します

![スクリーンショット 2022-06-06 10.38.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/d715608b-b6ee-ea21-b8d8-ee10e3c9b146.png)

それ以外は何も変更せず、最下部の `ディストリビューションを作成` ボタンをクリックします

最終変更日が `デプロイ` になっているので、ここに日時が表示されるまで待ちます

![スクリーンショット 2022-06-06 10.50.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/c7b48dac-fef7-3639-f99a-90ea8e7c87a7.png)

Auth0 の以下の項目の値を `https://<ディストリビューションドメイン名>/` にします

- Allowed Callback URLs
- Allowed Logout URLs

この状態で `https://<ディストリビューションドメイン名>/` にアクセスすると、ログインボタンが無事表示されます

![スクリーンショット 2022-06-06 11.16.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/b927ca4f-dbe4-9e05-c8e7-97edab829e7e.png)

設定がうまくいっていれば Auth0 のログイン画面が表示されます
※このログイン画面のデザインや文言は変更可能です

![スクリーンショット 2022-06-06 11.17.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/5837a2d3-80ea-269a-016b-8a2e0bcf3eef.png)

Sign Up してユーザーを作成してから改めて Log In しましょう

認証できてチャット画面が表示されました

と、思いきや、ブラウザのコンソールにはエラーが表示され、
websocket の接続がエラーになっているようです

![スクリーンショット 2022-06-06 11.20.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/ade18640-785a-bf07-ecce-3a944fce5bda.png)

`copilot svc logs` で Phoenix のログを見てみると、何だか分からないけどエラーが発生しています

```
copilot/lb-svc/c4c9313680 02:28:29.809 [error] #PID<0.2241.0> running ReactChatWeb.Endpoint (connection #PID<0.2231.0>, stream id 3) terminated
copilot/lb-svc/c4c9313680 Server: react-publi-rkepxdw2wsdp-243464349.ap-northeast-1.elb.amazonaws.com:80 (http)
copilot/lb-svc/c4c9313680 Request: GET /socket/websocket
copilot/lb-svc/c4c9313680 ** (exit) an exception was raised:
copilot/lb-svc/c4c9313680     ** (FunctionClauseError) no function clause matching in ReactChatWeb.UserSocket.connect/3
copilot/lb-svc/c4c9313680         (react_chat 0.1.0) lib/react_chat_web/channels/user_socket.ex:26: ReactChatWeb.UserSocket.connect(%{}, %Phoenix.Socket{assigns: %{}, channel: nil, channel_pid: nil, endpoint: ReactChatWeb.Endpoint, handler: ReactChatWeb.UserSocket, id: nil, join_ref: nil, joined: false, private: %{}, pubsub_server: ReactChat.PubSub, ref: nil, serializer: Phoenix.Socket.V1.JSONSerializer, topic: nil, transport: :websocket, transport_pid: nil}, %{})
copilot/lb-svc/c4c9313680         (phoenix 1.6.10) lib/phoenix/socket.ex:550: Phoenix.Socket.user_connect/6
copilot/lb-svc/c4c9313680         (phoenix 1.6.10) lib/phoenix/socket.ex:438: Phoenix.Socket.__connect__/3
copilot/lb-svc/c4c9313680         (phoenix 1.6.10) lib/phoenix/transports/websocket.ex:33: Phoenix.Transports.WebSocket.connect/4
copilot/lb-svc/c4c9313680         (phoenix 1.6.10) lib/phoenix/endpoint/cowboy2_handler.ex:22: Phoenix.Endpoint.Cowboy2Handler.init/4
copilot/lb-svc/c4c9313680         (cowboy 2.9.0) /app/deps/cowboy/src/cowboy_handler.erl:37: :cowboy_handler.execute/2
copilot/lb-svc/c4c9313680         (cowboy 2.9.0) /app/deps/cowboy/src/cowboy_stream_h.erl:306: :cowboy_stream_h.execute/3
copilot/lb-svc/c4c9313680         (cowboy 2.9.0) /app/deps/cowboy/src/cowboy_stream_h.erl:295: :cowboy_stream_h.request_process/3
```

### オリジンリクエストポリシー作成

CloudFront で websocket を使うための条件を確認してみましょう

https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/distribution-working-with.websockets.html

どうやら `Sec-WebSocket-Key` などのヘッダーが必要なようで、
それが CloudFront から ELB に渡っていないために通信できていないようです

ヘッダーの設定を紹介しているサイトもありました

https://nickzamosenchuk.medium.com/configure-amazon-cloudfront-cdn-for-websocket-connection-43c44b3f877c

また、 Auth0 の ID トークンを Phoenix に渡す際、トークンはクエリ文字列として渡されています

クエリ文字列もすべて CloudFront から ELB に渡す必要があります

というわけで、上記の条件を満たすオリジンリクエストポリシーを作成します

- CloudFront の左メニュー `ポリシー` をクリック
- `オリジンリクエスト` タブをクリック
- `オリジンリクエストポリシーを作成` をクリック

![スクリーンショット 2022-06-06 11.38.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/eda21d15-9833-3880-01e5-f0b524577bf6.png)

適当な名前を付けて、以下のような設定にします

- ヘッダー

  次のヘッダーを含める

  - Sec-WebSocket-Key
  - Sec-WebSocket-Version
  - Sec-WebSocket-Protocol
  - Sec-WebSocket-Accept

- クエリ文字列

  すべて

- cookie

  なし

![スクリーンショット 2022-06-06 11.43.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/de7ac1b7-d1ff-6bb0-cec3-aeb3d939fbbc.png)

続いて、作成したポリシーをビヘイビアに紐付けます

ELB に紐付いた CloudFront ディストリビューションのビヘイビアタブを開き、
`ビヘイビアを作成`　をクリックします

![スクリーンショット 2022-06-06 11.46.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/7f03f9a1-3c89-5139-a045-d91bccd4056e.png)

パスパターンを `/socket/*` （ websocket の接続先） にします

オリジンとオリジングループを ELB のエンドポイントにします

![スクリーンショット 2022-06-06 11.49.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/1a80faf8-3e55-7446-b524-f56ba744b275.png)

オリジンリクエストポリシーを先程作成したポリシーにします

![スクリーンショット 2022-06-06 11.49.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/3b6be55e-eadd-ce5e-c473-f40025b7f52b.png)

この状態でビヘイビアを作成し、ディストリビューションがデプロイされるのを待ちます

![スクリーンショット 2022-06-06 11.52.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/499832bf-f7d1-d781-ee6c-bc93db65d9b0.png)

ビヘイビアとポリシーの設定により、
CloudFront -> ELB -> Phoenix に必要なヘッダーとクエリ文字列が渡るようになり、
無事チャットが機能しました

![スクリーンショット 2022-06-06 11.55.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/1bc13821-80cd-c0bd-60e7-110aaec682d5.png)

## Auth0 トークンの検証

フロントエンド側の認証は `@auth0/auth0-react` パッケージがよしなにしてくれますが、
Phoenix 側でも Auth0 のトークンを検証しないと、どんなトークンでもチャットに参加できてしまいます

というわけで、 Phoenix 側にもトークン検証を追加します

Auth0 のトークンは JWT として提供されます

https://jwt.io/

なので、 JWT をパースするためのライブラリを Elixir 側に追加します

react_chat/mix.exs

```exs
...
  defp deps do
    [
      ...
      {:joken, "~> 2.4.1"},
      {:joken_jwks, "~> 1.6"},
      {:jose, "~> 1.11"},
      ...
    ]
  end
...
```

トークン検証用のモジュールを追加します

react_chat/lib/react_chat_web/token.ex

```exs
defmodule ReactChatWeb.Token do
  # no signer
  use Joken.Config, default_signer: nil

  add_hook(JokenJwks, strategy: ReactChatWeb.Auth0JwksStrategy)

  @impl true
  def token_config do
    domain = Application.get_env(:react_chat, ReactChatWeb.Token)[:domain]
    client_id = Application.get_env(:react_chat, ReactChatWeb.Token)[:client_id]

    default_claims()
    |> add_claim("aud", nil, &(&1 == client_id))
    |> add_claim("iss", nil, &(&1 == "https://#{domain}/"))
  end
end

defmodule ReactChatWeb.Auth0JwksStrategy do
  use JokenJwks.DefaultStrategyTemplate

  def init_opts(_) do
    domain = Application.get_env(:react_chat, ReactChatWeb.Token)[:domain]
    [jwks_url: "https://#{domain}/.well-known/jwks.json"]
  end
end
```

上記のコードを少し解説します

JWT はヘッダー、ペイロード、署名から構成されています

ヘッダーには暗号方式など、ペイロードにはユーザー情報などが含まれ、
それらが改竄されていないことを検証するため署名が付いています

トークンを発行するサービス（今回の例では Auth0）は秘密鍵を使って署名し、
トークンを受けるサービス（今回の例では Phoenix）は公開鍵を使って署名を検証します

Auth0 では、トークンに署名する際に使用する公開鍵の情報 JWKS （JSON Web Key Sets） を
`https://<Auth0のアプリケーションドメイン>/.well-known/jwks.json`
に公開しています

https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-key-sets

JWKS から公開鍵を取得し、署名を検証、トークンをパースするところまでを `joken_jwks` がやってくれます

パースされたトークンの `aud` に Auth0 アプリケーションのクライアント ID 、
`iss` に Auth0 アプリケーションのドメイン URL が入っているので、
トークン内の値が Auth0 アプリケーションの値と一致することを確かめます

websocket 接続要求時、トークンを検証して問題ない場合だけ接続します

react_chat/lib/react_chat_web/channels/user_socket.ex

```exs
defmodule ReactChatWeb.UserSocket do
...
  @impl true
  def connect(%{"token" => token}, socket, _connect_info) do
    case ReactChatWeb.Token.verify_and_validate(token) do
      {:ok, claim_map} ->
        Logger.info("claim_map = #{inspect(claim_map)}")
        {:ok, socket}

      _ ->
        Logger.info("verify error")
        {:error, "Invalid token"}
    end
  end
...
end
```

wscat を使ってトークン検証ができていることを確認しましょう

https://github.com/websockets/wscat

テキトーな値を入れた場合

```bash
$ wscat -c "wss://dmed6md8okgqs.cloudfront.net/socket/websocket?token=xxx&vsn=2.0.0"
error: Unexpected server response: 403
```

正当な ID トークンを入れた場合

```bash
$ export ID_TOKEN="<ブラウザの開発者ツールなどで取得した ID トークンの値>"
$ wscat -c "wss://dmed6md8okgqs.cloudfront.net/socket/websocket?token=${ID_TOKEN}&vsn=2.0.0"
Connected (press CTRL+C to quit)
```

正当なトークンの場合のみ接続することができました

## まとめ

これで一通り、以下のことが検証できました

- コスト・料金
- リリース用ビルド
- クラスタリング
- データベース
- 認証

websocket でリアルタイム通信する仕組みを構築するのであれば、
十分本番利用できるのではないでしょうか

AWS Copilot は非常に簡単でシンプルなので、
EC2　+ Ansible とか、 Terraform + SAM とかで疲れているのであれば、
検討してみてください