THE DEVLOG

scribbly.

Next JS 치트 시트

2025.05.16 12:19:39

Auth 구현

Implicit Flow vs. PKCE (Proof Key for Code Exchange) Flow

  1. Implicit Flow

    • 클라이언트가 인증 서버에 로그인을 요청한 후, 토큰을 클라이언트 측에 저장한다.
    • URL 혹은 Request에 해당 토큰을 담아 사용한다.
    • 토큰이 쉽게 탈취될 가능성이 있고 CSRF(사이트간 위조)를 방어하기 취약하다.
  2. PKCE

    • 클라이언트가 임의의 난수(code_verifier)와 난수를 암호화한 수(code_challenge)를 가짐
    • 서버가 code_challenge를 전달받음.
    • 이후 클라이언트가 엑세스 토큰을 요청할 때마다 code_verifier를 함께 보내고, code_verifier가 유효할 때에만 엑세스 토큰이 발급됨.
    • 토큰이 유출되더라도 토큰이 만료되면 code_verifier가 있어야 새로운 토큰을 발급받을 수 있으며, code_verifier는 엑세스 토큰에 비해 유출될 위험이 비교적 적음.

PKCE (Proof Key for Code Exchange) Flow 구현

Google 로그인 구현

Google Cloud API Oauth 세팅
  • 구글 클라우드 콘솔 에 접속한다.
  • 새 프로젝트를 생성한다.
  • 프로젝트-API 및 서비스-OAuth 동의 화면에 접속한다.
  • '시작하기' 버튼을 누른 후 아래와 관련된 사항들을 입력하여 OAuth를 시작한다.
    • '앱 이름'(로그인 시 노출될 프로젝트 명)
    • '사용자 지원 이메일'(내 이메일)
    • '대상'(외부)
  • 이제 Supabase에 접속해서 'https://supabase.com/dashboard/project/{Project ID}/auth/providers'에 접속하여 Google을 Provider로 선택한다.
  • 해당 페이지에서 'Callback URL (for OAuth)'를 확인한다.
  • 다시 구글 클라우드 콘솔 프로젝트-API 및 서비스-OAuth 동의 화면에서 'OAuth 클라이언트 만들기'를 클릭하고 아래의 사항들을 입력하여 Client를 만든다.
    • '애플리케이션 유형'(웹 애플리케이션)
    • '승인된 JavaScript 원본'(http://localhost:3000)
    • '승인된 리디렉션 URI'(https://{Project ID}.supabase.co/auth/v1/callback)
  • 다시 Supabase에 접속해서 'https://supabase.com/dashboard/project/{Project ID}/auth/providers'에 아래 사항들을 입력해준다.
    • Enable Sign in with Google를 활성화한다.
    • 클라이언트 ID : Google OAuth 클라이언트의 ID (566..877-cuhhs...apps.googleusercontent.com)
    • 클라이언트 Secret : Google OAuth 클라이언트의 보안 비밀번호 (GO...PX-fK...xQ)
  • 이제 Supabase에 Google Cloud API의 OAuth Client가 등록되었다.
Route Handlers 구현
  • .env 파일에 NEXT_PUBLIC_BASE_URL=http://localhost:3000을 변수명으로 추가해준다. (해당 변수명은 배포/개발시마다 바뀔 수 있다.)
  • 아래와 같이 app\auth\callback\route.ts 파일을 작성한다.
    • code : 인증서비스제공자가 Authorization Code를 supabase 서버에 전달하면, supabase 서버는 이를 Search Params의 code라는 key에 담아서 보내준다.
    • next : Next.js에서 이동할 URL을 설정할 때에는 next라는 key로 이동할 url을 Search Params에 담으면 된다.
    • exchangeCodeForSession : supabase 클라이언트에서 Authorization Code를 인자로 받아, access_token을 반환받고 세션을 생성한다.
    • if !error : exchange가 성공적으로 완료되면 사용자를 redirect한다. (forwardedHost는 한 어플리케이션을 여러 서버가 다룰 때에 사용하는 서버의 주소이다.(로드 밸런싱 방식))
import { NextResponse } from "next/server";
// The client you created from the Server-Side Auth instructions
import { createClient } from "@/utils/supabase/server";

export async function GET(request: Request) {
  const { searchParams, origin } = new URL(request.url);
  const code = searchParams.get("code");
  // if "next" is in param, use it as the redirect URL
  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`);
}
Pages 구현
  • login page : signInWithOAuth 클라이언트에 provider와 /auth/callback 주소를 전송한다.
// app/auth/login/page.tsx (로그인 페이지)
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { createClient } from "@/utils/supabase/client";

export default function LoginPage() {
  const [loading, setLoading] = useState(false);
  const router = useRouter();

  const handleLogin = async () => {
    setLoading(true);
    const supabase = createClient();
    const { error } = await supabase.auth.signInWithOAuth({
      provider: "google", // Google OAuth 로그인
      options: { redirectTo: `${window.location.origin}/auth/callback` },
    });
    if (error) {
      router.push(
        `/auth/auth-code-error?message=${encodeURIComponent(error.message)}`
      );
    }
    setLoading(false);
  };

  return (
    <div className="flex flex-col items-center justify-center h-screen">
      <h1 className="text-2xl font-bold">로그인</h1>
      <button
        onClick={handleLogin}
        disabled={loading}
        className="mt-4 px-4 py-2 bg-blue-500 text-white rounded"
      >
        {loading ? "로그인 중..." : "Google 로그인"}
      </button>
    </div>
  );
}
  • auth-code-error에서는 searchParams으로 넘어온 에러 메시지를 노출시킨다.
import Link from "next/link";

// app/auth/auth-code-error/page.tsx (로그인 에러 페이지)
export default function AuthCodeErrorPage({
  searchParams,
}: {
  searchParams: { message?: string };
}) {
  const errorMessage =
    searchParams?.message || "로그인 중 문제가 발생했습니다.";

  return (
    <div className="flex flex-col items-center justify-center h-screen">
      <h1 className="text-2xl font-bold text-red-500">인증 오류 발생</h1>
      <p className="text-gray-600 mt-2">{errorMessage}</p>
      <Link
        href="/auth/login"
        className="mt-4 px-4 py-2 bg-blue-500 text-white rounded"
      >
        로그인 다시 시도하기
      </Link>
    </div>
  );
}

Password 로그인 구현

Supabase is now compatible with Next.js 14 - supabase blogs 를 참고하여 Sign-up 액션과 Sign-in 액션을 구현하여 보자.

Redirect용 Util 함수

utils\encodedRedirect.tsx

import { redirect } from "next/navigation";

/**
 * Redirects to a specified path with an encoded message as a query parameter.
 * @param {('error' | 'success')} type - The type of message, either 'error' or 'success'.
 * @param {string} path - The path to redirect to.
 * @param {string} message - The message to be encoded and added as a query parameter.
 * @returns {never} This function doesn't return as it triggers a redirect.
 */
export function encodedRedirect(
  type: "error" | "success", // 메시지 타입 (에러 또는 성공)
  path: string, // 리디렉션할 경로
  message: string // 전달할 메시지
) {
  return redirect(`${path}?${type}=${encodeURIComponent(message)}`);
}

encodeURIComponent는 JavaScript 내장 함수로, 특수 문자나 공백이 포함된 문자열을 URL-safe 형식으로 인코딩하는 역할을 합니다.

Actions

🚀 📌 코드 분석 요약

  1. 회원가입 (signUpAction)

    • 이메일과 비밀번호를 받아 Supabase에 회원가입 요청.
    • 인증 이메일을 전송하고, 성공 또는 실패 여부에 따라 적절한 페이지로 리디렉션.
  2. 로그인 (signInAction)

    • 이메일과 비밀번호를 받아 Supabase에 로그인 요청.
    • 로그인 실패 시 /sign-in으로 에러 메시지를 포함하여 리디렉션.
    • 로그인 성공 시 보호된 페이지 /protected로 이동.
  3. 비밀번호 재설정 요청 (forgotPasswordAction)

    • 이메일을 받아 비밀번호 재설정 이메일을 전송.
    • 실패하면 /forgot-password에러 메시지를 포함하여 리디렉션.
    • 성공하면 사용자가 이메일을 확인하도록 안내하는 메시지를 포함하여 리디렉션.
  4. 비밀번호 변경 (resetPasswordAction)

    • 사용자가 입력한 새 비밀번호를 확인 후 Supabase에 업데이트 요청.
    • 비밀번호가 일치하지 않거나 요청이 실패하면 에러 메시지를 포함하여 리디렉션.
    • 성공 시 비밀번호가 변경되었음을 알리는 메시지를 포함하여 리디렉션.
  5. 로그아웃 (signOutAction)

    • Supabase에서 세션을 삭제하고 /sign-in 페이지로 이동.

✔️ 모든 액션에서 encodedRedirect()를 활용하여 성공/실패 메시지를 포함한 리디렉션을 수행하는 것이 특징! 🚀

app\auth\actions.tsx

"use server"; // Next.js의 Server Actions를 사용하도록 지정

import { encodedRedirect } from "@/utils/encodedRedirect"; // 메시지를 포함한 리디렉션 함수
import { createClient } from "@/utils/supabase/server"; // Supabase 클라이언트 생성 함수
import { headers } from "next/headers"; // 요청 헤더 가져오기
import { redirect } from "next/navigation"; // Next.js 리디렉션 함수

// 회원가입 처리 (Sign Up)
export const signUpAction = async (formData: FormData) => {
  // 폼 데이터에서 이메일과 비밀번호 추출
  const email = formData.get("email")?.toString();
  const password = formData.get("password")?.toString();
  const supabase = await createClient(); // Supabase 클라이언트 생성
  const origin = (await headers()).get("origin"); // 현재 요청의 Origin (도메인) 가져오기

  // 이메일 또는 비밀번호가 없으면 에러 메시지를 포함하여 리디렉션
  if (!email || !password) {
    return encodedRedirect(
      "error",
      "/sign-up",
      "Email and password are required"
    );
  }

  // Supabase를 사용해 회원가입 요청
  const { error } = await supabase.auth.signUp({
    email,
    password,
    options: {
      emailRedirectTo: `${origin}/auth/callback`, // 이메일 확인 후 이동할 URL 설정
    },
  });

  // 에러 발생 시 에러 메시지를 포함하여 리디렉션
  if (error) {
    console.error(error.code + " " + error.message);
    return encodedRedirect("error", "/sign-up", error.message);
  }

  // 회원가입 성공 시 성공 메시지를 포함하여 리디렉션
  return encodedRedirect(
    "success",
    "/sign-up",
    "Thanks for signing up! Please check your email for a verification link."
  );
};

// 로그인 처리 (Sign In)
export const signInAction = async (formData: FormData) => {
  // 폼 데이터에서 이메일과 비밀번호 추출
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;
  const supabase = await createClient(); // Supabase 클라이언트 생성

  // Supabase를 사용해 로그인 요청
  const { error } = await supabase.auth.signInWithPassword({
    email,
    password,
  });

  // 에러 발생 시 에러 메시지를 포함하여 리디렉션
  if (error) {
    return encodedRedirect("error", "/sign-in", error.message);
  }

  // 로그인 성공 시 보호된 페이지로 이동
  return redirect("/protected");
};

// 비밀번호 재설정 요청 (Forgot Password)
export const forgotPasswordAction = async (formData: FormData) => {
  // 폼 데이터에서 이메일 추출
  const email = formData.get("email")?.toString();
  const supabase = await createClient(); // Supabase 클라이언트 생성
  const origin = (await headers()).get("origin"); // 현재 요청의 Origin (도메인) 가져오기
  const callbackUrl = formData.get("callbackUrl")?.toString(); // 콜백 URL이 있는 경우 가져오기

  // 이메일이 없으면 에러 메시지를 포함하여 리디렉션
  if (!email) {
    return encodedRedirect("error", "/forgot-password", "Email is required");
  }

  // Supabase를 사용해 비밀번호 재설정 이메일 전송 요청
  const { error } = await supabase.auth.resetPasswordForEmail(email, {
    redirectTo: `${origin}/auth/callback?redirect_to=/protected/reset-password`, // 비밀번호 재설정 후 이동할 URL 설정
  });

  // 에러 발생 시 에러 메시지를 포함하여 리디렉션
  if (error) {
    console.error(error.message);
    return encodedRedirect(
      "error",
      "/forgot-password",
      "Could not reset password"
    );
  }

  // 콜백 URL이 있으면 해당 URL로 리디렉션
  if (callbackUrl) {
    return redirect(callbackUrl);
  }

  // 비밀번호 재설정 이메일이 전송되었음을 알리는 메시지 포함하여 리디렉션
  return encodedRedirect(
    "success",
    "/forgot-password",
    "Check your email for a link to reset your password."
  );
};

// 비밀번호 변경 처리 (Reset Password)
export const resetPasswordAction = async (formData: FormData) => {
  const supabase = await createClient(); // Supabase 클라이언트 생성

  // 폼 데이터에서 새 비밀번호와 확인용 비밀번호 추출
  const password = formData.get("password") as string;
  const confirmPassword = formData.get("confirmPassword") as string;

  // 비밀번호 또는 확인용 비밀번호가 없으면 에러 메시지를 포함하여 리디렉션
  if (!password || !confirmPassword) {
    return encodedRedirect(
      "error",
      "/protected/reset-password",
      "Password and confirm password are required"
    );
  }

  // 비밀번호와 확인용 비밀번호가 일치하지 않으면 에러 메시지를 포함하여 리디렉션
  if (password !== confirmPassword) {
    return encodedRedirect(
      "error",
      "/protected/reset-password",
      "Passwords do not match"
    );
  }

  // Supabase를 사용해 비밀번호 변경 요청
  const { error } = await supabase.auth.updateUser({
    password: password,
  });

  // 에러 발생 시 에러 메시지를 포함하여 리디렉션
  if (error) {
    return encodedRedirect(
      "error",
      "/protected/reset-password",
      "Password update failed"
    );
  }

  // 비밀번호 변경 성공 시 성공 메시지를 포함하여 리디렉션
  return encodedRedirect(
    "success",
    "/protected/reset-password",
    "Password updated"
  );
};

// 로그아웃 처리 (Sign Out)
export const signOutAction = async () => {
  const supabase = await createClient(); // Supabase 클라이언트 생성

  // Supabase를 사용해 로그아웃 요청
  await supabase.auth.signOut();

  // 로그아웃 후 로그인 페이지로 이동
  return redirect("/sign-in");
};

해당 Server Actions는 두 가지 사용법이 있다.

  1. Form 태그에 action 속성에 넘겨주는 방법
<form action={signOutAction}>
  <button type="submit">Sign out</button>
</form>
  1. Form 태그 내부의 Button 태그에 formAction 속성에 넘겨주는 방법
<form>
  <button formAction={signOutAction}>Sign out</button>
</form>