🐾

Next.js × Auth.js(NextAuth) × Cognitoでカスタムログイン画面を作成しセッション管理をする

2023/12/19に公開

https://qiita.com/advent-calendar/2023/arsaga

環境

ライブラリ・フレームワーク・言語 バージョン
next 13.4.19
react 18.2.0
react-dom 18.2.0
typescript 5.2.2
next-auth 4.24.5
@aws-sdk/client-cognito-identity-provider 3.454.0

※Next.jsはpage routerを使用しています

前提条件

  • 一般的な認証機能付きのwebアプリケーションの開発を前提
  • メールアドレス、パスワードで認証する
  • バックエンドのAPIは別で準備されている
  • 認証後、Authorization: Bearer ヘッダにIDトークン(またはアクセストークン)を入れて、バックエンドのAPIを叩くことでアプリケーションとして成立する
  • トークンの有効期限が切れた場合はリフレッシュトークンを使用し更新する

手順

1. cognito側の設定

2. ログイン画面のUIを作成

お好きなデザインのログイン画面を作ってください

3. Auth.jsの設定

  1. ライブラリのインストール
    npm i next-auth @aws-sdk/client-cognito-identity-provider
    
  2. .env.localを作成
    任意の値を入れてください
    COGNITO_CLIENT_ID=
    COGNITO_CLIENT_SECRET=
    COGNITO_REGION=
    COGNITO_USER_POOL_ID=
    NEXTAUTH_SECRET=
    
  3. pages/api/auth/[...nextauth].tsを作成
    [...nextauth].ts
    import NextAuth, { NextAuthOptions } from 'next-auth'
    import { Issuer } from 'openid-client'
    import { jwtDecode } from 'jwt-decode'
    import * as crypto from 'crypto'
    import CognitoProvider from 'next-auth/providers/cognito'
    import { getSecretHash } from '@/features/auth'
    import {
      CognitoIdentityProvider,
      InitiateAuthCommand,
    } from '@aws-sdk/client-cognito-identity-provider'
    import CredentialsProvider from 'next-auth/providers/credentials'
    
    const cognitoProvider = CognitoProvider({
      clientId: process.env.COGNITO_CLIENT_ID || '',
      clientSecret: process.env.COGNITO_CLIENT_SECRET || '',
      issuer: process.env.COGNITO_ISSUER,
    })
    
    // ** リフレッシュトークンを使用してID、アクセストークンを更新する関数
    const refreshAccessToken = async (refreshToken?: string) => {
      if (!refreshToken) {
        return null
      }
    
      const client_id = process.env.COGNITO_CLIENT_ID ?? ''
      const client_secret = process.env.COGNITO_CLIENT_SECRET ?? ''
      const issuer = await Issuer.discover(cognitoProvider.wellKnown ?? '')
      const token_endpoint = issuer.metadata.token_endpoint ?? ''
      const basicAuthParams = `${client_id}:${client_secret}`
      const basicAuth = Buffer.from(basicAuthParams).toString('base64')
      const params = new URLSearchParams({
        client_id,
        client_secret,
        grant_type: 'refresh_token',
        refresh_token: refreshToken,
      })
    
      try {
        const response = await fetch(token_endpoint, {
          headers: {
    	'Content-Type': 'application/x-www-form-urlencoded',
    	Authorization: `Basic ${basicAuth}`,
          },
          method: 'POST',
          body: params.toString(),
        })
    
        const newTokens = await response.json()
    
        return {
          idToken: newTokens.id_token,
          accessToken: newTokens.access_token,
        }
      } catch (error) {
        console.error('Error refreshing access token')
        throw error
      }
    }
    
    export const authOptions: NextAuthOptions = {
      providers: [
        CredentialsProvider({
          name: 'Cognito',
          credentials: {
            email: { label: 'Email', type: 'text' },
            password: { label: 'Password', type: 'password' },
          },
          async authorize(credentials) {
            const cognitoClient = new CognitoIdentityProvider({
              region: process.env.COGNITO_REGION,
            })
    
            const email = credentials?.email ?? ''
            const password = credentials?.password ?? ''
    
            try {
              const response = await cognitoClient.send(
                new InitiateAuthCommand({
                  AuthFlow: 'USER_PASSWORD_AUTH',
                  ClientId: process.env.COGNITO_CLIENT_ID,
                  AuthParameters: {
                    USERNAME: email,
                    PASSWORD: password,
                    SECRET_HASH: getSecretHash(email),
                  },
                }),
              )
    
              if (response.AuthenticationResult) {
                if (!response.AuthenticationResult.IdToken) {
                  throw new Error('No Id Token')
                }
    
                const { IdToken, AccessToken, ExpiresIn, RefreshToken } =
                  response.AuthenticationResult
    
                return {
                  email,
                  idToken: IdToken,
                  accessToken: AccessToken,
                  expiresIn: ExpiresIn,
                  refreshToken: RefreshToken,
                }
              } else {
                throw new Error('No Auth Response Result')
              }
            } catch (error) {
              throw new Error('Auth Error')
            }
          },
        }),
      ],
      callbacks: {
        async jwt({ token, user }) {
          if (user) {
    	token.idToken = user.idToken
    	token.accessToken = user.accessToken
    	token.expiresIn = user.expiresIn
    	token.refreshToken = user.refreshToken
    	token.email = user.email
          }
    
          const decodedToken = jwtDecode(token.idToken)
          const currentTime = Math.floor(Date.now() / 1000)
          
          if (decodedToken.exp && decodedToken.exp < currentTime) {
    	try {
    	  // 有効期限が切れている場合、リフレッシュトークンを使用してトークンを更新
    	  const refreshedTokens = await refreshAccessToken(token.refreshToken)
    
    	  if (refreshedTokens?.idToken && refreshedTokens?.accessToken) {
    	    token.idToken = refreshedTokens.idToken
    	    token.accessToken = refreshedTokens.accessToken
    	  } else {
    	      throw new Error()
    	  }
    	} catch (error) {
    	  // リフレッシュトークンの期限が切れたorリフレッシュできなかった場合
    	  token.error = 'RefreshTokenError'
    	}
          }
          
          return token
        },
        async session({ session, token }) {
          // 必要に応じてトークンを暗号化させてください
          session.idToken = token.idToken
          session.accessToken = token.accessToken
          session.error = token.error
          return session
        },
      },
      secret: process.env.NEXTAUTH_SECRET,
    }
    
    export default NextAuth(authOptions)
    
  4. next-authの型定義を拡張
    デフォルトの型定義だと、上記の設定でエラーが出るため、型定義を拡張させます
    types/next-auth.d.tsを作成
    next-auth.d.ts
    import 'next-auth'
    
    declare module 'next-auth' {
      interface Session {
        idToken?: string
        accessToken?: string
        error?: string
      }
    
      interface User {
        id?: string
        idToken?: string
        accessToken?: string
        refreshToken?: string
        expiresIn?: number
      }
    }
    
    declare module 'next-auth/jwt' {
      interface JWT {
        idToken?: string
        accessToken?: string
        refreshToken?: string
        expiresIn?: number
        error?: string
      }
    }
    

4. ログイン、ログアウト処理の実装

import { signIn, signOut } from 'next-auth/react'

export type LoginParams = {
  email: string
  password: string
}

export const login = async (params: LoginParams) => {
  const { email, password } = params

  const result = await signIn('credentials', {
    email,
    password,
    redirect: false,
  })

  return result
}

export const logout = async () => {
  await signOut({ redirect: false })
}

※エラーハンドリング、その他の処理は任意で記述してください

5. IDトークンを取得しAPIを叩く

import axios from 'axios'
import { getSession } from 'next-auth/react'

export const getAxiosInstanceWithToken = async () => {
  const session = await getSession()
  if (!session) {
    throw new Error()
  }

  if (session?.error === 'RefreshTokenError') {
    // リフレッシュトークンの期限切れ時の処理
  }

  return axios.create({
    headers: {
      Authorization: `Bearer ${session.idToken}`,
    },
  })
}

// APIを叩く処理
export const getPosts = async () => {
  const axiosInstance = await getAxiosInstanceWithToken()
  const response = await axiosInstance.get('任意のエンドポイント')
}

※エラーハンドリング、その他の処理は任意で記述してください

Arsaga Developers Blog

Discussion