fix(kakao): Kakao SDK v2 authorize 방식으로 전환 + code 교환 엔드포인트
Deploy / deploy (push) Failing after 0s

- Kakao SDK v2.7에서 Auth.login() 제거 → authorize() 리다이렉트 플로우 사용
- services/kakao.ts: kakaoWebLoginStart() redirect 시작
- RootNavigator: 웹 URL의 code + state=kakao_login 감지 → kakaoCodeLogin 호출
- useAuthStore.kakaoCodeLogin 추가 (authApi.kakaoCode 호출)
- 백엔드 /auth/kakao/code: REST API 키로 code→access_token 교환 후 기존 processKakaoLogin 재사용
- api-secrets에 kakaoRestApiKey/kakaoClientSecret 슬롯 추가
- 환경변수: KAKAO_REST_API_KEY 서버에 저장 완료

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-04-23 01:27:49 +09:00
parent fc63746728
commit c53cc69d97
8 changed files with 133 additions and 77 deletions
+72 -48
View File
@@ -1,4 +1,4 @@
import type { FastifyInstance } from 'fastify';
import type { FastifyInstance, FastifyReply } from 'fastify';
import bcrypt from 'bcrypt';
import { z } from 'zod';
@@ -17,8 +17,10 @@ const LoginBody = z.object({
password: z.string().min(1),
});
const KakaoBody = z.object({
accessToken: z.string().min(10),
const KakaoBody = z.object({ accessToken: z.string().min(10) });
const KakaoCodeBody = z.object({
code: z.string().min(5),
redirectUri: z.string().url(),
});
type KakaoMe = {
@@ -40,6 +42,62 @@ async function fetchKakaoProfile(accessToken: string): Promise<KakaoMe> {
return (await res.json()) as KakaoMe;
}
async function exchangeKakaoCode(code: string, redirectUri: string): Promise<string> {
const restKey = process.env.KAKAO_REST_API_KEY;
if (!restKey) throw new Error('KAKAO_REST_API_KEY not configured');
const form = new URLSearchParams({
grant_type: 'authorization_code',
client_id: restKey,
redirect_uri: redirectUri,
code,
});
if (process.env.KAKAO_CLIENT_SECRET) {
form.set('client_secret', process.env.KAKAO_CLIENT_SECRET);
}
const res = await fetch('https://kauth.kakao.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8' },
body: form.toString(),
});
if (!res.ok) throw new Error(`Kakao token exchange ${res.status}: ${await res.text()}`);
const data = (await res.json()) as any;
return data.access_token as string;
}
async function processKakaoLogin(app: FastifyInstance, reply: FastifyReply, accessToken: string) {
let me: KakaoMe;
try {
me = await fetchKakaoProfile(accessToken);
} catch {
return reply.code(401).send({ message: '카카오 인증 실패' });
}
const kakaoId = String(me.id);
const email = me.kakao_account?.email;
const name = me.kakao_account?.profile?.nickname ?? me.properties?.nickname ?? '카카오사용자';
const profileImage = me.kakao_account?.profile?.profile_image_url ?? me.properties?.profile_image;
let user = await app.prisma.user.findUnique({
where: { kakaoId },
include: { profile: true },
});
if (!user) {
user = await app.prisma.user.create({
data: {
kakaoId,
email: email ?? null,
name,
phone: me.kakao_account?.phone_number,
provider: 'KAKAO',
profileImage,
profile: { create: { age: 30, gender: 'MALE', job: '기타' } },
},
include: { profile: true },
});
}
const token = await reply.jwtSign({ sub: user.id, email: user.email ?? undefined });
return { token, user: publicUser(user) };
}
export async function authRoutes(app: FastifyInstance) {
app.post('/register', async (req, reply) => {
const body = RegisterBody.parse(req.body);
@@ -54,13 +112,7 @@ export async function authRoutes(app: FastifyInstance) {
name: body.name,
phone: body.phone,
provider: 'EMAIL',
profile: {
create: {
age: body.age,
gender: body.gender,
job: body.job ?? '기타',
},
},
profile: { create: { age: body.age, gender: body.gender, job: body.job ?? '기타' } },
},
include: { profile: true },
});
@@ -86,46 +138,18 @@ export async function authRoutes(app: FastifyInstance) {
app.post('/kakao', async (req, reply) => {
const body = KakaoBody.parse(req.body);
let me: KakaoMe;
return processKakaoLogin(app, reply, body.accessToken);
});
app.post('/kakao/code', async (req, reply) => {
const body = KakaoCodeBody.parse(req.body);
let accessToken: string;
try {
me = await fetchKakaoProfile(body.accessToken);
} catch (e) {
return reply.code(401).send({ message: '카카오 인증 실패' });
accessToken = await exchangeKakaoCode(body.code, body.redirectUri);
} catch (e: any) {
return reply.code(401).send({ message: `카카오 토큰 교환 실패: ${e?.message ?? ''}` });
}
const kakaoId = String(me.id);
const email = me.kakao_account?.email;
const name = me.kakao_account?.profile?.nickname ?? me.properties?.nickname ?? '카카오사용자';
const profileImage = me.kakao_account?.profile?.profile_image_url ?? me.properties?.profile_image;
let user = await app.prisma.user.findUnique({
where: { kakaoId },
include: { profile: true },
});
if (!user) {
user = await app.prisma.user.create({
data: {
kakaoId,
email: email ?? null,
name,
phone: me.kakao_account?.phone_number,
provider: 'KAKAO',
profileImage,
profile: {
create: {
age: 30,
gender: 'MALE',
job: '기타',
},
},
},
include: { profile: true },
});
}
const token = await reply.jwtSign({ sub: user.id, email: user.email ?? undefined });
return { token, user: publicUser(user) };
return processKakaoLogin(app, reply, accessToken);
});
app.get('/me', { onRequest: [app.authenticate] }, async (req) => {