Amazon ECS × Nuxt × Express での認証付きWebサイト構築事例

はじめに

こんにちは。インフラ部 プラットフォームグループの近藤です。

本記事では、AWS環境でAmazon ECS・Nuxt・Expressなどを使用して社内向けの認証付きWebサイトを構築した事例を紹介します。

こちらのWebサイト開発の話が挙がった際に、フロント開発としてはSPAの簡単なページ作成ぐらいしか経験がなかったのですが、個人的に興味がありシステム構築を担当させていただきました。
開発・リリースしたのは2023年の夏頃になります。

記事の内容としては、システムのアーキテクチャに触れた後に、以下に焦点を当てて書いています。

  • Nuxtでユニバーサルレンダリングモード設定時の、ECSサービスの通信
  • Keycloakと連携したNuxt, Expressの認証
  • ユニバーサルレンダリングモード、認証ありでのアプリケーションのテストコード

Amazon ECSやNuxtを使用したWebサイト構築の一例として参考になれば幸いです。

目次

  1. アーキテクチャ
  2. ECSサービスの通信
  3. 認証
  4. テストコード
  5. システム構築の振り返り
  6. 感想

アーキテクチャ

アーキテクチャ

システムはAWS上にAmazon ECSを使用したWeb3層アーキテクチャで構築しました。

フロントエンド(画面)とバックエンド(API)はKeycloakと連携して認証機能を実装しています。

主に使用している技術

AWS

AWSリソース作成のIaCツールとして、所属部署で広く使用されているTerraformを使用しました。

  • Amazon ECS
    • フロントエンド、バックエンドのコンテナをFargateで実行しています
    • Terraformにて以下のresourceを定義しています
      • クラスター : aws_ecs_cluster
      • タスク定義 : aws_ecs_task_definition
        • フロントエンドとバックエンドでそれぞれ定義
      • サービス : aws_ecs_service
        • フロントエンドとバックエンドでそれぞれ定義
  • AWS Cloud Map
    • ECSのフロントエンドサービスとバックエンドサービス間通信で使用しています
  • AWS Lambda
    • ECSサービスのタスク実行数を変更するプログラムを実行しており、夜間休日はタスクを停止しています
    • EventBridgeでスケジュールを設定して実行しています
  • Amazon RDS
    • 今回のWebサイト開発以前に、既にAWS上に構築済みのRDS(エンジンはPostgreSQL)があり、そちらを使用しています

アプリケーション

  • Node.js, TypeScript
    • フロントエンド・バックエンドの言語は統一したいと思い、共にTypeScriptを使用して開発しました
    • システム構築時のバージョンはNode.jsが18.15.0、TypeScriptが5.2.2です
  • Nuxt, Vuetify
    • 所属部署にVueの知見がある方がいたため、フロントエンドはNuxtにVuetifyを導入して開発しました
    • システム構築時のバージョンはNuxtが3.6.5、Vuetifyが3.3.12です
  • Express
    • Node.jsでAPIを構築する際に最も使用されているフレームワークなので、バックエンドのAPI開発で採用しました
    • システム構築時のバージョンは4.18.2です

認証系

  • Keycloak
    • フロントエンド、バックエンドの認証で使用しています
    • 今回のWebサイト開発以前に、既にAWS上に構築済みのKeycloakがあり、そちらを使用しています
    • システム構築時のバージョンは21.0.2です
  • NuxtAuth
    • フロントエンド(Nuxt)における Keycloak と連携した認証にNuxtAuthモジュールを使用して実装しました
    • システム構築時のバージョンは0.5.0です

テスト

  • Cypress
    • 所属部署で管理している別システムで使用されているため、フロントエンドのテストで採用しました
    • システム構築時のバージョンは12.16.0です
  • Jest, Supertest
    • バックエンド(Express)のテストで使用しています
    • システム構築時のバージョンはJestが29.6.2、Supertestが6.3.3です

ECSサービスの通信

Nuxtでユニバーサルレンダリングモード設定時の、ECSサービスの通信などについて記述します。

ユニバーサルレンダリング

Nuxtには以下のレンダリングモードがあります(参考:Rendering Modes · Nuxt Concepts):

  • ユニバーサルレンダリング
    • ユニバーサル(サーバー側 + クライアント側)にレンダリングしてHTMLを生成する
    • Nuxtのデフォルトはユニバーサルレンダリングとなっている
  • クライアントサイドレンダリング
    • クライアントサイド(ブラウザ)でレンダリングしてHTMLを生成する

これまでSPAの簡単なページを作成したことはあったのですが、サーバーサイドレンダリングのシステムは開発した経験がなかったので、興味があったこともありNuxtのデフォルトであるユニバーサルレンダリングモードで開発しました(システム構築の振り返りに書いていますが、振り返ったらクライアントサイドレンダリングの方が良かったかと思っています)

ECS Service Connect

サーバーサイドでのレンダリングが発生するので、フロントエンドとバックエンドのECSサービス間で通信する必要がありました。
ECSサービス間の通信を実現する方法として、以下の4つを検討しました。

  • Elastic Load Balancer
  • ECS Service Discovery
  • AWS App Mesh
  • ECS Service Connect

それぞれメリットとデメリットがありますが、2022年のAWS re:Inventで発表された機能で他3つのメリットを活かすようになっているECS Service Connectを今回は採用しました。
Terraformを使用しているので、こちらの記事(ECS Service ConnectをTerraformでデプロイしてみた | DevelopersIO)を参考にさせていただきECS Service Connectを実装しました。

クライアントサイドからのAPIアクセス

クライアントサイド(ブラウザ)からのバックエンドAPIへのアクセスは、ALBのパスベースルーティングを利用しました。

「/api〜」のパスはバックエンドAPIで使用するものとし、/api*へのリクエストはルーティングされるようにALBに設定しました。

APIへのアクセス

リクエストのURL

クライアントサイド、サーバーサイドの双方からバックエンドAPIへ通信されますが、サーバーサイドからAPIへの通信はECS Service Connectを使用しているため、それぞれの通信時に異なるURLをプログラム上で指定する必要があります。

Nuxtではprocess.clientプロパティにて現在の処理がクライアントサイドかサーバーサイドかを判定できるので、utilsディレクトリ内のファイルに以下の関数を作成し、APIへのリクエスト時に指定しています:

export const getSubmitTo = () => {
  const config = useRuntimeConfig();
  return process.client ? config.public.ApiBrowserUrl : process.env.API_BASE_URL;
};

nuxt.config.ts

export default defineNuxtConfig({
  runtimeConfig: {
    public: {
      ApiBrowserUrl: "" // can be overridden by NUXT_PUBLIC_API_BROWSER_URL environment variable
    }
  },

...

APIへのリクエスト時:

const { data, error } = await useFetch<{ [name: string]: string }[], Error>(getSubmitTo() + "/api/v1/**********", {
  query: { condition: condition },
  headers: { Accept: "application/json", Authorization: "Bearer " + token }
});

リクエスト送信には、Nuxt3で用意されている機能のuseFetchを使用しています。

認証

フロントエンド(画面)・バックエンド(API)へのアクセス時に認証を設定しています。
認証機能の開発経験や、OIDCの詳しい知識もなかったため、このシステム開発で一番苦労しました。

OIDCによる認証フロー

OIDC(OpenID Connect)による認証で、以下のフローとなっています。

  1. WebサイトにアクセスするとNuxtAuthによりKeycloak認証画面に遷移される
  2. 認証が成功したらアクセストークンが発行される
  3. リクエストヘッダーに発行されたアクセストークンを設定し、APIへリクエストを送信する
  4. APIはアクセストークンが問題ないかをKeycloakに確認する

また必要があったため、フロントエンド・バックエンドからKeycloakと通信する際はフォワードプロキシを経由しています。

Keycloak

Keycloakに今回使用するクライアント・クライアントスコープの設定を以下のように追加しました。
※既にレルムなどの設定が完了している前提です。

  • クライアント
    • フロントエンド用のクライアントを以下の設定をして作成する
      • クライアント認証クライアント認可を有効にする
      • 作成後、有効なリダイレクトURIWebオリジンにWebサイトのURLを設定する
      • クレデンシャルタブが出現し、ここで確認できるシークレットはNuxtAuthのclientSecretに設定する
    • バックエンド用のクライアントをデフォルトで作成する
  • クライアントスコープ
    • バックエンドの認証時に使用するクライアントスコープをデフォルトで作成する
      • 作成後、マッパータブを開き、Configure a new mapperAudienceを選び、含めるクライアント・オーディエンスに先ほど作成したバックエンド用のクライアントを選択し、アクセストークンに追加を有効にして作成する
    • 先ほど作成したフロントエンド用のクライアントから、クライアントスコープタブを開き、クライアントスコープの追加から作成したクライアントスコープをDEFALTで追加する

NuxtAuth

フロントエンドの認証にNuxtAuthを使用しています。

Keycloakはモジュールの標準プロバイダーに存在しており、プロバイダーにKeycloakを指定して実装しました(参考:Keycloak | NextAuth.js

その際の以下の対応について記述します。

  • ルーティング設定
  • プロキシ設定
  • アクセストークンリフレッシュ
  • アクセストークン取得のための設定

NuxtAuthはNextAuthがベースになっているので、NextAuthの公式ドキュメントもよく参照しました。

ルーティング設定

NuxtAuthを使用して実装すると、Nuxtのサーバー機能によって認証APIが公開されます(参考:REST API - by sidebase

この認証APIがデフォルト設定だと/api/authに公開されますが、/api〜は バックエンドAPIへのルーティングを想定していたためパスが被りました。

なので、NuxtAuthの認証APIのパスは/api/authではなく/authになるように変更しました。

  • ファイル配置場所を ~/server/api/auth/[...].ts から ~/server/routes/auth/[...].ts へと変更
  • nuxt.config.tsにてauth.basePathを設定 :
  modules: ["@sidebase/nuxt-auth"],
  auth: {
    isEnabled: true,
    enableGlobalAppMiddleware: true,
    basePath: "/auth"
  },

プロキシ設定

認証時の通信はフォワードプロキシを経由する必要があったため、設定を追加しています。

NextAuthの公式ドキュメントにプロキシ設定について記載があり、そちらを参考にプロキシ設定を実装しました(参考:corporate-proxy | NextAuth.js

NuxtAuthモジュール内のプログラムを変更する必要があるので、patch-packageモジュールを使用することで、デプロイ用のDockerイメージが作成される際にもNuxtAuthモジュール内のプログラムが変更されるようにしています(参考:GitHub - ds300/patch-package: Fix broken node modules instantly 🏃🏽‍♀️💨

patch-packageのパッチファイル:

diff --git a/node_modules/next-auth/core/lib/oauth/client.js b/node_modules/next-auth/core/lib/oauth/client.js
index c2f0d40..2bc1769 100644
--- a/node_modules/next-auth/core/lib/oauth/client.js
+++ b/node_modules/next-auth/core/lib/oauth/client.js
@@ -7,11 +7,19 @@ exports.openidClient = openidClient;

 var _openidClient = require("openid-client");

+var { HttpsProxyAgent } = require("https-proxy-agent");
+
 async function openidClient(options) {
   const provider = options.provider;
-  if (provider.httpOptions) _openidClient.custom.setHttpOptionsDefaults(provider.httpOptions);
-  let issuer;
+  let httpOptions = {};
+  if (provider.httpOptions) httpOptions = { ...provider.httpOptions };
+  if (process.env.HTTP_PROXY) {
+    let agent = new HttpsProxyAgent(process.env.HTTP_PROXY);
+    httpOptions.agent = agent;
+  }
+  _openidClient.custom.setHttpOptionsDefaults(httpOptions);

+  let issuer;
   if (provider.wellKnown) {
     issuer = await _openidClient.Issuer.discover(provider.wellKnown);
   } else {

package.jsonpostinstallにコマンド追加:

  "scripts": {
    "postinstall": "nuxt prepare & patch-package"
  }

アクセストークンリフレッシュ

NuxtAuth(バージョン0.5.0)では、デフォルトではアクセストークンをリフレッシュする機能はないので、アクセストークンをリフレッシュするように[...].tsファイルに実装しています。

NextAuth の公式ドキュメントにトークンリフレッシュの記載があるので、そちらを参考に実装しました(参考:Auth.js | Refresh Token Rotation

※ 2024年8月現在のNuxtAuthバージョン0.9.0の公式ドキュメントを見るとリフレッシュトークンに関する記載があり、検証はしていませんがもしかしたら現在はモジュールの標準機能として提供されているかもしれません(参考:Local provider - by sidebase

アクセストークン取得のための設定

Keycloakでユーザー認証後に返されたアクセストークンを、API へのリクエスト時にヘッダーに付与することで、API でも認証しています:

  headers: { Accept: "application/json", Authorization: "Bearer " + ACCESS_TOKEN },

NuxtAuthで取得したアクセストークンを ~/server/routes/auth/[...].ts ファイル以外でも使用できるようにするために、~/server/routes/auth/[...].tsファイルの Session callbackにて、アクセストークンや有効期限などをセッションに設定しています(参考:Callbacks | NextAuth.js):

    session({ session, token }) {
      session.accessToken = token.accessToken;
      session.expiresAt = token.expiresAt;
      return session;
    }

このようにアクセストークンをセッションに設定することで、フロント側にてuseAuthを使用してアクセストークンを取得できるようになります(参考:Session Access and Management - by sidebase

ただ、アクセストークン取得時には以下のようなケースがそれぞれ想定されるので、正常にアクセストークンを取得できるように注意が必要です。

  • Webサイト初回アクセス時でトークン情報がない場合
  • セッションに情報はあるがアクセストークンの有効期限が切れている場合
  • セッションに情報はあるがアクセストークンもリフレッシュトークンも有効期限が切れている場合

Express

フロントエンドと同様に、バックエンドAPIにもexpress-oauth2-jwt-bearerというモジュールを使用して認証機能を実装しています(参考:express-oauth2-jwt-bearer - npm):

import { auth } from "express-oauth2-jwt-bearer";
import { HttpsProxyAgent } from "https-proxy-agent";

...

interface AuthOption {
  issuerBaseURL: string | undefined;
  audience: string | undefined;
  agent?: HttpsProxyAgent<string> | undefined;
}

...

const authOptions: AuthOption = {
  issuerBaseURL: process.env.AUTH_ISSUER,
  audience: process.env.AUTH_AUDIENCE
};
if (process.env.HTTP_PROXY) {
  const agent = new HttpsProxyAgent(process.env.HTTP_PROXY);
  authOptions["agent"] = agent;
}
app.all("/api/v*", auth(authOptions));

Expressの起動ファイルに上記を追加しています。

フロントエンドと同様に通信時にはプロキシ設定が必要なため、https-proxy-agentモジュールを使用しています。

上記の設定をすることで、/api/v* のパスにアクセスする際は認証が必要になり、リクエストのヘッダーに付与されたアクセストークンをKeycloakへ確認するようになります。

ALBのヘルスチェック

ALBのヘルスチェックは正常になるように、フロントエンド・バックエンド共にヘルスチェックリクエストは認証を避けるように設定しています。

Nuxt

ALBヘルスチェック用にpages/healthcheck.vueページを作成し、definePageMeta({ auth: false });によって認証の対象外となるようにしています:

<template>
  <div>
    <h1>Health Check Page</h1>
  </div>
</template>
<script setup lang="ts">
// ALBヘルスチェック用に認証の対象外とする
definePageMeta({ auth: false });
</script>

Express

認証の対象となるパスは/api/v*となるようにしているので、認証の対象外となる/api/healthcheckをヘルスチェックのパスとし、リクエストが正常に返るように設定しています。

テストコード

フロントエンドはCypress、バックエンドはJestとSupertestを使用してテストを実装しています。

今回のシステム構築では認証やユニバーサルレンダリングを使用しているので、それによって影響を受けた点について記述します。

Cypress

認証への対応

NuxtAuthを使用して認証を設けているので、画面アクセス時に認証が求められます。

自動テストの認証の対応として、以下のいずれかを考えました。

  1. テスト実施時にDockerで認証サーバー(Keycloak)を立ち上げておく
  2. 事前に認証が通った状態でテストが実施されるよう、Cypressに設定する

1.のデメリットとしてはテスト時に事前に認証サーバーを立ち上げておく必要があるので、テスト実施が重くなります。

2.は事前に認証サーバーを立ち上げておく必要はありませんが、認証が求められないので認証部分はテストされずにスキップされてしまいます。

今回は1.のデメリットをなるべく避けたかったので2.を採用しました。
Cypressに事前に認証済みの設定が入るよう、こちら(Request: example of cypress testing · nextauthjs next-auth · Discussion #2053 · GitHub)を参考に実装しました。

support/commands.ts :

import { hkdf } from "@panva/hkdf";
import { EncryptJWT, JWTPayload } from "jose";

async function getDerivedEncryptionKey(secret: string) {
  return await hkdf("sha256", secret, "", "NextAuth.js Generated Encryption Key", 32);
}

export async function encode(token: JWTPayload, secret: string) {
  const maxAge = 30 * 24 * 60 * 60; // 30 days
  const encryptionSecret = await getDerivedEncryptionKey(secret);
  return await new EncryptJWT(token)
    .setProtectedHeader({ alg: "dir", enc: "A256GCM" })
    .setIssuedAt()
    .setExpirationTime(Date.now() / 1000 + maxAge)
    .setJti("test")
    .encrypt(encryptionSecret);
}

Cypress.Commands.add("login", () => {
  cy.intercept("/auth/session", { fixture: "session.json" }).as("session");

  cy.wrap(null)
    .then(() => cy.fixture("session.json"))
    .then((sessionJSON) => encode(sessionJSON, Cypress.env("NEXTAUTH_JWT_SECRET")))
    .then((encryptedToken) => cy.setCookie("next-auth.session-token", encryptedToken));
});

fixtures/session.json :

{
    "user": {
        "name": "Test",
        "email": "Test",
        "image": "/path/to/your/mock/user.jpg"
    },
    "expires": "3000-01-01T00:00:00.000Z",
    "accessToken": "abcdefghijklmnopqrst",
    "expiresAt": "4070876400"
}

commands.tsにて定義したloginコマンドを、e2eフォルダ配下の各テストにて事前に実行する :

describe("〜〜〜〜〜 Test", function () {
  beforeEach(() => {
    cy.login();
  });

...

モック

バックエンドAPI通信部分のモックとして、Cypressのinterceptを使用しようとしていました。

ですが、恐らくユニバーサルレンダリングを採用していることでサーバサイド・クライアントサイドの双方から通信が発生する影響からかinterceptによるモックが正常に動作しませんでした。

なので、今回は代わりのモックとしてjson-serverを使用しました。
テスト用にNuxtとjson-serverを起動できるよう、package.jsonscriptsに定義しています:

  "scripts": {
    "json-server": "json-server --watch ./test/json-server/data.json --routes ./test/json-server/routes.json --host 127.0.0.1 -p 9999 --no-cors true",
    "dev-test": "nuxt dev --dotenv .env.test",
    "ci": "yarn json-server & yarn dev-test",

...

Jest, Supertest

バックエンドのテストコードでは、DBがPostgreSQLなので、モックにはpg-mem(GitHub - oguimbal/pg-mem: An in memory postgres DB instance for your unit tests)を使用しました。

認証への対応

バックエンドでも認証機能を実装しているので、フロントエンドと同様に認証への対応が必要です。

事前に認証サーバー(Keycloak)を立ち上げておくことは避けたかったので、テスト時は認証をスキップするように今回は対応しました:

...

if (process.env.APP_ENV !== "test") {
  const authOptions: AuthOption = {
    issuerBaseURL: process.env.AUTH_ISSUER,
    audience: process.env.AUTH_AUDIENCE
  };
  if (process.env.HTTP_PROXY) {
    const agent = new HttpsProxyAgent(process.env.HTTP_PROXY);
    authOptions["agent"] = agent;
  }
  app.all("/api/v*", auth(authOptions));
}

...

システム構築の振り返り

主にシステムの構成について振り返ります。

Nuxtのレンダリングモード

現在振り返ってみて、システム構成でこうした方が良かったかもというのはいくつかありますが、一番はNuxtのレンダリングモードです。

ユニバーサルレンダリングではなくてクライアントサイドレンダリングを使用していたら、以下のような利点があったかと思います。

  • ECS Service Connectを使用する必要がなくなる
    • システム構成がわかりやすくなり、Terraform上で必要な設定も少なくなる
    • ECS Service Connectを使用するとプロキシ用のサイドカーコンテナが立ち上がるが、その分のリソースを考慮する必要がなくなる
  • クライアントサイドからの接続のみになるので、こちらのようにアプリケーション上でprocess.clientプロパティにて分岐する必要がなくなる
  • Cypressによるテストがjson-serverを使用しなくても、Cypressのinterceptを使用してモックできたかもしれない

ユニバーサルレンダリングの利点であるパフォーマンス向上と検索エンジン最適化については、社内向けWebサイトなのでそこまで求められておらず、クライアントサイドレンダリングの方が良かったかと思っています。

バックエンドAPI

他には、今回はExpressでバックエンドAPIを構築していますが、ExpressではなくてNuxtのサーバー機能を使用するという選択肢もありました。
Nuxtのサーバー機能を使用していたら以下のような利点があったかと思います。

  • ECSで動作させるサービスが1つになる
    • バックエンドのリソースがなくなるのでランニングコストが下がる
    • Terraform上で定義が必要なリソースが少なくなる
  • Expressを使用せずにNuxtで完結するので、システムの学習コストが下がる

NuxtAuthなど認証が関係して、もしかしたらNuxtのサーバー機能では実装できなかったかもしれませんが、そういう選択肢もあったかと思いました。

感想

フロント開発の経験があまりなかったので、こちらのシステム開発では苦労したことが多かったのですが、その分個人的に考えたことや工夫したことも多かったので、今回の記事のテーマとさせていただきました。

新規システム構築ということで使用する技術の選定から、AWSアーキテクチャの構築、フロントエンド・バックエンドのアプリケーション開発、またGitHub ActionsによるCI/CD実装といったことも構築・開発させていただき、貴重な経験になりました。

振り返ったらこうした方が良かったかもということも多く全然完璧なシステムではありませんが、自分が開発したシステムが現在でも使われているのを見るとシステム開発はいいなと思います。

今回のシステムを構築・開発する機会をいただけて感謝しています。

近藤 章則 Akinori Kondo

テクノロジー本部 ITマネジメント&インフラ・セキュリティ統括部 インフラ部 プラットフォームG