|
|
@@ -0,0 +1,112 @@
|
|
|
+import { NextRequest, NextResponse } from 'next/server';
|
|
|
+import { LoginResponse } from '@/types/response/auth';
|
|
|
+import { fetchJson } from '@/lib/utils/server';
|
|
|
+import { isSocialLoginProvider, SOCIAL_PROVIDER_LABELS, OAUTH_STATE_COOKIE_PREFIX } from '@/constants/social';
|
|
|
+
|
|
|
+/**
|
|
|
+ * 소셜 로그인 콜백 라우트 (Naver/Kakao) — /login/{provider}/callback
|
|
|
+ *
|
|
|
+ * 1. ?code&state(&error) 수신 → start 라우트가 심은 httpOnly state 쿠키와 대조 (CSRF 방어)
|
|
|
+ * 2. 백엔드 POST /api/auth/social-login (code 교환 + userinfo + 계정 매칭)
|
|
|
+ * 3. 성공: accessToken/refreshToken httpOnly 쿠키(기존 google/callback 옵션 동일) → /login/{provider}/complete
|
|
|
+ * 4. Auth.EmailRequired / Auth.AccountExists: 안내 화면(/login/social-notice)으로 분기
|
|
|
+ * 5. 기타 실패: /login?error={message} (로그인 페이지 alert 관례)
|
|
|
+ */
|
|
|
+export async function GET(request: NextRequest, { params }: { params: Promise<{ provider: string }> })
|
|
|
+{
|
|
|
+ const { provider } = await params;
|
|
|
+
|
|
|
+ if (!isSocialLoginProvider(provider)) {
|
|
|
+ const url = new URL('/login', request.url);
|
|
|
+ url.searchParams.set('error', '지원하지 않는 소셜 로그인입니다.');
|
|
|
+ return NextResponse.redirect(url);
|
|
|
+ }
|
|
|
+
|
|
|
+ const label = SOCIAL_PROVIDER_LABELS[provider];
|
|
|
+ const stateCookieName = `${OAUTH_STATE_COOKIE_PREFIX}${provider}`;
|
|
|
+ const savedState = request.cookies.get(stateCookieName)?.value ?? null;
|
|
|
+
|
|
|
+ // state 쿠키는 일회용 — 성공/실패와 무관하게 응답에서 제거
|
|
|
+ const finish = (response: NextResponse) => {
|
|
|
+ response.cookies.delete(stateCookieName);
|
|
|
+ return response;
|
|
|
+ };
|
|
|
+
|
|
|
+ const failRedirect = (message: string) => {
|
|
|
+ const url = new URL('/login', request.url);
|
|
|
+ url.searchParams.set('error', message);
|
|
|
+ return finish(NextResponse.redirect(url));
|
|
|
+ };
|
|
|
+
|
|
|
+ const noticeRedirect = (reason: 'email-required'|'account-exists') => {
|
|
|
+ const url = new URL('/login/social-notice', request.url);
|
|
|
+ url.searchParams.set('reason', reason);
|
|
|
+ url.searchParams.set('provider', provider);
|
|
|
+ return finish(NextResponse.redirect(url));
|
|
|
+ };
|
|
|
+
|
|
|
+ const searchParams = request.nextUrl.searchParams;
|
|
|
+ const code = searchParams.get('code');
|
|
|
+ const state = searchParams.get('state');
|
|
|
+ const oauthError = searchParams.get('error');
|
|
|
+ const oauthErrorDescription = searchParams.get('error_description');
|
|
|
+
|
|
|
+ // 사용자가 동의 화면에서 취소한 경우 등 provider 측 에러
|
|
|
+ if (oauthError) {
|
|
|
+ if (oauthError === 'access_denied') {
|
|
|
+ return failRedirect(`${label} 로그인이 취소되었습니다.`);
|
|
|
+ }
|
|
|
+ return failRedirect(oauthErrorDescription || `${label} 인증에 실패했습니다. 다시 시도해주세요.`);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!code) {
|
|
|
+ return failRedirect(`${label} 인증 코드가 없습니다. 다시 시도해주세요.`);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!state || !savedState || state !== savedState) {
|
|
|
+ return failRedirect('로그인 요청 검증(state)에 실패했습니다. 다시 시도해주세요.');
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ const redirectUri = `${request.nextUrl.origin}/login/${provider}/callback`;
|
|
|
+
|
|
|
+ const res = await fetchJson<LoginResponse>('/api/auth/social-login', {
|
|
|
+ method: 'POST',
|
|
|
+ body: JSON.stringify({
|
|
|
+ Provider: provider,
|
|
|
+ Code: code,
|
|
|
+ State: state,
|
|
|
+ RedirectUri: redirectUri
|
|
|
+ })
|
|
|
+ });
|
|
|
+
|
|
|
+ if (res.success && res.data) {
|
|
|
+ const response = NextResponse.redirect(new URL(`/login/${provider}/complete`, request.url));
|
|
|
+ const cookieOptions = { httpOnly: true, path: '/' };
|
|
|
+ response.cookies.set('accessToken', res.data.accessToken, cookieOptions);
|
|
|
+ response.cookies.set('refreshToken', res.data.refreshToken, cookieOptions);
|
|
|
+ return finish(response);
|
|
|
+ }
|
|
|
+
|
|
|
+ const errorCode = res.errors?.[0]?.code;
|
|
|
+
|
|
|
+ // 카카오 이메일 선택동의 미동의 → 이메일 제공 동의 안내 화면
|
|
|
+ if (errorCode === 'Auth.EmailRequired') {
|
|
|
+ return noticeRedirect('email-required');
|
|
|
+ }
|
|
|
+
|
|
|
+ // 같은 이메일의 기존 계정 존재 (자동연결 금지) → 기존 계정 로그인 + SNS 연동 안내 화면
|
|
|
+ if (errorCode === 'Auth.AccountExists') {
|
|
|
+ return noticeRedirect('account-exists');
|
|
|
+ }
|
|
|
+
|
|
|
+ if (errorCode === 'Auth.SocialNotConfigured') {
|
|
|
+ return failRedirect(`${label} 로그인이 아직 준비되지 않았습니다. 잠시 후 다시 시도해주세요.`);
|
|
|
+ }
|
|
|
+
|
|
|
+ return failRedirect(res.errors?.[0]?.description || res.message || `${label} 로그인에 실패했습니다. 다시 시도해주세요.`);
|
|
|
+ } catch (e) {
|
|
|
+ console.error(`[${provider} Callback] Error:`, e);
|
|
|
+ return failRedirect(e instanceof Error ? e.message : `${label} 로그인 처리 중 오류가 발생했습니다.`);
|
|
|
+ }
|
|
|
+}
|