NextAuth.js + Cognito + SRPプロトコルでカスタムログイン画面を実装する

## はじめに
この記事では、NextAuth.js × Cognito × SRP（Secure Remote Password）プロトコルを使用したカスタムログイン画面の実装方法を解説します。

また、今回この記事を執筆した背景として、この構成（NextAuth.js × Cognito × SRP）に関する技術記事が少なく、備忘録も兼ねてまとめることを目的としています。

おまけとして、Next.js のミドルウェアを活用したルーティング制御もご紹介します。
## 使用したライブラリとバージョン
本記事で使用した主要ライブラリのバージョンは以下の通りです。
```
{
  "next-auth": "^4.24.11",
  "amazon-cognito-identity-js": "^6.3.12",
  "next": "15.1.0",
  "react": "^19.0.0"
}
```

## なぜSRPプロトコルを使うのか？

AWS Cognitoは、認証時にSRPプロトコル（Secure Remote Password Protocol）を使用します。SRPは、クライアントとサーバー間でパスワードを直接送信せずに認証を行うため、セキュリティが強化されるというメリットがあります。

公開鍵とソルトをやり取りして認証を行いますがamazon-cognito-identity-js を使う場合はライブラリが自動で処理するため開発者側は特に意識する必要はありません。

### 主な利点
- パスワードをサーバーに直接送信しないため、盗聴リスクを低減
- サーバー側で平文のパスワードを保持しない
- 認証時に安全なトークン交換が可能


## コードの詳細
authorizeメソッドでcognitoと認証を行い、認証情報を取得します。

それを用いてjwtのコールバックで初回はjwtの作成、次回以降はアクセストークンの有効期限チェックを行い有効期限が切れている場合はリフレッシュトークンを使ってアクセストークンを更新しています。
```typescript:route.ts
const ACCESS_TOKEN_EXPIRATION = 1 * 60 * 60; // 1時間

const handler = NextAuth({
  providers: [
    CredentialsProvider({
      name: "Credentials",
      credentials: {
        userId: { label: "UserId", type: "text" },
        password: { label: "Password", type: "password" },
      },
      async authorize(credentials) {
        if (!credentials?.userId || !credentials?.password) {
          throw new Error("ユーザーIDとパスワードを入力してください");
        }
        try {
          return await authorizeCognitoUser(credentials.userId, credentials.password);
        } catch (error) {
          throw new Error(getCognitoErrorMessage(error));
        }
      },
    }),
  ],
  callbacks: {
    async jwt({ token, user }) {
      const currentTime = Math.floor(Date.now() / 1000);
      // jwtにペイロードの設定
      if (user) {
        token.userId = user.userId;
        token.accessToken = user.accessToken;
        token.refreshToken = user.refreshToken;
        token.idToken = user.idToken;
        token.expiresIn = currentTime + ACCESS_TOKEN_EXPIRATION;
      }
      // アクセストークンの有効期限検証
      if (token.expiresIn && token.expiresIn < currentTime && token.refreshToken) {
        try {
        // リフレッシュトークンを用いたアクセストークンの更新
          const refreshedTokens = await refreshAccessToken(token.refreshToken, token.userId);
          token.idToken = refreshedTokens.idToken;
          token.accessToken = refreshedTokens.accessToken;
        } catch (error) {
        // リフレッシュトークンの有効期限切れはフロント側で処理
          token.error = "refreshTokenExpired";
        }
      }
      return token;
    },
    // セッションの設定
    async session({ session, token }) {
      session.error = token.error;
      session.accessToken = token.accessToken;
      session.idToken = token.idToken;
      return session;
    },
  },
  secret: process.env.NEXTAUTH_SECRET,
});

export { handler as GET, handler as POST };
```

実際のauthorizeCognitoUserの処理を見るとパスワードを送らず認証していることがわかります。
```typescript:utils.ts
// SRPプロトコルを用いた認証
const cognitoPoolData = {
  UserPoolId: process.env.COGNITO_USER_POOL_ID ?? "",
  ClientId: process.env.COGNITO_CLIENT_ID ?? "",
}

export const authorizeCognitoUser = async (
  userId: string,
  password: string,
): Promise<User> => {
  const userPool = new CognitoUserPool(cognitoPoolData);
  const user = new CognitoUser({
    Username: userId,
    Pool: userPool,
  });

  const authDetails = new AuthenticationDetails({
    Username: userId,
    Password: password,
  });
  return new Promise((resolve, reject) => {
    user.authenticateUser(authDetails, {
      onSuccess: (session) => {
        resolve({
          idToken: session.getIdToken().getJwtToken(),
          accessToken: session.getAccessToken().getJwtToken(),
          refreshToken: session.getRefreshToken().getToken(),
          userId,
        });
      },
      onFailure: (err) => {
        reject(new CognitoError(err.message, err.code));
      },
    });
  });
};

// アクセストークンを更新する処理
export const refreshAccessToken = async (
  refreshToken: string,
  userId: string,
): Promise<User> => {
  return new Promise((resolve, reject) => {
    const userPool = new CognitoUserPool(cognitoPoolData)
    const user = new CognitoUser({
      Username: userId,
      Pool: userPool,
    })

    const session = {
      getRefreshToken: () => ({
        getToken: () => refreshToken,
      }),
    }

    user.refreshSession(session.getRefreshToken(), (err, session) => {
      if (err) {
        return reject(new CognitoError(err.message, err.code))
      }

      resolve({
        idToken: session.getIdToken().getJwtToken(),
        accessToken: session.getAccessToken().getJwtToken(),
        refreshToken: session.getRefreshToken().getToken(),
      })
    })
  })
}
```
cognitoのエラーメッセージは英語で返ってくるので日本語にマッピングを行います
```typescript:utils.ts
Class CognitoError extends Error {
  code: string

  constructor(message: string, code: string) {
    super(message)
    this.code = code
  }
}

export const getCognitoErrorMessage = (error: unknown) => {
  if (error instanceof CognitoError) {
    switch (error.code) {
      case "NotAuthorizedException":
        return "ユーザー名またはパスワードが間違っています。"
      case "LimitExceededException":
        return "試行回数の制限を超えました。しばらくしてから再試行してください。"
      case "UserNotFoundException":
        return "ユーザーが見つかりませんでした。登録済みのアカウントを確認してください。"
      default:
        return "認証に失敗しました。"
    }
  }
  return "認証に失敗しました。"
}
```
middlewareではNextAuthが提供しているwithAuthに従ってセッションが存在しない場合はログインページへリダイレクトしています。
```typescript:middleware.ts
export default withAuth({
  pages: {
    signIn: "/login",
  },
});
```

## まとめ
- NextAuth.js を使って Cognito 認証を実装
- SRP プロトコルを利用してセキュアなログイン
- middlewareでセッションがないユーザーを /login にリダイレクト
- refreshTokenを用いたアクセストークンの更新

今回は要件の都合上、Cognitoのホストティング上のログイン画面を使わずカスタムログイン画面で認証の実装を行いました。今後、機会があればAuth0を用いた認証実装も取り組みたいと思っております。

## 参考サイト

- https://zenn.dev/c_shiraga/articles/4fac54eb4d5bd8
- https://www.npmjs.com/package/amazon-cognito-identity-js
- https://stackoverflow.com/questions/53837027/the-difference-between-aws-amplify-and-amazon-cognito-identity-js
- https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/amazon-cognito-user-pools-authentication-flow.html
- https://qiita.com/yukitaka13-1110/items/99197466c1ba88f0f8d3