Next.jsのMiddlewareでAmplify Auth(Cognito)の認証状態によってログイン前後の画面を振り分ける
こんにちわ。 ZUMA です。
認証が必須の MPA の WEB アプリケーションを実装する際に以下の URL ルーティング要件があったとします。
- ルート URL にアクセスがあった場合、認証前であればログイン画面、認証後であればダッシュボード画面にリダイレクトする
- 認証前にダッシュボード画面の URL を直接叩かれた場合にログイン画面にリダイレクトする
- 認証後にログイン画面の URL を直接叩かれた場合にダッシュボード画面にリダイレクトする
- 利用規約やプライバシーポリシー画面は認証状態に関係無く表示する
要件を実現させる為にまず以下処理を思いつきました。
- 各画面で認証状態を見て他画面にリダレクトする
- 画面共通で呼ばれる箇所(Next.js だと_app.tsx など)で認証状態と URI を見てリダイレクトする
しかしサービスのドメイン部分のロジックに関係無い認証の関心事は外部にまとめて実装したいと思いました。
そこで Next.js には 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 を使ったエッジ環境で動くので制約があります。
例えば 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 の処理の流れは大きく以下のようになります。
- Cookie から Cognito の認証トークン(idToken/accessToken/refreshToken)を取得
- Cookie に認証トークンが無ければ未認証状態
- Cookie から認証トークンが取得出来れば idToken を JWT 検証
- JWT 検証結果エラーだった場合未認証状態
- JWT 検証を通過すれば認証済
- idToken の有効期限が切れている場合 refreshToken で idToken/acccessToken を再作成
- 再作成した 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 を利用可能にします。
ポイントは Amplify.configure
で ssr: true
と設定することです。
Cognito はデフォルトで認証トークンをブラウザの Local Storage に保存します(設定で Session storage に保存も可能)。
ssr オプションを有効にすることにより、Cognito の認証トークンを Cookie に保存します。
認証トークンを Cookie に保存する事により、Middleware から Cookie にアクセスして認証状態の判定が可能になります。
ログイン画面を実装する
実装は公式のコードをそのまま使います。
http://localhost:3000/signin
にアクセスしてログイン画面が表示されることを確認します。
Sign Up からユーザー作成後、Cognito 上にユーザーが存在することを確認します。
その他の画面を実装する
認証済みの状態でのみアクセス出来るダッシュボード画面です。
認証状態に関係無くアクセス出来る利用規約画面です。
環境変数を設定する
Middleware 上では環境変数が使用できるようです。
src/aws-exports.js
の値を利用出来れば良いのですが、Middleware のエッジ環境からはアクセス出来ないので別途環境変数を設定する必要があります。
COGNITO_URL 以外は src/aws-exports.js
にある値を記述します。
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 等にデプロイ後エッジ環境で動作します。これには感動しました。
Middleware を通さない HTTP リクエストを設定する
ポイントはまず、以下 Middleware の config 設定で Middleware を通さない HTTP リクエストを設定します。
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 にアクセスがあった場合、認証前であればログイン画面、認証後であればダッシュボード画面にリダイレクトする
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 の認証トークンを取得する
以下が Cookie から Cognito の認証トークンを取得するモジュールになります。
Cookie には認証トークンが以下のような形式で保存されているので正規表現を使用して idToken/accessToken/refreshToken をそれぞれ取得します。
CognitoIdentityServiceProvider.{COGNITO_USER_POOLS_WEB_CLIENT_ID}.XXXX-XXXX-XXXX-XXXX-XXXXXXXXX.idToken
未認証状態はログイン画面へリダイレクトする
以下の要件を満たす為に未認証状態の処理を実装します。
- 認証前にダッシュボード画面の URL を直接叩かれた場合にログイン画面にリダイレクトする
まず、 unauthenticatedPaths
にログイン画面など認証前画面のパスを配列で記述します。(こちらは後ほど使用します)
authenticatedPaths
にはダッシュボード画面など認証必須画面のパスを配列で記述します。
以下実装は Cookie から認証トークンが取得できない状態を未認証状態としています。
未認証状態かつ、認証必須画面のアクセスの場合(authenticatedPaths
のパスだった場合) ログイン画面にリダイレクトさせて要件を満たしています。
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 検証を行います。
- JWT 形式判定
- JWT 署名検証
- 有効期限チェック
- JWT クレーム検証
詳しくは以下ドキュメントを参照ください。
以下が JWT 検証の実装となります。
今回は jose
パッケージを利用して JWT 検証を実装しました。
認証状態はダッシュボード画面へリダイレクトする
以下の要件を満たす為に認証状態の処理を実装します。
- 認証後にログイン画面の URL を直接叩かれた場合にダッシュボード画面にリダイレクトする
idToken が JWT 検証結果、認証状態と判定、かつ認証前画面のアクセスの場合(unauthenticatedPaths
のパスだった場合) ダッシュボード画面にリダイレクトさせて要件を満たしています。
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
以下が実装になります。
次に再作成した認証トークンを Cookie に詰め直します。
一度現在 Cookie にセットされている認証トークンを取得、その情報を元に新しい認証トークンをセットします。
こちらが上記モジュールの呼び出し元モジュールになります。
そして Middleware からは以下のようにモジュールを呼び出して認証トークンの再作成、Cookie にセットしています。
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 で動かしてみます。
参考サイト
Discussion