PulseBoard/Auth

Firebase Functions로 카카오/네이버 소셜 로그인 Custom Token 발급하기

밤새는 탐험가89 2025. 12. 31. 16:40
728x90
SMALL

문제: iOS에서 소셜 accessToken만으로 Firebase 로그인할 수 없을까?

iOS에서 카카오/네이버 로그인에 성공하면 accessToken을 얻는다.
하지만 Firebase Auth는 이 accessToken을 직접 받아 로그인할 수 없다(Apple/Google 제외 케이스).


대신 Firebase는 Custom Token 로그인을 제공하는데, 이 토큰은 신뢰된 서버에서만 생성 가능하다.

즉, 필요한 구조는 다음과 같다.

  1. iOS에서 소셜 로그인 후 accessToken 획득
  2. iOS → Cloud Functions로 accessToken 전달
  3. Functions가 발급자(Kakao/Naver) API로 토큰을 검증
  4. 검증된 사용자에 대해 Firebase Custom Token 발급
  5. iOS가 Custom Token으로 Firebase Auth 로그인 완료

설계 목표

  • 클라이언트(iOS)를 신뢰하지 않는다
  • 토큰 검증은 반드시 발급자 서버(Kakao/Naver)에서 수행한다
  • 함수는 “검증 + Custom Token 발급”에만 집중한다
  • 에러는 클라이언트가 처리할 수 있게 표준화한다(HttpsError)

 

1) 이 함수가 하는 일 한 문장

iOS가 준 accessToken이 진짜인지(=해당 소셜 사업자가 발급한 토큰인지) 서버가 직접 확인하고, 진짜면 uid를 정해서 Firebase Custom Token을 만들어 iOS에 돌려준다.

즉 “검증 + 발급”이 핵심.

 

2) 전체 흐름(설계 관점) — 왜 이렇게 나눴나?

Step 0. 왜 서버가 필요하나?

  • iOS 앱은 해킹/프록시/리패키징/런타임 변조가 가능함
    → iOS가 “난 카카오 토큰을 받았어!” 라고 말해도 그대로 믿으면 안 됨
  • Firebase Custom Token은 Firebase 프로젝트의 “로그인 티켓”을 만드는 행위라서
    신뢰된 서버(Cloud Functions 등) 만 가능

그래서 “검증은 서버에서”가 원칙.

 

 Step 1. 입력 파라미터 검증 (invalid-argument) 

if (!accessToken || !provider) throw new HttpsError("invalid-argument", ...)

왜?

  • 서버는 방어적으로 설계해야 함
  • 클라이언트 버그/공격/오타로 들어오는 “이상한 입력”을 초기에 차단해야 뒤 로직이 안정됨
  • 특히 callable은 앱에서 쉽게 호출되므로 “입력 유효성 검증”이 기본

이 단계의 목적: “서버가 처리할 수 있는 형태인가?”를 확인.

 

Step 2. provider whitelist 검증 (failed-precondition)

if (provider !== "kakao" && provider !== "naver") throw ...

 

왜?

  • 문자열은 오타가 나기 쉽고
  • 공격자는 provider: "admin" 같은 값을 넣어 서버의 다른 분기를 유도할 수도 있음
  • 따라서 서버에서 “허용된 값만 통과”시키는 게 안전

이 단계의 목적: “지원 범위 외 요청 차단” + “예측 가능한 분기만 유지”

 

Step 3. 토큰 검증(Provider API 호출) — 신뢰의 핵심 

여기가 설계의 심장.

왜 이렇게 검증하나?

  • “accessToken이 유효한지”를 발급자(카카오/네이버)에게 직접 물어보는 방식이 가장 단단함
  • 서버가 이걸 확인하면,
    • 토큰이 위조/변조/만료/도난인지 바로 걸러짐
    • 동시에 사용자 고유 ID(immutable identifier)를 얻음

여기서 얻는 사용자 ID가 왜 중요?

  • Firebase에서 유저를 식별하려면 uid가 필요함
  • 소셜 로그인에서 가장 안정적인 uid 재료는 “provider가 보장하는 고유 ID”
  • 이메일/닉네임은 바뀌고 없을 수도 있음

 Step 4. UID 설계 (${provider}:${providerUserId}) 

const uid = `${provider}:${providerUserId}`;

왜 prefix를 붙이나?

  • provider마다 ID 형식이 다름
  • 우연히 같은 값이 나올 수 있어 충돌 가능성 존재
  • 그래서 “네임스페이스”를 만들면 충돌이 원천 차단됨

이건 실무에서도 많이 쓰는 패턴:

  • kakao:4664...
  • naver:Vh3k2...

장점

  • 디버깅이 쉬움(로그/Firestore에서 uid만 봐도 어디서 온 유저인지 감)
  • 계정 연동(나중에 “카카오→네이버로도 로그인 가능”)을 하려면 별도의 매핑 테이블이 필요하지만,
    지금 단계에서는 “단일 provider 당 단일 uid”가 가장 단순/안전함.

Step 5. Custom Token 발급 + Claims 

admin.auth().createCustomToken(uid, { provider: provider.toUpperCase() })

 

왜 Claims를 넣나?

  • Custom Claims는 “이 사용자 로그인은 NAVER로 만들어진 토큰이다” 같은 메타정보를 담는 곳
  • 나중에 서버/클라이언트에서
    • provider별 분기
    • 약관/권한/온보딩 흐름
      등을 설계할 때 도움이 됨

단, Claims는 “권한 부여”에 쓰는 경우도 많아서
의미/보안 정책을 명확히 정하고 쓰는 게 좋아.

 

Step 6. 에러 처리 방식 (HttpsError만 클라이언트에 명확히 전달)

if (error instanceof HttpsError) throw error;
throw new HttpsError("internal", "Internal server error");

 

왜 이렇게?

  • callable function에서 iOS가 “에러 코드/메시지”를 제대로 받으려면
    반드시 HttpsError 형태로 던져야 함
  • axios 에러 같은 일반 Error를 그대로 throw하면
    클라이언트에서 일관된 처리 어려움
  • 즉 “서버 내부 에러는 internal로 감싸서”
    클라이언트에는 일관된 인터페이스를 제공

3) 이 구조를 일반화하면

나중에 Google/Apple 등도 붙이게 될 텐데, 그때도 똑같이 적용되는 템플릿이 있어:

✅ Social Login Server Template

  1. 입력 검증 (accessToken, provider)
  2. provider whitelist
  3. provider별 “발급자 API” 호출로 토큰 검증
  4. provider의 immutable user id 추출
  5. uid = provider:userId 설계
  6. Custom Token 발급 (optional: claims)
  7. return { customToken }
  8. HttpsError로 에러 표준화

이 템플릿만 기억하면, 어떤 소셜이든 추가 가능해.

 

알아둬야 할 핵심 개념

1) Firebase Functions v2

  • firebase-functions/v2/https의 onCall 사용
  • v2는 런타임/성능/동시성 등에서 v1과 차이가 있음

2) Callable Function (onCall) vs HTTP Function (onRequest)

  • onCall: Firebase SDK가
    • 인증 헤더 처리
    • 직렬화/역직렬화
    • 에러 포맷
      등을 도와줌
      → 모바일 앱에서 호출하기 편함
  • onRequest: 일반 HTTP 엔드포인트
    → Postman/외부 서비스 연동에 더 유연

현재 상황(“iOS 앱에서 호출”)에는 onCall 선택이 합리적.

3) Admin SDK (firebase-admin)

  • admin.auth().createCustomToken(uid)는 “서버 권한”이 있어야만 가능
  • 그래서 Admin SDK는 서버에서만 사용

4) Axios

  • 외부 API 호출을 위한 HTTP 클라이언트
  • await axios.get(url, { headers }) 패턴

 

전체코드

/**
 * Firebase Cloud Functions (Callable) - Social Login
 *
 * 이 파일의 책임:
 * - iOS 앱에서 전달받은 소셜 accessToken을 서버에서 검증한다
 * - 검증된 사용자에 대해 Firebase Custom Token을 생성한다
 * - iOS 앱은 이 Custom Token으로 Firebase Auth 로그인을 완료한다
 *
 * 🔑 왜 서버(Function)가 필요한가?
 * - Firebase Custom Token은 "신뢰된 서버"에서만 생성 가능
 * - iOS 앱에서 직접 createCustomToken을 호출하면 보안상 허용되지 않음
 * - 따라서 Kakao/Naver accessToken 검증 + Custom Token 발급은 반드시 서버에서 처리
 */

const { onCall, HttpsError } = require("firebase-functions/v2/https");
const admin = require("firebase-admin");
const axios = require("axios");


/**
 * Firebase Admin SDK 초기화
 *
 * - Cloud Functions 환경에서는 서비스 계정으로 자동 인증됨
 * - admin.auth() 등을 사용하기 위해 반드시 필요
 */
admin.initializeApp();


/**
 * socialLogin
 *
 * Firebase Callable Function
 * iOS에서 functions.httpsCallable("socialLogin")으로 호출됨
 *
 * 특징:
 * - onCall 방식 → Firebase SDK가 인증/직렬화 처리
 * - request.data 로 파라미터 전달
 * - 에러는 반드시 HttpsError 로 throw 해야 iOS에서 정상 수신됨
 */
exports.socialLogin = onCall(
  { region: "asia-northeast3" },     // 🇰🇷 서울 리전 
  async (request) => {
    try {
      /**
       * 1️⃣ 요청 파라미터 추출
       *
       * iOS에서 전달되는 형태:
       * {
       *   accessToken: string,
       *   provider: "kakao" | "naver"
       * }
       */
      const { accessToken, provider } = request.data;

      /**
       * 2️⃣ 필수 파라미터 검증
       *
       * - accessToken 또는 provider가 없으면 클라이언트 오류
       * - invalid-argument → iOS에서 명확히 구분 가능한 에러 코드
       */
      if (!accessToken || !provider) {
        throw new HttpsError(
          "invalid-argument",
          "Missing accessToken or provider"
        );
      }
      
      /**
       * 3️⃣ 지원 Provider 검증
       *
       * - 현재는 Kakao / Naver 지원
       * - 그 외 provider는 서버에서 처리 불가
       *
       * ⚠️ provider 값은 iOS에서 문자열로 전달되므로
       *    오타/예상치 못한 값이 들어올 수 있음
       *    → 서버에서 반드시 whitelist 검증 필요
       */
      if (provider !== "kakao" && provider !== "naver") {
        throw new HttpsError(
          "failed-precondition",
          "Unsupported provider"
        );
      }

      /**
       * 4️⃣ Provider별 API 호출
       *
       * 목적:
       * - accessToken이 실제로 유효한지 검증
       * - Provider 사용자 고유 ID 획득
       *
       * ⚠️ 이 단계가 "신뢰의 핵심"
       * → 이 검증이 통과된 사용자만 Firebase 계정을 발급함
       */
      let providerUserId;

      // -------------------------------------------------------------------
      // ✅ 4-1) Kakao 토큰 검증 + 사용자 ID 조회
      // -------------------------------------------------------------------
      if (provider === "kakao") {
        let kakaoResponse;
        try {
          kakaoResponse = await axios.get(
            "https://kapi.kakao.com/v2/user/me",
            {
              headers: {
                Authorization: `Bearer ${accessToken}`,
              },
            }
          );
        } catch (error) {
          /**
           * Kakao API 호출 실패
           * - 토큰 만료
           * - 위조된 토큰
           * - 네트워크 오류
           */
          console.error("❌ Kakao API error:", error.response?.data || error);
          throw new HttpsError(
            "unauthenticated",
            "Invalid Kakao access token"
          );
        }

        /**
         * Kakao 사용자 ID 추출
         *
         * - Kakao에서 제공하는 immutable identifier
         * - Firebase UID 생성에 사용
         */
        providerUserId = kakaoResponse.data?.id;

        if (!providerUserId) {
          throw new HttpsError(
            "unauthenticated",
            "Failed to get Kakao user id"
          );
        }
      }

      // -------------------------------------------------------------------
      // ✅ 4-2) Naver 토큰 검증 + 사용자 ID 조회
      // -------------------------------------------------------------------
      if (provider === "naver") {
        let naverResponse;
        try {
          /**
           * Naver 사용자 정보 API 호출
           *
           * - Naver accessToken을 전달하여
           *   토큰 유효성 검증 + 사용자 정보를 획득
           *
           * 응답 형태:
           * {
           *   "resultcode":"00",
           *   "message":"success",
           *   "response": { "id": "..." , ... }
           * }
           */
          naverResponse = await axios.get(
            "https://openapi.naver.com/v1/nid/me",
            {
              headers: {
                Authorization: `Bearer ${accessToken}`,
              },
            }
          );
        } catch (error) {
          /**
           * Naver API 호출 실패
           * - 토큰 만료
           * - 위조된 토큰
           * - 네트워크 오류
           */
          console.error("❌ Naver API error:", error.response?.data || error);
          throw new HttpsError(
            "unauthenticated",
            "Invalid Naver access token"
          );
        }

        /**
         * Naver 사용자 ID 추출
         *
         * - Naver에서 제공하는 immutable identifier (response.id)
         * - Firebase UID 생성에 사용
         */
        providerUserId = naverResponse.data?.response?.id;

        if (!providerUserId) {
          throw new HttpsError(
            "unauthenticated",
            "Failed to get Naver user id"
          );
        }
      }

      /**
       * 5️⃣ Firebase UID 생성
       *
       * - provider prefix를 붙여 충돌 방지
       * - 예:
       *   kakao:4664280513
       *   naver:Vh3k2...
       */
      const uid = `${provider}:${providerUserId}`;

      /**
       * 6️⃣ Firebase Custom Token 생성
       *
       * 🔥 이 지점이 이번 이슈의 핵심 포인트
       *
       * - admin.auth().createCustomToken()은
       *   IAM 권한 (iam.serviceAccounts.signBlob)이 필요
       * - Cloud Functions의 서비스 계정에
       *   "서비스 계정 토큰 생성자" 역할이 없으면 실패
       *
       * 추가로:
       * - Custom Claims에 provider 정보를 넣어두면
       *   클라이언트/서버에서 사용자 분기 처리에 도움됨
       */
      const customToken = await admin.auth().createCustomToken(uid, {
        provider: provider.toUpperCase(), // "KAKAO" | "NAVER"
      });
      
      /**
       * 7️⃣ iOS로 Custom Token 반환
       *
       * iOS에서는:
       * Auth.auth().signIn(withCustomToken: customToken)
       */
      return { customToken };

    } catch (error) {
      /**
       * 8️⃣ 에러 처리
       *
       * - HttpsError → 그대로 throw (클라이언트에 코드 전달)
       * - 그 외 Error → INTERNAL로 감싸기
       */
      console.error("🔥 socialLogin error:", error);

      // HttpsError는 그대로 던짐
      if (error instanceof HttpsError) {
        throw error;
      }

      // 그 외 모든 에러는 INTERNAL로 감싸기
      throw new HttpsError(
        "internal",
        "Internal server error"
      );
    }
  }
);

 

728x90
LIST