Nuxt.js (View) + Chalice (API) + AWS によって SSG サイトを生成するテンプレート

# はじめに

ウェブアプリケーションを稼働させる場合、サーバーにWeb アプリケーション (nginx, httpd など)を入れたり、言語によって異なるサーバー用アプリケーション (Tomcat, gunicorn など)を入れたりする方法が従来行われてきた。 一方、クラウドのSaaS を活用することで、サーバーなしでウェブアプリケーションを稼働させることも可能になってきている。
Nuxt.js では、開発後にページを (`yarn generate` などで) 静的ファイルに変換し、これをウェブサーバーに配置することでウェブサイトを提供することができる。 これを SSG (Static Site Generation) などと呼ぶ。 これに API を組み合わせることで、より柔軟なウェブサイトを構築することが可能になる。
ここでは、以下の技術を組み合わせて AWS 上に SSG ウェブサイトを構築するためのテンプレートを作成したので、この説明を行う。

- Nuxt.js (v2.15.3): SSG による UI の作成に利用
- Chalice (Python 3.8): API の作成に利用
- Amazon Cognito UserPool: IDaaS としてユーザー管理に利用
- CloudFront + S3: 静的ファイルのホスティングと同一ドメインでのアクセス集約

これらの技術を用いたのは、個人的な好みである。 また、これらのサービスの配置関係は以下の図のようになる。

![nuxt-chalice.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/502582/4c7aedad-1b54-fa27-ddd6-9596322e8b57.png)


## テスト公開用ウェブサイト

以下でテンプレートを利用したウェブサイトを公開中。
ゲストユーザーとして `guest@t-kigi.net` / `Guest1234` を提供中。

https://nuxt-chalice-template.t-kigi.net/

テンプレートのソースは Github にて公開。

https://github.com/t-kigi/nuxt-chalice-aws-app-template

細かい使い方やデプロイ方法は Github の README.md 参照。

# コンテンツの工夫点など

実装は Github のコードを読めば書いてあるので、以下では工夫した点や悩んだ点についてを以下に記載する。


## Private S3 Bucket に対する Nuxt.js の設定

別記事を書いているのでそちらを参照。
そのため、各ページは基本 `.html` の拡張子ファイルとなっている。

https://qiita.com/t-kigi/items/c83f8c50ba10d7be6885

## Nuxt.js + Cognito のログイン実装

Cognito による実装は Nuxt.js のプラグインとして実装した。

https://github.com/t-kigi/nuxt-chalice-aws-app-template/blob/main/nuxt/plugins/cognito.ts

これを、プラグインで `inject` することで、各 Vue のページで `this.$cognito` として Cognito 用の実装を呼び出せるようにしている。

```typescript
export default ({ app }: Context, inject: (key: string, ref: any) => void) => {
  inject('cognito', new CognitoAuth(app, app.$config.cognito));
};
```

実際にログインを処理するには、以下のように `this.$cognito` を利用する。

```javascript
  methods: {
    async login() {
      try {
        const username = 'guest@t-kigi.net';
        const password = 'Guest1234';
        // Cognito に user/password を使ってログイン
        const res = await this.$cognito.login(username, password);
        if (!res.session) return;
        // 署名付きCookie を発行してブラウザに保存
        await axios.$get('/api/auth/signedcookie', {
          headers: {
            Authorization: res.session.getIdToken().getJwtToken(),
          },
        });
        // ログイン後領域に遷移
        this.$router.push('/m/index.html');
      } catch (error) {
        // ログイン失敗時はこちら
        console.log(error);
      }
    }
  }
```

Cognitoでログインに成功した場合、`/api/auth/signedcookie` に対してアクセスを行って `/m/*` 以下へアクセス可能とする署名付きCookieを得て、ブラウザに保持させている。 ブラウザにこれらのCookieなしに `/m/index.html` などにアクセスした場合、403 が返ってくる。 このテンプレートでは 403 が返ってくる場合、 CloudFront が `/errors/cushion.html` を返すような設定になっている(詳細は後述)。

ログインに成功すると JWT トークンを得られるが、ここで得られた [JWT トークンは `store/auth.js` に state として保持するようになっている](https://github.com/t-kigi/nuxt-chalice-aws-app-template/blob/main/nuxt/store/auth.js)。 そして、保持したこれらの情報をもとに API アクセスを行うための認証トークンをヘッダに含めたオブジェクトを `store/auth.js` の getter で取得できるようにしており、ログイン後の領域の API 利用時に利用できる。

## Nuxt.js middleware による認証制御

https://github.com/t-kigi/nuxt-chalice-aws-app-template/blob/main/nuxt/middleware/auth.js

Nuxt.js の middleware によって、各ルートに遷移した直後に実行するロジックを注入することができる。 ここでは `middleware/auth.js` を使って状態を確認してから、認証がうまくできていない場合は特定ページへと遷移させるようなロジックを記載している。

```javascript
export default async ({ app, route, store, redirect }) => {
  if (route.name === 'logout') {
    // ログアウトページでは何もしない
    return;
  }

  // 必要に応じてトークンを更新する
  if (store.getters['auth/isExpired']) {
    await store.dispatch('auth/update', {
      cognitoAuth: app.$cognito,
      axios: app.$axios,
    });
  }
  const isLoggedIn = store.getters['auth/isLoggedIn']();
  const isMemberPage = (r) => {
    // /m/index.html は alias なので、ここのみ独自に判定
    return r === 'm' || r.startsWith('m-');
  };
  if (!isLoggedIn && isMemberPage(route.name)) {
    // ログインでないのにメンバーページを開いている場合
    redirect('/');
  } else if (isLoggedIn && route.name === 'index') {
    // トップページかつログインしている場合は自動的に移動
    redirect('/m/index.html');
  }
};
```

## エラーページの処理

Nuxt.js が [ルーティングによって表示制御を行っている](https://qiita.com/aql/items/11b0f9743dff982c2526)　ため、CloudFront の挙動でエラー時に関係ないページを返すようにしても、別の Nuxt.js 制御下のページのルーティングを使ってしまい、正常に表示されない。
例えば、 `/hoge.html` で 403 or 404 として (pages/error.vue から生成された) `/error.html` を返した場合、`/error.html` のコンテンツを `/hoge.html` のパスで処理してしまうため、正常にページが表示されない。

そのため、ここでは　Nuxt.js に関係ない静的なクッションページ (`/static/errors/cushion.html`)を１つ作り、この中で `/error.html` にリダイレクトするような構造とした。

```yaml:s3andcloudfront.yaml
        CustomErrorResponses:
          - ErrorCachingMinTTL: 300
            ErrorCode: 400
            ResponseCode: 400
            ResponsePagePath: /errors/cushion.html
          - ErrorCachingMinTTL: 300
            ErrorCode: 403
            ResponseCode: 403
            ResponsePagePath: /errors/cushion.html
          - ErrorCachingMinTTL: 300
            ErrorCode: 404
            ResponseCode: 404
            ResponsePagePath: /errors/cushion.html
```

```html:static/errors/cushion.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <title>ERROR Cushion Page</title>
    <meta name="description" content="" />
    <meta name="keywords" content="" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="robots" content="noindex" />
    <meta name="googlebot" content="noindex" />
    <meta name="googlebot-news" content="noindex" />
  </head>
  <body>
    <p>ERROR...</p>
    <script type="text/javascript">
      document.addEventListener('DOMContentLoaded', function () {
        location.href = '/error.html';
      });
    </script>
  </body>
</html>
```

このようにしておくことで、署名つきCookieがない状態で `/m/index.html` などにアクセスした場合には `/error.html` へとリダイレクトされるようにしている。


## API実装側での Cognito Token の認証 (Python/Chalice)

個人的には最も利用しやすい Python による API 実装を行っている。 ここではその方法に Chalice というシンプルな AWS Lambda 用のフレームワークを用いた。
Chalice には Authorizer という仕組みがあり、Authorizer で指定した条件を満たさない場合はエラー(401)を返すような実装にできる。

ここでは、Chalice がデフォルトで提供している `CognitoUserPoolAuthorizer` を利用している。 この Authorizer では `Authorization` ヘッダに入っている JWT トークン (ID Token) で認証を行う。
これとは別に、CognitoIdp でユーザー情報を得るためには `AccessToken` が必要になるので、これを `CognitoAccessToken` として渡すようになっている。

```javascript:store.auth.jsから抜粋
export const state = () => ({
  accessToken: undefined, // Cognito Idp 用トークン  (CognitoAccessToken Header)
  idToken: undefined, // API Gateway 用認証トークン  (Authorization Header)
});
```

これを Python の boto3 側で渡すことで Cognito に登録したユーザー情報を取得できる。 サンプルとして実装しているのは以下の通りで、ログイン後のページの Private API Access ボタンで呼び出しを確認できる。

```python:chalicelib/routes/example.py
@app.route('/api/private/test', authorizer=authorizer, cors=cors)
def private_test():
    """ Cognito の認証がないとアクセスできない API の実装 """
    access_token = app.current_request.headers.get('cognitoaccesstoken')
    if access_token is None:
        return responses.application_json({'user': None})

    idp = store.get('aws.cognito-idp')
    user = idp.get_user(AccessToken=access_token)
    return responses.application_json({
        'user': user
    })
```


## ローカルでの CORS 設定

ローカルの検証時、ローカルで実行する場合はAPIのエンドポイント (localhost:3000) と、Nuxt.js のエンドポイント (localhost:3000) が異なる。
そのため、この部分を解消しないとローカルで正常な検証が行われない（厳密には、`CognitoUserPoolAuthorizer` はローカル稼働時には自動的に無効化されているし、local 同士のアクセスなので署名付きCookieがなくても関係なくアクセスできるのだが、正しく設定されることが確認できない）。

この場合、以下のサイトなどを参考に次の設定を行った。

https://qiita.com/y_tochukaso/items/78972e0e2917bb11fa10

- クライアント側では axios の設定に `credentials: true` を追加する
- API側ではローカルの場合のみ CORSConfig を設定する
- Cookie の設定は local の場合は Secure 属性を付けず、また、ドメインの指定を行わない

```python:app.py
if store.is_local():
    cors = CORSConfig(
        allow_origin=store.conf('FrontUrl'),
        allow_headers=['CognitoAccessToken'],
        allow_credentials=True
    )
else:
    cors = None
```

```python:chalicelib/routes/auth.py
    secure = '' if store.is_local() else 'Secure;'
    cookies = [
        f'{key}={value};Path=/;{secure}Expires={expire}'
        for (key, value) in publisher.publish(policy).items()
    ]
```

これで、APIで `/api/auth/signedcookie` を axios で呼び出したのち、Cookie が自動的にセットされるようになる。


# まとめ

以上、どのようなアプリケーションを作るとしても基本となる部分のテンプレートを作成し、そこでの工夫・解決方法などを述べた。

これを作成したモチベーションとして、個人的には個別の要素をカスタマイズしたいことがある。 ただし、自分はこれら全ての利用経験があるため配置関係を理解できるが、なにも知らない状態からこれを使おうと思うとかなり敷居が高いとは思う。

今回の構成 (Nuxt.js + API Gateway による API 実装 + CloudFront + S3 + Cognito) は AWS Amplify でも実現可能である。 以下の記事でも書いているが、CloudFront による署名付きCookieの実装を行いたい + API 部分を Python で書いてローカルで高速に検証したかったために Amplify を利用せずにこのような個別のテンプレートを用意した。

しかし、もし AWS Amplify のみで完結できるのならば、そちらを使うことをお勧めしておく。 

https://qiita.com/t-kigi/items/cd78a2bfbf80dfa1e2ee