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 로그인을 제공하는데, 이 토큰은 신뢰된 서버에서만 생성 가능하다.
즉, 필요한 구조는 다음과 같다.
- iOS에서 소셜 로그인 후 accessToken 획득
- iOS → Cloud Functions로 accessToken 전달
- Functions가 발급자(Kakao/Naver) API로 토큰을 검증
- 검증된 사용자에 대해 Firebase Custom Token 발급
- 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 호출) — 신뢰의 핵심
여기가 설계의 심장.
- kakao: https://kapi.kakao.com/v2/user/me
- naver: https://openapi.naver.com/v1/nid/me
- 둘 다 공통: Authorization: Bearer <accessToken>
왜 이렇게 검증하나?
- “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
- 입력 검증 (accessToken, provider)
- provider whitelist
- provider별 “발급자 API” 호출로 토큰 검증
- provider의 immutable user id 추출
- uid = provider:userId 설계
- Custom Token 발급 (optional: claims)
- return { customToken }
- 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