✍️

【Next.js】Supabase+Googleログインを実装する

2024/12/18に公開

今回は、Supabaseを使ったGoogleログイン機能の実装方法をまとめています。

Supabase ログイン

実装までの流れ

  1. 事前準備
  2. ライブラリをインストールする
  3. 環境変数に設定する
  4. Supabaseクライアントを設定する
  5. コールバック関数を作る
  6. ログイン/ログアウト関数を作る
  7. ログインページを作る
  8. 認証情報を判定/キャッシュする関数を作る
  9. 「ミドルウェア」で認証情報を確認する
  10. 「ページ内」で認証情報を確認する

事前に用意するもの

  1. Google Cloud Platform
    • OAuth 2.0 クライアント ID
    • OAuth 2.0 クライアント シークレット
  2. Supabase
    • Supabase URL
    • Supabase anon key

【0】事前準備

  1. プロジェクトの「Authentication」→「Providers」でGoogleをEnableにする
  2. GCPの「OAuth 2.0 クライアントID」「OAuth 2.0 クライアント シークレット」をClient IDClient Secretに設定する
  3. Callback URLを取得する
  4. GCPのOAuth 2.0 設定画面で「承認済みのリダイレクト URI」にCallback URLを追加する

【1】ライブラリをインストールする

$ npm install @supabase/supabase-js @supabase/ssr

【2】環境変数に設定する

# .env.local
SUPABASE_URL=https://xxxxx.supabase.co   # プロジェクトURL
SUPABASE_ANON_KEY=xxxxxxxxxxxxxxxxxxxx   # 公開用のAPIキー
SUPABASE_AUTH_URL=http://localhost:3001  # 認証後にリダイレクトするURL

【3】Supabaseクライアントを設定する

lib/supabase-auth/server.ts
// lib/supabase-auth/server.ts

"use server";

import { CookieOptions, createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'


export async function createClient() {
    const cookieStore = cookies()

    return createServerClient(
        process.env.SUPABASE_URL!,
        process.env.SUPABASE_ANON_KEY!,
        {
            cookies: {
                get(name: string) {
                    return cookieStore.get(name)?.value;
                },
                set(name: string, value: string, options: CookieOptions) {
                    try {
                        if (typeof window === 'undefined') {
                            cookieStore.set({ name, value, ...options });
                        }
                    } catch (error) {
                        // Server Component内でのCookie設定エラーを無視
                        // ミドルウェアでCookieが処理される
                    }
                },
                remove(name: string, options: CookieOptions) {
                    try {
                        if (typeof window === 'undefined') {
                            cookieStore.set({ name, value: '', ...options, maxAge: 0 });
                        }
                    } catch (error) {
                        // Server Component内でのCookie削除エラーを無視
                    }
                }
            },
        }
    )
}

【4】コールバック関数を作る

api/auth/callback/route.ts
import { createClient } from '@/lib/supabase-auth/server'
import { NextResponse } from 'next/server'
// The client you created from the Server-Side Auth instructions


export async function GET(request: Request) {
    const { searchParams, origin } = new URL(request.url)
    const code = searchParams.get('code')
    const next = searchParams.get('next') ?? '/'

    if (code) {
        const supabase = await createClient()
        const { error } = await supabase.auth.exchangeCodeForSession(code)
        if (!error) {
            const forwardedHost = request.headers.get('x-forwarded-host') // original origin before load balancer
            const isLocalEnv = process.env.NODE_ENV === 'development'
            if (isLocalEnv) {
                // we can be sure that there is no load balancer in between, so no need to watch for X-Forwarded-Host
                return NextResponse.redirect(`${origin}${next}`)
            } else if (forwardedHost) {
                return NextResponse.redirect(`https://${forwardedHost}${next}`)
            } else {
                return NextResponse.redirect(`${origin}${next}`)
            }
        }
    }

    // return the user to an error page with instructions
    return NextResponse.redirect(`${origin}/auth/auth-code-error`)
}

【5】ログイン/ログアウト関数を作る

lib/supabase-auth/authGoogle.ts
// lib/supabase-auth/authGoogle.ts
"use server";

import { createClient } from "@/lib/supabase-auth/server";
import { redirect } from "next/navigation";

// ---------------------------------------------
// Googleログイン
// ---------------------------------------------
export async function signInWithGoogle() {
    // クライアントを作成
    const supabase = await createClient();
    const { data: { url }, error } = await supabase.auth.signInWithOAuth({
        provider: "google",
        options: {
            redirectTo: `${process.env.SUPABASE_AUTH_URL}/api/auth/callback`,
            queryParams: {
                access_type: 'offline',
                prompt: 'consent',
            },
        },
    });
    if (error) console.error('Googleログインエラー:', error.message)
    if (!error && url) redirect(url);
}

// ---------------------------------------------
// Googleログアウト
// ---------------------------------------------
export async function signOut() {
    // クライアントを作成
    const supabase = await createClient();
    const { error } = await supabase.auth.signOut();
    if (error) console.error('Googleログアウトエラー:', error.message)
    if (!error) return true;
    return false;
}

【6】ログインページを作る

login/page.tsx
// login/page.tsx

"use client";

import React from "react";
import { signInWithGoogle, signOut } from "@/lib/supabase-auth/authGoogle";
import { useRouter } from "next/navigation";


export default function LoginPage() {

    // Googleログイン
    const handleGoogleLogin = async () => {
        await signInWithGoogle();
    }

    // Googleログアウト
    const router = useRouter();
    const handleGoogleLogout = async () => {
        const result = await signOut();
        if (result) router.refresh();
    }

    return (
        <div>
            <button onClick={handleGoogleLogin}>ログイン</button>
            <button onClick={handleGoogleLogout}>ログアウト</button>
        </div>
    )
}

【7】認証情報を判定/キャッシュする関数を作る

lib/supabase-auth/auth.ts
// lib/supabase-auth/auth.ts

import { cache } from 'react';
import { redirect } from 'next/navigation';
import { createClient } from './server';


// ---------------------------------------------
// 管理者として許可するメールアドレスのリスト
// ---------------------------------------------
export const adminUsers = [
    "example@example.com",
];

// ---------------------------------------------
// 認証チェック
// ---------------------------------------------
const validateAuthWithRedirect = async () => {
    /* 未認証であればredirect、 認証できればユーザー情報を返す */
    // ユーザーを取得
    const supabase = await createClient();
    const { data: { user }, error } = await supabase.auth.getUser();
    // 認証失敗: ユーザーが存在しない場合 / 権限がない場合
    if (!user || !user.email || !adminUsers.includes(user.email)) {
        redirect("/login");
    };
    return user;
};

// 認証チェックをキャッシュ
export const cachedValidateAuthWithRedirect = cache(validateAuthWithRedirect);

【8】「ミドルウェア」で認証情報を確認する

middleware.ts
// middleware.ts
import { type NextRequest } from 'next/server'
import { updateSession } from '@/lib/supabase-auth/middleware'


export async function middleware(request: NextRequest) {
    return await updateSession(request)
}

export const config = {
    matcher: [
        // 以下のパスは除外されます: 
        // - api/auth/*
        // - _next/static, _next/image, favicon.ico, 画像関連ファイル
        '/((?!api/auth/.*|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
    ],
}
lib/supabase-auth/middleware.ts
// lib/supabase-auth/middleware.ts

import { CookieOptions, createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";


// 1. クッキー情報からUserデータを取得する
// 2. 未ログインの場合(Userデータが存在しない場合)
export async function updateSession(request: NextRequest) {
    // レスポンスを作成
    let response = NextResponse.next({ request });
    const pathname = request.nextUrl.pathname;
    response.headers.set("x-current-path", pathname); // パス情報をヘッダーに設定

    // ---------------------------------------------
    // Userデータを取得
    // ---------------------------------------------
    // クライアントを作成
    const supabase = createServerClient(
        process.env.SUPABASE_URL!,
        process.env.SUPABASE_ANON_KEY!,
        {
            cookies: {
                get(name: string) {
                    return request.cookies.get(name)?.value;
                },
                set(name: string, value: string, options: CookieOptions) {
                    // リクエストとレスポンスの両方にCookieを設定
                    request.cookies.set({
                        name,
                        value,
                        ...options,
                    });
                    response = NextResponse.next({
                        request: {
                            headers: request.headers,
                        },
                    });
                    response.cookies.set({
                        name,
                        value,
                        ...options,
                    });
                },
                remove(name: string, options: CookieOptions) {
                    // Cookieを削除
                    request.cookies.set({
                        name,
                        value: '',
                        ...options,
                    });
                    response = NextResponse.next({
                        request: {
                            headers: request.headers,
                        },
                    });
                    response.cookies.set({
                        name,
                        value: '',
                        ...options,
                    });
                },
            },
        }
    );
    // ユーザーを取得
    const { data: { user } } = await supabase.auth.getUser();
    // 重要: createServerClient と supabase.auth.getUser() の間にロジックを
    // 書かないでください。単純なミスでも、ユーザーがランダムにログアウトされる
    // 問題のデバッグが非常に困難になる可能性があります。

    // ---------------------------------------------
    // 未ログインユーザーの場合: リダイレクト処理
    // ---------------------------------------------
    if (
        !user &&
        !pathname.startsWith('/login') &&
        !pathname.startsWith('/auth')
    ) {
        const url = request.nextUrl.clone();
        url.pathname = '/login';
        return NextResponse.redirect(url);
    }

    // ---------------------------------------------
    // ログインユーザーの場合: レスポンスを返す
    // ---------------------------------------------
    response.headers.set("x-current-path", pathname);
    return response;
}

【9】「ページ内」で認証情報を確認する

ログインページ/login以外全てのページで認証情報を確認する場合の例です。

layout.tsx
// layout.tsx

import React from "react";
import "./globals.css";
import { headers } from "next/headers";
import { cachedValidateAuthWithRedirect } from "@/lib/supabase-auth/auth";


export default async function RootLayout({children,}: {children: React.ReactNode;}) {

    // ---------------------------------------------
    // リクエストURLを取得
    // ---------------------------------------------
    const headersList = headers();
    const pathname = headersList.get("x-current-path");
    const isLoginPage = pathname?.startsWith("/login");

    // ---------------------------------------------
    // 認証確認: ログインページ以外
    // ---------------------------------------------
    if (!isLoginPage) {
        await cachedValidateAuthWithRedirect();
    }

    return (
        <html lang="ja" className="h-full">
            <body>
                {isLoginPage
                    ? <>{children}</>
                    : <main className="relative min-h-screen">
                        <MainLayout>
                            {children}
                        </MainLayout>
                    </main>
                }
            </body>
        </html>
    );
}

参考ページ

Discussion