😎

Next.jsのMiddlewareでAmplify Auth(Cognito)の認証状態によってログイン前後の画面を振り分ける

2022/12/02に公開

こんにちわ。 ZUMA です。

認証が必須の MPA の WEB アプリケーションを実装する際に以下の URL ルーティング要件があったとします。

  • ルート URL にアクセスがあった場合、認証前であればログイン画面、認証後であればダッシュボード画面にリダイレクトする
  • 認証前にダッシュボード画面の URL を直接叩かれた場合にログイン画面にリダイレクトする
  • 認証後にログイン画面の URL を直接叩かれた場合にダッシュボード画面にリダイレクトする
  • 利用規約やプライバシーポリシー画面は認証状態に関係無く表示する

要件を実現させる為にまず以下処理を思いつきました。

  • 各画面で認証状態を見て他画面にリダレクトする
  • 画面共通で呼ばれる箇所(Next.js だと_app.tsx など)で認証状態と URI を見てリダイレクトする

しかしサービスのドメイン部分のロジックに関係無い認証の関心事は外部にまとめて実装したいと思いました。

そこで Next.js には Middleware というリクエストが完了する前にエッジ環境で処理を実行する仕組みがあります。

https://nextjs.org/docs/advanced-features/middleware

Next.js の v12.2 から Middleware が stable になっているので Middleware で認証状態を判定して URL ルーティングをしてみたいと思います。

認証には AWS Amplify の Amplify Auth カテゴリ(Cognito)を使用します。

ちなみに筆者はフロントエンド開発歴が浅いので、間違えている箇所あればぶんぶんマサカリお願いします。

実行環境

  • Node
    • 16.13.0
  • npm
    • 8.1.0
  • Next.js
    • 13.0.5
  • Amplify CLI
    • 10.5.1

Next.js プロジェクトを作成する

以下コマンドを実行して Next.js プロジェクトを作成します。

npx create-next-app@latest --ts --use-npm nextjs-cognito-middleware-sample

プロジェクトルートディレクトリへ移動します。

cd nextjs-cognito-middleware-sample

Middleware の制約

Middleware は Web 標準の API を使ったエッジ環境で動くので制約があります。

https://nextjs.org/docs/api-reference/edge-runtime#unsupported-apis

例えば Middleware で Web 標準の API 以外でクライアントに依存するようなパッケージを利用しようとすると以下のようなエラーを出力します。

error - (middleware)/node_modules/@aws-amplify/core/lib-esm/Util/Reachability.js (11:0) @ <unknown>
error - window is not defined
null

最初何故 Middleware 上でほとんどのパッケージが動かないんだとハマってしまいました。。

aws-amplify や aws-jwt-verify などのパッケージを利用すれば簡単に Cognito の認証状態を判別出来ます。

ですが Middleware では使えるパッケージが限られるので、認証状態の判定処理の大部分を頑張って自前で書く必要がありました。

色々調べて以下のパッケージが認証部分の判定に使えそうだったのでインストールします。

npm i jose

Middleware の処理の流れ

Middlerare の処理の流れは大きく以下のようになります。

  1. Cookie から Cognito の認証トークン(idToken/accessToken/refreshToken)を取得
  2. Cookie に認証トークンが無ければ未認証状態
  3. Cookie から認証トークンが取得出来れば idToken を JWT 検証
  4. JWT 検証結果エラーだった場合未認証状態
  5. JWT 検証を通過すれば認証済
  6. idToken の有効期限が切れている場合 refreshToken で idToken/acccessToken を再作成
  7. 再作成した idToken/accessToken を Cookie にセット

Cognito の認証トークンとは

今回 Middleware では Cookie に保存された Cognito の認証トークンを使用して認証状態を判別します。

Cognito の認証トークンには以下の 3 種類があります。

用途 有効期限(デフォルト)
idToken 外部サービス認証または認証されたユーザー情報参照 1 時間
accessToken ユーザー属性の追加・変更・削除 1 時間
refreshToken 新しい idToken および accessToken の取得 30 日

今回認証判定には idToken を使用します。

idToken の有効期限が切れている場合は refreshToken を使用して idToken/acccessToken を再作成して Cookie に詰め直します。

accessToken は今回使用していませんが、筆者の場合 accessToken は自前の API サーバの認証で利用しています。

API サーバへの request header に Authorization: Bearer ${accessToken} を付けて API サーバの認証処理に利用しています。

ですので refreshToken で idToken を再作成する場合は必ず accessToken も再作成して Cookie には常に最新の idToken/accessToken がセットされている状態にします。

Amplify を設定する

Amplify の初期化

前提として Amplify CLI インストール済み、 amplify configure で Amplify で使用する AWS リソースにアクセス可能な IAM ユーザーは作成済みとします。

プロジェクトルートディレクトリで以下コマンドを実行して Amplify の初期化を行います。

amplify init

設問はデフォルトの通り回答していきます。

Note: It is recommended to run this command from the root of your app directory
? Enter a name for the project nextjscognitomiddlew
The following configuration will be applied:

Project information
| Name: nextjscognitomiddlew
| Environment: dev
| Default editor: Visual Studio Code
| App type: javascript
| Javascript framework: react
| Source Directory Path: src
| Distribution Directory Path: build
| Build Command: npm run-script build
| Start Command: npm run-script start

? Initialize the project with the above configuration? Yes
Using default provider  awscloudformation
? Select the authentication method you want to use: AWS profile

For more information on AWS Profiles, see:
https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-profiles.html

? Please choose the profile you want to use [自身の環境のprofileを選択]
Adding backend environment dev to AWS Amplify app: dmgki8ps6fwkm

Deployment completed.
Deployed root stack nextjscognitomiddlew [ ======================================== ] 4/4
	amplify-nextjscognitomiddlew-… AWS::CloudFormation::Stack     CREATE_COMPLETE                Thu Dec 01 2022 16:38:31…
	DeploymentBucket               AWS::S3::Bucket                CREATE_COMPLETE                Thu Dec 01 2022 16:38:11…
	UnauthRole                     AWS::IAM::Role                 CREATE_COMPLETE                Thu Dec 01 2022 16:38:28…
	AuthRole                       AWS::IAM::Role                 CREATE_COMPLETE                Thu Dec 01 2022 16:38:29…

✔ Help improve Amplify CLI by sharing non sensitive configurations on failures (y/N) · no
Deployment bucket fetched.
✔ Initialized provider successfully.
✅ Initialized your environment successfully.

Amplify Auth カテゴリを追加する

以下のコマンドを実行して Amplify Auth カテゴリを追加します。

amplify add auth

今回 Next.js の Middleware 検証の為、Cognito の設定は最低限メールアドレスとパスワードのみの認証とします。

Using service: Cognito, provided by: awscloudformation

 The current configured provider is Amazon Cognito.

 Do you want to use the default authentication and security configuration?
❯ Default configuration
  Default configuration with Social Provider (Federation)
  Manual configuration
  I want to learn more.
 How do you want users to be able to sign in?
  Username
❯ Email
  Phone Number
  Email or Phone Number
  I want to learn more.
 Do you want to configure advanced settings? (Use arrow keys)
❯ No, I am done.
  Yes, I want to make some additional changes.
✅ Successfully added auth resource nextjscognitomiddlewe0c8d7ed locally

✅ Some next steps:
"amplify push" will build all your local backend resources and provision it in the cloud
"amplify publish" will build all your local backend and frontend resources (if you have hosting category added) and provision it in the cloud

以下のコマンドで作成した Amplify Auth カテゴリをクラウドにプロビジョニングします。

amplify push -y

画面の実装をする

共通処理を実装する

まずは pages/_app.tsx に全画面の共通処理を実装します。

以下のパッケージをインストールします。

npm i @aws-amplify/ui-react aws-amplify

Authenticator.Provider で Component をラップして全画面で useAuthenticator hook を利用可能にします。

https://github.com/kazuma-fujita/nextjs-cognito-middleware-sample/blob/main/pages/_app.tsx

ポイントは Amplify.configuressr: true と設定することです。

Cognito はデフォルトで認証トークンをブラウザの Local Storage に保存します(設定で Session storage に保存も可能)。

ssr オプションを有効にすることにより、Cognito の認証トークンを Cookie に保存します。

認証トークンを Cookie に保存する事により、Middleware から Cookie にアクセスして認証状態の判定が可能になります。

ログイン画面を実装する

実装は公式のコードをそのまま使います。

https://ui.docs.amplify.aws/react/connected-components/authenticator

https://github.com/kazuma-fujita/nextjs-cognito-middleware-sample/blob/main/pages/signin.tsx

http://localhost:3000/signin にアクセスしてログイン画面が表示されることを確認します。

Sign Up からユーザー作成後、Cognito 上にユーザーが存在することを確認します。

その他の画面を実装する

認証済みの状態でのみアクセス出来るダッシュボード画面です。

https://github.com/kazuma-fujita/nextjs-cognito-middleware-sample/blob/main/pages/dashboard.tsx

認証状態に関係無くアクセス出来る利用規約画面です。

https://github.com/kazuma-fujita/nextjs-cognito-middleware-sample/blob/main/pages/terms.tsx

環境変数を設定する

Middleware 上では環境変数が使用できるようです。

https://nextjs.org/docs/api-reference/edge-runtime#environment-variables

src/aws-exports.js の値を利用出来れば良いのですが、Middleware のエッジ環境からはアクセス出来ないので別途環境変数を設定する必要があります。

COGNITO_URL 以外は src/aws-exports.js にある値を記述します。

.env.development.local
REGION="ap-northeast-1"
COGNITO_USER_POOLS_ID="ap-northeast-1_XXXXXXXX"
COGNITO_USER_POOLS_WEB_CLIENT_ID="1rjnt22ltlaXXXXXXXXXXXXX"
COGNITO_URL="https://XXXXXXXXXX.auth.ap-northeast-1.amazoncognito.com"

COGNITO_URL は Cognito の Console で Cognito ドメインを作成して値を設定します。

該当のユーザープールの アプリケーションの統合 タブを開きます。

アクションプルダウンから Cognitoドメインの作成 を開きます。

Cognito ドメインを入力して Cognitoドメインの作成 ボタンを押下します。

Middleware を実装する

以下が Middleware の実装となります。

Middleware はプロジェクトルートに middleware.ts ファイルを作成するだけで Vercel 等にデプロイ後エッジ環境で動作します。これには感動しました。

https://github.com/kazuma-fujita/nextjs-cognito-middleware-sample/blob/main/middleware.ts

Middleware を通さない HTTP リクエストを設定する

ポイントはまず、以下 Middleware の config 設定で Middleware を通さない HTTP リクエストを設定します。

middleware.ts
export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - api (API routes)
     * - _next/static (static files)
     * - favicon.ico (favicon file)
     * - .svg (SVG file)
     * - excluded paths (e.g. static screen path)
     */
    "/((?!api|_next/static|favicon.ico|.*\\.svg|terms).*)",
  ],
};

上記の場合、主に静的ファイルを除外しています。

また、以下の URL ルーティング要件を満たす為、静的画面である利用規約画面 terms のパスを除外しています。

  • 利用規約やプライバシーポリシー画面は認証状態に関係無く表示する

ルート URL アクセスはダッシュボード画面へリダイレクトする

Middleware の先頭で以下の要件を満たす為にルートパスはダッシュボード画面へリダイレクトさせます

  • ルート URL にアクセスがあった場合、認証前であればログイン画面、認証後であればダッシュボード画面にリダイレクトする
middleware.ts
  const url: NextURL = request.nextUrl.clone();

  const signin = `${url.origin}/signin`;
  const dashboard = `${url.origin}/dashboard`;

  if (url.pathname === "/") {
    return NextResponse.redirect(dashboard);
  }

未認証状態の場合以下の流れでログイン画面にリダイレクトされます。

  • ルートパスにアクセス
  • ダッシュボード画面へリダイレクト
  • Middleware で未認証であることを判定
  • ログイン画面へリダイレクト

以下が Cookie から Cognito の認証トークンを取得するモジュールになります。

https://github.com/kazuma-fujita/nextjs-cognito-middleware-sample/blob/main/src/auth/services/get-cookie-by-cognito-token-type.ts

https://github.com/kazuma-fujita/nextjs-cognito-middleware-sample/blob/main/src/auth/services/get-cognito-token-from-cookie.ts

Cookie には認証トークンが以下のような形式で保存されているので正規表現を使用して idToken/accessToken/refreshToken をそれぞれ取得します。

CognitoIdentityServiceProvider.{COGNITO_USER_POOLS_WEB_CLIENT_ID}.XXXX-XXXX-XXXX-XXXX-XXXXXXXXX.idToken

未認証状態はログイン画面へリダイレクトする

以下の要件を満たす為に未認証状態の処理を実装します。

  • 認証前にダッシュボード画面の URL を直接叩かれた場合にログイン画面にリダイレクトする

まず、 unauthenticatedPaths にログイン画面など認証前画面のパスを配列で記述します。(こちらは後ほど使用します)

authenticatedPaths にはダッシュボード画面など認証必須画面のパスを配列で記述します。

以下実装は Cookie から認証トークンが取得できない状態を未認証状態としています。

未認証状態かつ、認証必須画面のアクセスの場合(authenticatedPaths のパスだった場合) ログイン画面にリダイレクトさせて要件を満たしています。

middleware.ts
const unauthenticatedPaths: string[] = ["/signin"];
const authenticatedPaths: string[] = ["/dashboard"];

export async function middleware(request: NextRequest): Promise<NextResponse> {
  const url: NextURL = request.nextUrl.clone();

  const signin = `${url.origin}/signin`;
  const dashboard = `${url.origin}/dashboard`;

  const cognitoToken: CognitoToken | null = getCognitoTokenFromCookie(request);
  if (!cognitoToken) {
    if (authenticatedPaths.includes(url.pathname)) {
      return NextResponse.redirect(signin);
    }

    return NextResponse.next();
  }

idToken を JWT 検証して認証状態を判定する

次に Cookie から認証トークンが取得できた場合、認証トークン(今回は idToken)の以下 JWT 検証を行います。

  1. JWT 形式判定
  2. JWT 署名検証
  3. 有効期限チェック
  4. JWT クレーム検証

詳しくは以下ドキュメントを参照ください。

https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html

以下が JWT 検証の実装となります。

https://github.com/kazuma-fujita/nextjs-cognito-middleware-sample/blob/main/src/auth/services/verify-cognito-token.ts

今回は jose パッケージを利用して JWT 検証を実装しました。

認証状態はダッシュボード画面へリダイレクトする

以下の要件を満たす為に認証状態の処理を実装します。

  • 認証後にログイン画面の URL を直接叩かれた場合にダッシュボード画面にリダイレクトする

idToken が JWT 検証結果、認証状態と判定、かつ認証前画面のアクセスの場合(unauthenticatedPaths のパスだった場合) ダッシュボード画面にリダイレクトさせて要件を満たしています。

middleware.ts
  try {
    await verifyCognitoToken(cognitoToken.idToken);

    if (unauthenticatedPaths.includes(url.pathname)) {
      return NextResponse.redirect(dashboard);
    }

    return NextResponse.next();
  } catch (error) {

認証トークンが有効期限切れの場合 refreshToken で認証トークンを再作成する

JWT 検証結果、idToken の有効期限(デフォルト 1 時間)が切れていた場合、refreshToken を使用して idToken/accessToken を再作成します。

ここで環境変数 COGNITO_URL で設定した Cognito ドメインを使用します。

以下 Cognito の url に POST で refreshToken をリクエストしてレスポンスで再作成された idToken/accessToken セットを取得します。

https://XXXXXXXXXX.auth.ap-northeast-1.amazoncognito.com/oauth2/token

以下が実装になります。

https://github.com/kazuma-fujita/nextjs-cognito-middleware-sample/blob/main/src/auth/services/refresh-cognito-token.ts

次に再作成した認証トークンを Cookie に詰め直します。

一度現在 Cookie にセットされている認証トークンを取得、その情報を元に新しい認証トークンをセットします。

https://github.com/kazuma-fujita/nextjs-cognito-middleware-sample/blob/main/src/auth/services/set-cognito-token-into-cookie.ts

こちらが上記モジュールの呼び出し元モジュールになります。

https://github.com/kazuma-fujita/nextjs-cognito-middleware-sample/blob/main/src/auth/services/refresh-cognito-token-then-set-cookie.ts

そして Middleware からは以下のようにモジュールを呼び出して認証トークンの再作成、Cookie にセットしています。

middleware.ts
    try {
      let response: NextResponse = NextResponse.next();

      response = await refreshCognitoTokenThenSetCookie(
        cognitoToken.refreshToken,
        request,
        response
      );

      return response;
    } catch (error) {
      console.error("failed to the refresh token", error);
    }

動作確認

http://localhost:3000/signin にアクセスすると AmplifyUI の Authenticator を使用したログイン画面が表示されるので ID/PASS を入力してログインします。

Cognito に認証済みの状態で再度ログイン画面にアクセスします。

Middleware で Cognito の認証状態の判定結果、認証済みなのでダッシュボード画面にリダイレクトされます。

開発者コンソールのネットワークを開くと /signin のアクセスが HTTP ステータスコード 307 で /dashboard にリダイレクトされていることが分かります。

次に Sign Out をして未認証状態で ダッシュボード画面(http://localhost:3000/dashboard) にアクセスします。

ログイン画面が表示され /dashboard のアクセスが HTTP ステータスコード 307 で /signin にリダイレクトされていることが分かります。

また、今回実装した認証 Middleware はルート URL にアクセスした場合、Middleware で認証状態を判定して未ログインならログイン画面、認証済みならダッシュボード画面に振り分けます。

Next.js の Middleware は URI のパスを正規表現で判定し、静的ファイルなどは Middleware をパスする事も出来ます。

今回 Middleware の config 設定で URI を判定して利用規約など静的画面は Middleware を通さず、認証判定に関係無く表示しています。

おわりに

以上 Next.js の Middleware で Amplify Auth(Cognito)の認証状態でログイン前後の画面を振り分ける方法でした。

筆者はフロントエンド開発歴が浅い為、自前で認証周りの実装するのが結構大変でした。

もっと楽な方法があれば是非コメント宜しくお願い致します。

次回はローカルで実装した認証 Middleware を Amplify Hosting で動かしてみます。

https://zenn.dev/zuma_lab/articles/next-js-middleware-deploy-to-amplify

参考サイト

https://dev.classmethod.jp/articles/study-tokens-of-cognito-user-pools/

GitHubで編集を提案

Discussion