Amplify Adapter Next.js と Cognito でログインをしてみた
Next.js を利用した Cognito User Pools でのログインを実装してみました。
基本構成を覚えれば難しいところはないのですが、ライブラリが散見しているのでまとめました。これから実装を進めたい人の役に立てば何よりです。
要点のまとめ
いくつか知るべきことはありますが、要点を頭に入れておけばすんなりと進むかと思います。
aws-amplify/auth
を利用して Client Components でログインを実装するaws-amplify/auth/server
で Server Components からユーザー情報にアクセスする@aws-amplify/adapter-nextjs
と Next.js の cookies で連携をする@aws-amplify/ui-react
のプロバイダーでアプリをラップする@aws-amplify/ui-react
で Client Components からユーザー情報にアクセスする
ライブラリが少し多いので、具体的な使い分けがあやふやになってしまいます。
それぞれの用途を簡単にまとめましょう。
aws-amplify/auth
クライアントサイドでのログインを主に担当します。ユーザー情報を取得したりもできます。
Client Components のハンドラーで呼び出したり、Hooks で呼び出したりします。
@aws-amplify/ui-react
クライアントサイドでの簡単に使える UI や便利な Hooks を提供します。
aws-amplify/auth/server
サーバーサイドでのJWTの取得やユーザー情報の取得をおこないます。
なので Server Components や API Routes での呼び出しが主になります。
@aws-amplify/adapter-nextjs
Next.js で cookies ベースの認証情報管理を行うためのアダプターです。 aws-amplify/auth/server
と組み合わせて利用します。
ここまでライブラリの目的を理解したところで、Cognito User Pools を利用したログインと、User Groups を利用した権限管理の実装をしてみましょう。
Amlify のライブラリを中心にログインを実装する
それではどのように実装したかについてまとめていきます。実行時の環境は下記の通りです。Next.js のブートストラップなどは省略します。
- React: 19.x
- Next.js: 15.5.x
- aws-amplify: 6.15.x
- TypeScript: 5.x
- Node.js: 24.x
ディレクトリ構造
Page Components では、認証後に表示可能なページを authenticated
でまとめます。グルーピングすることで、認証が必要なページとそうではない部分の見分けがつきやすくレビューなどしやすくなります。また、bulletproof-react に近い考えで、機能の役割ごとにディレクトリを features にまとめます。今回は認証/認可/ログイン周りを features/auth
にまとめています。実際はこれ以外にも機能があるので諸々追加されていく形にはなります...。
├── app
│ ├── (authenticated)
│ │ └── page.tsx
│ ├── layout.tsx
│ └── login
│ └── page.tsx
├── features
│ ├── auth
│ │ ├── components
│ │ │ ├── confirm-login.tsx
│ │ │ └── login.tsx
│ │ ├── configs
│ │ │ └── index.ts
│ │ ├── hooks
│ │ │ └── index.ts
│ │ ├── models
│ │ │ └── index.ts
│ │ ├── providers
│ │ │ └── index.tsx
│ │ └── utils
│ │ └── server.ts
Amplify config を仕込む
まずは Amplify の設定を作成します。Cognito User Pool の ID関連を指定するだけです。
また環境を簡単に切り替えられるように、ID を環境変数で管理しています。
この設定ファイルはクライアントでもサーバーでも利用するので、NEXT_PUBLIC
を接頭語につけています。
import type { ResourcesConfig } from 'aws-amplify'
export const amplifyConfig: ResourcesConfig = {
Auth: {
Cognito: {
userPoolId: process.env.NEXT_PUBLIC_USER_POOL_ID ?? '',
userPoolClientId: process.env.NEXT_PUBLIC_USER_POOL_CLIENT_ID ?? '',
loginWith: {
email: true,
},
},
},
}
Client Components で Amplify を使えるようにする
クライアントで Amify.configure(config)
を呼び出します。また、@aws-amplify/ui-react
の機能を使えるように Provider でアプリをラップします。なのでこの2つを実行する Component を作成します。
'use client'
import { Authenticator } from '@aws-amplify/ui-react'
import { Amplify } from 'aws-amplify'
import type { ReactNode } from 'react'
import { amplifyConfig } from '../configs'
Amplify.configure(amplifyConfig, { ssr: true })
export const AmplifyProvider = ({ children }: { children: ReactNode }) => {
return <Authenticator.Provider>{children}</Authenticator.Provider>
}
あとはこの Provider コンポーネントを Root Layout に渡してあげれば完了です。
import { AmplifyProvider } from '@/features/auth/providers'
import './globals.css'
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en">
<AmplifyProvider>
<body className="antialiase">{children}</body>
</AmplifyProvider>
</html>
)
}
Server Components でユーザー情報の取得をする
最後に Server Components でユーザー情報にアクセスできるようにします。
createServerRunner
で Amplify の設定を渡して、Next.js 上で動かせるようにします。
関数のレスポンスである runWithAmplifyServerContext
には、cookies を渡してあげて、operation で実際の処理を記述します。
import { createServerRunner } from '@aws-amplify/adapter-nextjs'
import { fetchAuthSession, getCurrentUser } from 'aws-amplify/auth/server'
import { cookies } from 'next/headers'
import { cache } from 'react'
import { amplifyConfig } from '../configs'
const { runWithAmplifyServerContext } = createServerRunner({
config: amplifyConfig,
})
export const getUser = cache(async () => {
return runWithAmplifyServerContext({
nextServerContext: {
cookies,
},
operation: async (contextSpec) => {
const [session, user] = await Promise.all([
fetchAuthSession(contextSpec),
getCurrentUser(contextSpec),
])
return {
name: user.username,
email: user.signInDetails?.loginId,
groups: session.tokens?.idToken?.payload['cognito:groups'] ?? [],
}
},
})
})
export const getJsonWebToken = cache(async () => {
return runWithAmplifyServerContext({
nextServerContext: {
cookies,
},
operation: (contextSpec) => {
return fetchAuthSession(contextSpec).then((item) =>
item.tokens?.idToken?.toString(),
)
},
})
})
とりあえず、ユーザー情報と ID Token を取得する処理を書いてみました。
ログイン機能を実装する
ここから、Client Components の実装をします。流れとしては下記の通りです。
- form で username と password を受け取る
- handler で signIn 関数を呼び出す
- signIn 関数の signInStep がある場合にそれに応じた処理をする
FormData を handler で扱うだけなので処理は単純です。
signIn 関数の result.nextStep.signInStep というサインインプロセスの各段階を表す列挙型の処理が煩雑になります。
まず、ログインするためのコンポーネントを作成します。handler を受け取って、form 要素に渡すだけです。本当はスタイリングなどのために色々しているのですが、本質的な部分だけ簡潔に表示しています。
import type { ComponentProps } from 'react'
import { Button } from '@/ui/button'
import { Input } from '@/ui/input'
import { Label } from '@/ui/label'
interface Props {
handleSubmit: ComponentProps<'form'>['onSubmit']
}
export const Login = ({ handleSubmit }: Props) => {
return (
<form className="px-6 space-y-6" onSubmit={handleSubmit}>
<div className="space-y-2">
<Label htmlFor="username">email</Label>
<Input
name="username"
type="email"
required
placeholder="email@example.com"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">password</Label>
<Input name="password" type="password" required />
</div>
<div>
<Button type="submit" variant="outline" className="mt-4 w-full">
login
</Button>
</div>
</form>
)
}
Login コンポーネントを Page から呼び出します。
handleLoginでは下記の実装をします。
- FormData をログインで利用できるデータにパースする
- signIn を呼び出して、signInStep を取得する
- signInStep が DONE の場合はログインが完了したとみなす
- signInStep が DONE 以外の場合は追加の処理が必要になるので step という状態に値をセットする
省略していますが、step に応じてフォームの表示を切り替えて、パスワードや OTP を受け取ったりします。
'use client'
import { confirmSignIn, signIn } from 'aws-amplify/auth'
import { useRouter } from 'next/navigation'
import { type FormEvent, useState } from 'react'
import z from 'zod'
import { ConfirmLogin } from '@/features/auth/components/confirm-login'
import { Login } from '@/features/auth/components/login'
const LoginSchema = z.object({
username: z.email(),
password: z.string(),
})
export default function LoginPage() {
const [step, setStep] = useState('')
const router = useRouter()
const handleLogin = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
const data = new FormData(event.currentTarget)
const parsed = LoginSchema.safeParse({
username: data.get('username')?.toString(),
password: data.get('password')?.toString(),
})
if (!parsed.success) {
// error handling
console.error(parsed.error)
return
}
const signInStep = await signIn(parsed.data)
.catch((error) => {
// error handling
console.log(error)
return null
})
.then((result) => result?.nextStep.signInStep)
if (signInStep === 'DONE') {
router.push('/')
return
}
if (signInStep) {
setStep(signInStep)
}
}
if (step === 'CONFIRM_SIGN_IN_WITH_NEW_PASSWORD_REQUIRED') {
// step に応じた処理
}
return <Login handleSubmit={handleLogin} />
}
ユーザーグループに応じて表示を切り替える
今までの内容でユーザーログインをすることはできます。次はユーザー情報、ユーザーグループをユーザーの操作可能な権限グループとみなして、ページアクセス時に表示を切り替えられるようにします。今回は admin と viewer というユーザーグループを用意して、admin 権限の場合にのみ閲覧できるページを作ります。
まずは、下記のように CLI かマネジメントコンソールを経由してグループを追加します。
aws cognito-idp admin-add-user-to-group \
--user-pool-id <value> \
--username <value> \
--group-name <value>
次に、 /only-admin
というページを追加します。
authorizations という配列に許可されるグループを記述して、それに該当しない場合は 401 を返すようにします。
import { redirect, unauthorized } from 'next/navigation'
import { getUser } from '@/features/auth/utils/server'
const authorizations = ['admin']
const hasAuthorization = (
authorizations: string[],
groups: string[],
): boolean => {
return groups.some((group) => authorizations.includes(group))
}
export default async function Home() {
const user = await getUser().catch((error) => {
console.error(error)
redirect('/login')
})
if (!hasAuthorization(authorizations, user.groups)) {
unauthorized()
}
return <pre>{JSON.stringify(user, null, 2)}</pre>
}
また、Next.js で 401 を利用するには experimental.authInterrupts
を有効にする必要があるので next.config.ts で調整しましょう。これで localhost:3000/only-admin
にアクセスした際にグループが含まれていたらページが表示されて、ない場合は 401 ページが表示されるようになりました。
これにてログインから、グループに応じたページ表示の切り替えまで完了しました。
付録. nextStep をハンドリングする
signIn メソッドなどのレスポンスに含まれる nextStep とそれに対する対処になります。
Cognito User Pool の設定と照らし合わせて必要になる処理を記述していきましょう。
ステップ名 | 説明 | 使用メソッド |
---|---|---|
DONE | サインイン完了 | N/A |
CONFIRM_SIGN_UP | アカウント未確認 | confirmSignUp({ username, confirmationCode }) |
RESET_PASSWORD | パスワードリセット必要 | resetPassword({ username }) / confirmResetPassword({username, confirmationCode, newPassword}) |
CONFIRM_SIGN_IN_WITH_PASSWORD | パスワード入力必要 | confirmSignIn({ challengeResponse}) |
CONFIRM_SIGN_IN_WITH_NEW_PASSWORD_REQUIRED | 新パスワード設定必要 | confirmSignIn({ challengeResponse}) |
CONFIRM_SIGN_IN_WITH_CUSTOM_CHALLENGE | カスタムチャレンジ認証 | confirmSignIn({ challengeResponse}) |
CONFIRM_SIGN_IN_WITH_SMS_CODE | SMS認証コード必要 | confirmSignIn({ challengeResponse}) |
CONFIRM_SIGN_IN_WITH_TOTP_CODE | TOTPコード必要 | confirmSignIn({ challengeResponse}) |
CONFIRM_SIGN_IN_WITH_EMAIL_CODE | メール認証コード必要 | confirmSignIn({ challengeResponse}) |
CONTINUE_SIGN_IN_WITH_MFA_SELECTION | MFA方式の選択 | confirmSignIn({ challengeResponse}) |
CONTINUE_SIGN_IN_WITH_TOTP_SETUP | TOTP設定が必要 | confirmSignIn({ challengeResponse}) |
CONTINUE_SIGN_IN_WITH_EMAIL_SETUP | メールMFA設定が必要 | confirmSignIn({ challengeResponse}) |
CONTINUE_SIGN_IN_WITH_MFA_SETUP_SELECTION | MFA設定方法の選択 | confirmSignIn({ challengeResponse}) |
CONTINUE_SIGN_IN_WITH_FIRST_FACTOR_SELECTION | 第一認証要素の選択 | confirmSignIn({ challengeResponse}) |
付録. AuthError を処理する
aws-amplify/auth
の signIn
などの処理でエラーが起きたら AuthError インスタンスのエラーが返ってきます。
これ自体の処理は下記のように状態として保持して表示すれば良いです。下記のようにハンドラーでエラーをハンドリングして、表示させます。
export default function LoginPage() {
const [step, setStep] = useState('')
const [error, setError] = useState('')
const handleLogin = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
const data = new FormData(event.currentTarget)
const parsed = LoginSchema.parse({
username: data.get('username')?.toString(),
password: data.get('password')?.toString(),
})
const signInStep = await signIn(parsed)
.catch((error) => {
// AuthError の場合に setError を実行する
if (error instanceof AuthError) {
setError(error.message)
}
logger.info(error)
return null
})
.then((result) => result?.nextStep.signInStep)
// ...
}
return (
<div className="flex flex-col gap-4 min-h-svh w-full items-center justify-center max-w-sm mx-auto">
{error !== '' && (
<Alert variant="destructive">
<AlertCircleIcon />
<AlertTitle>ログインに失敗しました</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{(() => {
switch (step) {
case 'CONFIRM_SIGN_IN_WITH_NEW_PASSWORD_REQUIRED':
return <ConfirmLogin handleSubmit={handleConfirmSignIn} />
default:
return <Login handleSubmit={handleLogin} />
}
})()}
</div>
)
実際にエラーを発生させた場合には下記のように表示がされます。
メッセージを単純に表示できれば良い場合は AuthError.message
をそのまま表示すれば良いですが、英語での表記になるので、日本語で表示したいなどの要件が出てきます。その場合は AuthError.name
が下記のように返ってきます。これは Cognito が投げる例外なのでそれをキーに翻訳を掛ければ良いです。
エラー名 | 発生場面 |
---|---|
UserNotFoundException |
サインイン時、ユーザー名が間違っている場合 |
NotAuthorizedException |
パスワードが間違っている、トークンが無効な場合 |
UserNotConfirmedException |
メール確認が完了していない場合 |
PasswordResetRequiredException |
管理者がパスワードリセットを要求した場合 |
UsernameExistsException |
サインアップ時、既存のユーザー名を使用した場合 |
InvalidPasswordException |
パスワードポリシーを満たしていない場合 |
InvalidParameterException |
必須項目が不足、形式が不正な場合 |
CodeMismatchException |
間違った確認コードを入力した場合 |
ExpiredCodeException |
確認コードの有効期限が切れた場合 |
CodeDeliveryFailureException |
SMS/メール配信に失敗した場合 |
TooManyRequestsException |
API呼び出し制限を超えた場合 |
TooManyFailedAttemptsException |
ログイン試行回数を超えた場合 |
LimitExceededException |
リソース制限に達した場合 |
InvalidUserPoolConfigurationException |
Cognito設定に問題がある場合 |
NetworkError |
インターネット接続に問題がある場合 |
ResourceNotFoundException |
指定したリソースが存在しない場合 |
AliasExistsException |
メール/電話番号が既に使用されている場合 |
InternalErrorException |
AWS側のシステムエラー |
InvalidLambdaResponseException |
カスタムLambdaトリガーのエラー |
さいごに
Cognito User Pools と Amplify を利用したログインの実装について、最初はとてもとっつきにくい印象を受けました。ライブラリがどのような役割をしているかを理解することで一気に理解しやすくなりました。ぜひみなさんも使う際は参考にしてください。