はじめに
こんにちは。インフラ部 プラットフォームグループの近藤です。
本記事では、AWS環境でAmazon ECS・Nuxt・Expressなどを使用して社内向けの認証付きWebサイトを構築した事例を紹介します。
こちらのWebサイト開発の話が挙がった際に、フロント開発としてはSPAの簡単なページ作成ぐらいしか経験がなかったのですが、個人的に興味がありシステム構築を担当させていただきました。
開発・リリースしたのは2023年の夏頃になります。
記事の内容としては、システムのアーキテクチャに触れた後に、以下に焦点を当てて書いています。
- Nuxtでユニバーサルレンダリングモード設定時の、ECSサービスの通信
- Keycloakと連携したNuxt, Expressの認証
- ユニバーサルレンダリングモード、認証ありでのアプリケーションのテストコード
Amazon ECSやNuxtを使用したWebサイト構築の一例として参考になれば幸いです。
目次
アーキテクチャ
システムは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に設定しました。
リクエストの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(OpenID Connect)による認証で、以下のフローとなっています。
- WebサイトにアクセスするとNuxtAuthによりKeycloak認証画面に遷移される
- 認証が成功したらアクセストークンが発行される
- リクエストヘッダーに発行されたアクセストークンを設定し、APIへリクエストを送信する
- APIはアクセストークンが問題ないかをKeycloakに確認する
また必要があったため、フロントエンド・バックエンドからKeycloakと通信する際はフォワードプロキシを経由しています。
Keycloak
Keycloakに今回使用するクライアント・クライアントスコープの設定を以下のように追加しました。
※既にレルムなどの設定が完了している前提です。
- クライアント
- フロントエンド用のクライアントを以下の設定をして作成する
クライアント認証
とクライアント認可
を有効にする- 作成後、
有効なリダイレクトURI
とWebオリジン
にWebサイトのURLを設定する クレデンシャル
タブが出現し、ここで確認できるシークレットはNuxtAuthのclientSecret
に設定する
- バックエンド用のクライアントをデフォルトで作成する
- フロントエンド用のクライアントを以下の設定をして作成する
- クライアントスコープ
- バックエンドの認証時に使用するクライアントスコープをデフォルトで作成する
- 作成後、
マッパー
タブを開き、Configure a new mapper
でAudience
を選び、含めるクライアント・オーディエンス
に先ほど作成したバックエンド用のクライアントを選択し、アクセストークンに追加
を有効にして作成する
- 作成後、
- 先ほど作成したフロントエンド用のクライアントから、
クライアントスコープ
タブを開き、クライアントスコープの追加
から作成したクライアントスコープを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.json
のpostinstall
にコマンド追加:
"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を使用して認証を設けているので、画面アクセス時に認証が求められます。
自動テストの認証の対応として、以下のいずれかを考えました。
- テスト実施時にDockerで認証サーバー(Keycloak)を立ち上げておく
- 事前に認証が通った状態でテストが実施されるよう、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.json
のscripts
に定義しています:
"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