【Next.js】AWS CognitoでGoogleソーシャルログインを実装した話

Next.js

はじめに

Next.js と AWS Cognito を使った Web アプリケーションに、Google ソーシャルログイン機能を実装しました。


この記事では、実装手順と実際に遭遇した問題、その解決方法をまとめます。

なぜソーシャルログインを実装したか

ユーザーが簡単にログインできるようにするため、Google アカウントでのログイン機能を追加しました。


メールアドレスとパスワードを覚える必要がなくなり、ユーザー体験が向上します。

実装の概要

使用技術

  • Next.js 14 (App Router)
  • AWS Cognito User Pool (認証基盤)
  • Google OAuth 2.0 (ソーシャルログインプロバイダー)
  • TypeScript

アーキテクチャ

ユーザー 
→ ログインページ 
→ Cognito Hosted UI
→ Google認証 
→ コールバック 
→ トークン取得 
→ ホーム画面
  1. ユーザーが「Google でログイン」ボタンをクリック
  2. Cognito の認証 URL にリダイレクト
  3. Google の認証画面でログイン
  4. コールバック URL にリダイレクト
  5. 認証コードをトークンに交換
  6. ユーザー情報を取得してログイン完了

実装手順

① Google Cloud Console での設定

OAuth 同意画面の設定

  1. Google Cloud Console にアクセス
  2. プロジェクトを作成または選択
  3. 「API とサービス」→「OAuth 同意画面」を選択
  4. ユーザータイプを「外部」に設定(一般ユーザーが利用可能)
  5. アプリ情報を入力:
    • アプリ名
    • ユーザーサポートメール
    • デベロッパーの連絡先情報
  6. スコープを設定:
    • openid
    • email
    • profile

重要: スコープは後で追加できますが、最初に設定しておくとスムーズです。

OAuth 2.0 クライアント ID の作成

  1. 「API とサービス」→「認証情報」を選択
  2. 「認証情報を作成」→「OAuth クライアント ID」を選択
  3. アプリケーションの種類: **「ウェブアプリケーション」**を選択
  4. 承認済みのリダイレクト URIを追加:
    • 開発環境: http://localhost:3000/api/auth/callback
    • 本番環境: https://yourdomain.com/api/auth/callback
    • 重要: AWS Cognito の Hosted UI を使用する場合、Cognito のコールバック URL も追加:
      • https://your-cognito-domain.auth.ap-northeast-1.amazoncognito.com/oauth2/idpresponse
  5. 「作成」をクリック
  6. クライアント ID とシークレットをコピー(シークレットはこの時点でしか表示されません)

② AWS Cognito の設定

Identity Provider の設定

  1. AWS Cognito コンソールで User Pool を開く
  2. 「サインインエクスペリエンス」→「フェデレーション」→「Identity providers」を選択
  3. 「Google」を選択
  4. Google Cloud Console で取得したクライアント ID とシークレットを入力
  5. 「属性マッピング」を設定:
    • Google の email → Cognito の email
    • Google の name → Cognito の name
    • Google の sub → Cognito の username
  6. 「保存」をクリック

App Client の設定

  1. 「アプリの統合」→「アプリクライアント」を選択
  2. 既存の App Client を編集
  3. 「ホストされた UI」を有効化
  4. 「コールバック URL」を設定:
    • 開発環境: http://localhost:3000/api/auth/callback
    • 本番環境: https://yourdomain.com/api/auth/callback
  5. 「許可された OAuth スコープ」で以下を選択:
    • openid
    • email
    • profile

Cognito Domain の確認

  1. 「ブランディング」→「ドメイン」を選択
  2. Cognito ドメインを確認または作成
  3. ドメイン名をコピー(例: your-app.auth.ap-northeast-1.amazoncognito.com

注意: プロトコル(https://)は含めず、ドメイン部分のみを使用します。

③ 環境変数の設定

.env.local に以下を追加:

# 既存の環境変数
COGNITO_CLIENT_ID=your-client-id
AWS_REGION=ap-northeast-1

# ソーシャルログイン用の環境変数
COGNITO_DOMAIN=your-app.auth.ap-northeast-1.amazoncognito.com  # プロトコル不要
COGNITO_REDIRECT_URI=http://localhost:3000/api/auth/callback

重要: 以下の 3 つの場所でリダイレクト URI が一致している必要があります:

  1. .env.local の COGNITO_REDIRECT_URI
  2. AWS Cognito の App Client 設定
  3. Google Cloud Console の OAuth 2.0 クライアント ID 設定

④ バックエンド実装

認証 URL 生成 API

app/api/auth/social/route.ts を作成:

import { NextRequest, NextResponse } from "next/server";
import { getAuthorizationUrl } from "@/lib/cognito";
import { randomBytes } from "crypto";

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const provider = searchParams.get("provider");

  if (!provider) {
    return NextResponse.json(
      { error: "プロバイダーが指定されていません" },
      { status: 400 }
    );
  }

  // CSRF 対策用の state を生成
  const state = randomBytes(32).toString("base64url");

  // 認証 URL を生成
  const authUrl = getAuthorizationUrl(provider, state);

  return NextResponse.json({
    authUrl,
    state,
  });
}

コールバック処理 API

app/api/auth/callback/route.ts を作成:

import { NextRequest, NextResponse } from "next/server";
import { exchangeCodeForTokens, getUserInfoFromIdToken } from "@/lib/cognito";
import { dynamoClient, TABLES } from "@/lib/dynamodb";
import { GetCommand, PutCommand } from "@aws-sdk/lib-dynamodb";
import { getCurrentTimestamp } from "@/lib/utils";

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const code = searchParams.get("code");
  const errorParam = searchParams.get("error");

  if (errorParam) {
    return NextResponse.redirect(
      new URL(`/callback?error=${encodeURIComponent(errorParam)}`, request.url)
    );
  }

  if (!code) {
    return NextResponse.redirect(
      new URL("/callback?error=認証コードが取得できませんでした", request.url)
    );
  }

  // 認証コードをトークンに交換
  const tokens = await exchangeCodeForTokens(code);

  // ID Tokenからユーザー情報を取得
  const userInfo = getUserInfoFromIdToken(tokens.idToken);
  const userId = userInfo.userId || "";

  // DynamoDBにユーザーが存在しない場合は作成
  if (userId) {
    const existingUser = await dynamoClient.send(
      new GetCommand({
        TableName: TABLES.USERS,
        Key: { userId },
      })
    );

    if (!existingUser.Item) {
      const timestamp = getCurrentTimestamp();
      await dynamoClient.send(
        new PutCommand({
          TableName: TABLES.USERS,
          Item: {
            userId,
            username: userInfo.username || userInfo.email || "",
            email: userInfo.email || "",
            displayName: userInfo.displayName || userInfo.username || "",
            createdAt: timestamp,
            updatedAt: timestamp,
          },
        })
      );
    }
  }

  // トークンをクエリパラメータとして /callback にリダイレクト
  const redirectUrl = new URL("/callback", request.url);
  redirectUrl.searchParams.set("accessToken", tokens.accessToken);
  redirectUrl.searchParams.set("idToken", tokens.idToken);
  redirectUrl.searchParams.set("userId", userId);
  redirectUrl.searchParams.set("username", userInfo.username || "");

  return NextResponse.redirect(redirectUrl);
}

Cognito ライブラリの拡張

lib/cognito.ts に以下を追加:

/**
 * ソーシャルログイン用の認証 URL を生成
 */
export function getAuthorizationUrl(provider: string, state: string): string {
  let domain = process.env.COGNITO_DOMAIN;
  const clientId = getClientId();
  const redirectUri = process.env.COGNITO_REDIRECT_URI;

  // プロトコルが含まれている場合は削除
  domain = domain.replace(/^https?:\/\//, "").replace(/\/$/, "");

  // プロバイダー名を正規化(Googleの場合は大文字に)
  const normalizedProvider =
    provider.toLowerCase() === "google" ? "Google" : provider;

  const params = new URLSearchParams({
    response_type: "code",
    client_id: clientId,
    redirect_uri: redirectUri,
    identity_provider: normalizedProvider,
    state: state,
  });

  return `https://${domain}/oauth2/authorize?${params.toString()}`;
}

/**
 * 認証コードを Cognito トークンに交換
 */
export async function exchangeCodeForTokens(code: string) {
  let domain = process.env.COGNITO_DOMAIN;
  const clientId = getClientId();
  const redirectUri = process.env.COGNITO_REDIRECT_URI;

  domain = domain.replace(/^https?:\/\//, "").replace(/\/$/, "");

  const tokenUrl = `https://${domain}/oauth2/token`;

  const params = new URLSearchParams({
    grant_type: "authorization_code",
    client_id: clientId,
    code: code,
    redirect_uri: redirectUri,
  });

  const response = await fetch(tokenUrl, {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: params.toString(),
  });

  if (!response.ok) {
    const errorText = await response.text();
    throw new Error(`トークン交換に失敗しました: ${errorText}`);
  }

  const data = await response.json();

  return {
    accessToken: data.access_token,
    idToken: data.id_token,
    refreshToken: data.refresh_token,
  };
}

/**
 * ID Tokenからユーザー情報を取得(ソーシャルログイン用)
 */
export function getUserInfoFromIdToken(idToken: string) {
  try {
    const payload = JSON.parse(
      Buffer.from(idToken.split(".")[1], "base64").toString()
    );
    return {
      userId: payload.sub || null,
      username:
        payload.preferred_username || payload.email || payload.sub || "",
      email: payload.email || "",
      displayName: payload.name || payload.preferred_username || "",
    };
  } catch (error) {
    console.error("Failed to decode ID token:", error);
    return {
      userId: null,
      username: "",
      email: "",
      displayName: "",
    };
  }
}

⑤ フロントエンド実装

ログインページの拡張

app/(auth)/login/page.tsx に Google ログインボタンを追加:

const handleGoogleLogin = async () => {
  setSocialLoading(true);
  setError("");

  try {
    // 認証 URL を取得
    const response = await fetch("/api/auth/social?provider=google");
    const data = await response.json();

    if (!response.ok) {
      throw new Error(data.error || "認証 URL の取得に失敗しました");
    }

    // state をセッションストレージに保存(CSRF 対策)
    if (data.state) {
      sessionStorage.setItem("oauth_state", data.state);
    }

    // 認証 URL にリダイレクト
    if (data.authUrl) {
      window.location.href = data.authUrl;
    }
  } catch (err: any) {
    setError(err.message);
    setSocialLoading(false);
  }
};

コールバック処理ページ

"use client";

import { useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useAuthStore } from "@/lib/store/auth-store";

export default function CallbackPage() {
  const searchParams = useSearchParams();
  const setAuth = useAuthStore((state) => state.setAuth);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const handleCallback = async () => {
      const accessToken = searchParams.get("accessToken");
      const idToken = searchParams.get("idToken");
      const userId = searchParams.get("userId");
      const username = searchParams.get("username");
      const errorParam = searchParams.get("error");

      if (errorParam) {
        setError(`認証エラー: ${errorParam}`);
        return;
      }

      if (accessToken && idToken) {
        // トークンをストアに保存
        setAuth({
          accessToken,
          idToken,
          userId: userId || "",
          username: username || "",
        });

        // ホームページにリダイレクト
        window.location.href = "/home";
      }
    };

    handleCallback();
  }, [searchParams, setAuth]);

  // ... エラー表示とローディング表示
}

実際に遭遇した問題と解決方法

invalid_scope エラー

問題: Google ログイン時に「Some requested scopes were invalid」エラーが発生。

原因: Google Cloud Console の OAuth 同意画面で、必要なスコープ(openidemailprofile)が有効になっていなかった。

解決方法:

  1. Google Cloud Console の「OAuth 同意画面」を開く
  2. 「スコープを追加または削除」をクリック
  3. 以下のスコープを追加:
    • openid
    • email
    • profile
  4. テストユーザーを追加(開発中の場合)

405 エラー(Method Not Allowed)

問題: Cognito からのリダイレクト時に 405 エラーが発生。

原因/api/auth/callback が POST メソッドのみをサポートしていたが、Cognito からのリダイレクトは GET リクエスト。

解決方法/api/auth/callback/route.ts に GET メソッドを追加し、クエリパラメータから認証コードを取得するように修正。

Access Token does not have required scopes

問題getUser() 関数で「Access Token does not have required scopes」エラーが発生。

原因: ソーシャルログインで取得した Access Token には aws.cognito.signin.user.admin スコープが含まれていないため、GetUserCommand が使用できない。

解決方法: ID Token(JWT)から直接ユーザー情報を取得する getUserInfoFromIdToken() 関数を実装し、ソーシャルログイン時はこちらを使用。

初回ログイン時の 404 エラー

問題: 初回 Google ログイン時に /api/users/[userId] で 404 エラーが発生。

原因: Cognito ユーザープールにはユーザーが作成されるが、DynamoDB のユーザーテーブルにはまだレコードが存在しない。

解決方法: コールバック処理で、DynamoDB にユーザーが存在しない場合は自動的に作成する処理を追加。

Cognito Hosted UI の画面が表示される

問題: Google ログイン時に、Cognito の Hosted UI 画面が表示されてから Google 認証に進む。

原因identity_provider パラメータの値が小文字の "google" だったが、Cognito の設定では大文字の "Google" が期待されていた。

解決方法: プロバイダー名を正規化し、Google の場合は大文字の "Google" に変換。

実装のポイント

CSRF 対策

認証 URL 生成時にランダムな state パラメータを生成し、セッションストレージに保存。コールバック時に state を検証することで、CSRF 攻撃を防ぎます。

ID Token からのユーザー情報取得

ソーシャルログインでは、Access Token に必要なスコープがないため、ID Token(JWT)から直接ユーザー情報を取得します。

ID Token には以下の情報が含まれています:

  • sub: ユーザー ID
  • email: メールアドレス
  • name: 表示名
  • preferred_username: ユーザー名

初回ログイン時のユーザー作成

ソーシャルログインで初めてログインしたユーザーは、Cognito ユーザープールには自動的に作成されますが、アプリケーション側の DynamoDB にはまだレコードが存在しません。

コールバック処理で、ユーザーが存在しない場合は自動的に作成することで、シームレスな体験を提供します。

まとめ

AWS Cognito と Google OAuth 2.0 を使用したソーシャルログインの実装は、設定が複雑に見えますが、一度設定すれば安定して動作します。特に以下の点に注意が必要です:

  1. リダイレクト URI の一致: 3 つの場所(環境変数、Cognito、Google)で一致させる
  2. スコープの設定: Google Cloud Console で必要なスコープを有効化
  3. ID Token の活用: ソーシャルログインでは ID Token からユーザー情報を取得
  4. 初回ログイン時の処理: DynamoDB へのユーザー作成を自動化

実装後は、ユーザーが簡単にログインできるようになり、ユーザー体験が大幅に向上しました。

参考リソース