【Next.js】middlewareを用いて認証チェック時にリダレクトする

以前、Cognitoを使った認証処理を実装しました。
このときは認証処理を実装しただけで、認証後にのみアクセスできる画面に認証前はアクセスできないようにする処理を実装していませんでしたので、本記事で実装したいと思います。
はじめはuseEffectに認証前ならログイン画面にリダイレクトする処理を記載していましたが、この場合に一瞬画面がチラついてしました。
理由は下記サイトに記載されている通り、すべてのページをPre-renderingしてから処理が実行されるため、認証後の画面が一瞬見えてしまいます。

そのため今回はmiddlewareという機能を使って認証チェック処理を実装したいと思います。middlewareはリクエストが完了する前にコードを実行できる仕組みで、通常の処理が実行される前にCognitoに認証状態を確認し、認証されていなければログイン画面にリダイレクトするように実装します。

以前実装したCognito認証処理は、以下記事を確認してください。

目次

実装方針

今回実装するmiddlewareの処理の流れは以下の通りです。

  1. CookieからCognitoの認証トークンを取得する
    Cookieに認証トークンがなければ未認証状態として、ログイン画面にリダレクトする
  2. Cookieから認証トークンを取得した際はidTokenをJWT検証する
    JWT検証結果がエラーの場合は未認証状態として、ログイン画面にリダイレクトする
  3. JWT検証結果がOKの場合は認証済とする
  4. idTokenの有効期限が切れている場合はrefreshTokenを用いて認証トークンを再作成する
  5. 再作成した認証トークンをCookieに設定する

今回使用するトークンは以下の通りです。詳細はAWSのページを参照してください。

トークン用途有効期限(デフォルト)
idToken認証または認証されたユーザー情報を参照する際に利用する1時間
accessTokenユーザー属性の追加・変更・削除に利用する1時間
refreshToken新しいidToken・accessTokenの取得に利用する30日

Cognito認証トークンを検証する

middlewareを作成する前にCognito認証トークンを検証する処理を実装します。
まず、Cookieに保存されているidTokenを取得します。ちなみにAmplifyの認証処理を実装する際にconfigureの引数にssr: true を設定していましたが、これはAmplifyでSSRに対応する際に設定するものです。Cognitoはデフォルトで認証情報をブラウザのLocalStorageに保存しますが、ssrをtrueに設定するとCookieに保存してくれます。
認証トークンをCookieに保存する事により、middleware(サーバ側の処理)からCookieにアクセスして認証状態の判定が可能になります。

Amplify.configure(config, { ssr: true });

CookieのCognitoIdentityServiceProvider.clientid.xxxに認証トークンが保存されていますので、開発者ツールで確認してみてください。


services/cognito.js
Cookieから認証トークンを取得する処理を実装します。

const clientId = process.env.NEXT_PUBLIC_COGNITO_USER_POOLS_WEB_CLIENT_ID;

/**
 * cookieからcognitoトークン情報を取得
 * @param {*} request
 * @param {*} tokenType
 * @returns
 */
export const getCognitoTokenByCookie = (request, tokenType) => {
  const regexp = new RegExp(
    `CognitoIdentityServiceProvider\\.${clientId}\\..+\\.${tokenType}`
  );
  const cookies = request.cookies
    .getAll()
    .filter((cookie) => regexp.test(cookie.name));
  const cookie = cookies.length === 1 ? cookies[0] : null;
  return cookie && cookie.value;
};

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

  • JWT 形式判定
  • JWT 署名検証
  • 有効期限チェック
  • JWT クレーム検証

詳しくは下記を参考にしてください。

先程のファイルに下記を追記します。

import { decodeProtectedHeader, importJWK, jwtVerify } from "jose";

const userPoolsId = process.env.NEXT_PUBLIC_USER_POOLS_ID;
const cognitoIdpUrl = `https://cognito-idp.${process.env.NEXT_PUBLIC_AWS_REGION}.amazonaws.com`;

/**
 * トークン検証
 * @param {*} token
 */
export const verifyCognitoToken = async (token) => {
  const { keys } = await fetch(
    `${cognitoIdpUrl}/${userPoolsId}/.well-known/jwks.json`
  ).then((res) => res.json());
  const { kid } = decodeProtectedHeader(token);
  const jwk = keys.find((key) => key.kid === kid);
  if (!jwk) {
    throw Error("JWK is not found in the token");
  }
  const jwtImport = await importJWK(jwk);
  await jwtVerify(token, jwtImport);
};

次にJWT検証結果でidToken の有効期限(デフォルト 1 時間)が切れていた場合、refreshTokenを用いてidToken/accessTokenを再作成します。
oauth2/tokenにrefreshTokenを設定することでidTokenとaccessTokenを再取得します。

const cognitoDomain = process.env.NEXT_PUBLIC_COGNITO_DOMAIN;

/**
 * トークンの再取得
 * @param {*} refreshToken
 * @returns
 */
export const refreshCognitoToken = async (refreshToken) => {
  const res = await fetch(`${cognitoDomain}/oauth2/token`, {
    method: "POST",
    headers: new Headers({
      "content-type": "application/x-www-form-urlencoded",
    }),
    body: Object.entries({
      grant_type: "refresh_token",
      client_id: clientId,
      refresh_token: refreshToken,
    })
      .map(([k, v]) => `${k}=${v}`)
      .join("&"),
  });
  if (!res.ok) {
    throw new Error(JSON.stringify(await res.json()));
  }
  const newTokens = await res.json();
  return newTokens;
};

cognitoDomainはAWSマネジメントコンソール画面から下記手順で作成します。
アプリケーションの統合欄にあるCognitoドメインの作成をクリックします。

ドメインを設定し、Cognitoドメインの作成をクリックします。

そしてenv.localに設定したCognitoドメインを記載しておきます。

NEXT_PUBLIC_COGNITO_DOMAIN=xxx

最後に再取得した認証トークンをCookieに設定する処理を用意します。

/**
 * 認証トークンをCookieに設定
 * @param {*} request
 * @param {*} response
 * @param {*} tokenType
 * @param {*} tokenValue
 * @returns
 */
export const setTokenIntoCookie = (
  request,
  response,
  tokenType,
  tokenValue
) => {
  const cookie = getCognitoTokenByCookie(request, tokenType);
  if (!cookie) {
    throw Error(`${tokenType} is not found in the request cooke`);
  }
  response.cookies.set({
    name: cookie.name,
    value: tokenValue,
    secure: true,
    httpOnly: true,
  });
  return response;
};

middlewareを実装する

先程作成したトークン検証用の関数を利用して認証チェックを行うmiddlewareを実装します。
src/middleware.js

import { NextResponse } from "next/server";
import {
  getCognitoTokenByCookie,
  verifyCognitoToken,
  refreshCognitoToken,
  setTokenIntoCookie,
} from "@/services/cognito";

// 未認証アクセス可画面
const unAuthenticatedPaths = ["/auth"];

export async function middleware(request) {
  const idToken = getCognitoTokenByCookie(request, "idToken").value;
  const accessToken = getCognitoTokenByCookie(request, "accessToken").value;
  const refreshToken = getCognitoTokenByCookie(request, "refreshToken").value;
  console.log("idToken", idToken);
  console.log("accessToken", accessToken);
  console.log("refreshToken", refreshToken);
  const url = request.nextUrl.clone();

  // cookieからtokenが取得できない場合
  if (!idToken && !accessToken && !refreshToken) {
    if (!unAuthenticatedPaths.includes(url.pathname)) {
      return NextResponse.redirect(new URL("/auth", request.url));
    }
    return NextResponse.next();
  }

  try {
    // idToken検証結果が有効(認証済)
    await verifyCognitoToken(idToken);
    if (unAuthenticatedPaths.includes(url.pathname)) {
      return NextResponse.redirect(new URL("/", request.url));
    }
    return NextResponse.next();
  } catch (error) {
    try {
      // refreshTokenを使用して認証トークンを再取得
      const newTokens = await refreshCognitoToken(refreshToken);
      console.log("newTokens", newTokens);
      let response = NextResponse.next();
      response = setTokenIntoCookie(
        request,
        response,
        "idToken",
        newTokens.id_token
      );
      response = setTokenIntoCookie(
        request,
        response,
        "accessToken",
        newTokens.access_token
      );
      return response;
    } catch (error) {
      console.error("failed to the refresh token", error);
    }
  }
  return NextResponse.redirect(new URL("/auth", request.url));
}

export const config = {
  matcher: [
    "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
  ],
};

Cookieから認証トークンを取得できないかつ未認証アクセス不可画面であれば、ログイン画面にリダイレクトします。
認証トークンが取得できない場合でも未認証アクセス可の画面であれば、そのまま画面遷移します。
その後先程作成した関数でJWTの検証を行います。JWTの検証が問題ない場合、未認証アクセス可の画面の場合はトップ画面にリダイレクトします。認証済の場合にログイン画面に遷移すべきではないからです。
JWT検証がエラーだった場合はrefreshTokenを使用してトークンを再取得します。これに失敗した場合はrefreshTokenが不正か有効期限切れになりますので、ログイン画面にリダイレクトし、再度認証処理を実行するように促します。

また、middlewareではmiddlewareを実行しないHTTPリクエストを設定することができます。Next.js内で使用している静的ファイルは対象から除外します。↓の部分です。

export const config = {
  matcher: [
    "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
  ],
};

ちなみにidTokenを有効期限切れにして動作確認したい場合は、有効期限をAWSマネジメントコンソール画面から設定できます。

最後に動作確認をします。

  • 認証前にトップ画面にアクセスできないこと(ログイン画面にリダイレクトすること)
  • 認証後にログイン画面にアクセスできないこと(トップ画面にリダイレクトすること)
  • idTokenの有効期限が切れた後もrefreshTokenの有効期間内の場合はidTokenを再取得してログイン状態を保持できること

コードは、以下のGitHubリポジトリで確認できます。

よかったらシェアしてね!
  • URLをコピーしました!

コメント

コメントする

目次