본문 바로가기
FrontEnd/Next.js

인가 서버에서 token 받아오기 (OAuth) feat. NextJs

by 위그든씨 2024. 6. 10.
 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

  • 카카오 로그인 문서에 따르면 oauth의 로직은 아래 이미지와 같다

프로젝트 구조

  • 메인 페이지(Home)에 있는 카카오 로그인 버튼을 누르면 아래의 함수가 실행된다.
const HomePage = () => {
    const handleKakaoLogin = () => {
		    //리다이렉트 uri는 https://developers.kakao.com/console/app 에서 설정하면 됨. 이때 client Id도 발급 됨
        const kakaoAuthUrl = `https://kauth.kakao.com/oauth/authorize?response_type=code&client_id=${process.env.NEXT_PUBLIC_KAKAO_CLIENT_ID}&redirect_uri=${process.env.NEXT_PUBLIC_KAKAO_REDIRECT_URI}&prompt=login`;
        window.location.href = kakaoAuthUrl;
    };
    return (
        <div className="full center flex-col  mx-auto space-y-4">
            <Credential />
            <div className="w-full h-[0.5px] bg-slate-400" />
            <button
                onClick={handleKakaoLogin}
                className="w-full px-10 py-3 bg-yellow-400 rounded-xl text-white"
            >
                Kakao
            </button>
        </div>
    );
};

 

  • 본인은 redirect uri를 http://localhost:3000/auth/callback/kakao 로 설정해둠.
  • 이렇게 코드를 짜면 로그인 버튼을 클릭시 카카오 로그인 페이지가 뜰것이고 로그인이 성공한다면 인가 서버에서 code(인가코드)를 넘겨줄 것이고 리다이렉트 uri로 페이지는 이동된다.
  • app 폴더에 /auth/callback/kakao/page.tsx를 만들어줌. 
  • 해당 페이지에서는 카카오에서 받아온 code(인가 코드)를 api 서버에 넘겨준 뒤 해당 서버에서는 kakao 인가 서버에 코드를 넘겨줌으로써 accessToken과 refreshToken을 받아올것이다.(이 때 유효 기간도 함께 받아옴)
// app/auth/callback/kakao/page.tsx
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import React, { useEffect } from 'react';

const OAuthPage = () => {
    const searchParams = useSearchParams();
    const router = useRouter();

    useEffect(() => {
        (async () => {
            const code = searchParams.get('code');
            if (code) {
                try {
                    const res = await fetch('/api/auth/callback/kakao', {
                        method: 'POST',
                        body: JSON.stringify({ code }),
                    });
                    if (res.ok) {
                    	// 본인은 accessToken을 서버단에서 jwt화 시킴
                        const { jwt, refesh_token } = await res.json();
                        console.log(jwt, refesh_token);

                        router.push('/');
                    }
                } catch (error) {
                    console.log(error);
                }
            }
        })();
    }, [searchParams, router]);
    return (
        <div className="h-full w-full flex justify-center items-center ">
            Loading
        </div>
    );
};

export default OAuthPage;
  • 인가 코드를 통해 카카오 서버에서는 액세스 토큰과 리프레쉬 토큰을 발급해 줄 것인데 이제 이 토큰을 통해서 유저의 정보들을 받아올 수 있다.
    • 참고로 'Content-Type'이 'application/x-www-form-urlencoded', 일 경우에는  바디에 JSON 형태가 아닌 URLSearchParams 형태로 담아서 보내줘야함
    • accessToken은 JWT 페이로드에 담아서 클라이언트 쪽으로 보내고 , refreshToken은 HttpOnly 쿠키에 담아서 응답해준다.    (ContextAPI를 이용해서 jwt를 메모리에 담고, 리프레쉬 토큰은 쿠키에 담아서 안전하게 보내주기 위함)
      • ---> 정정사항: 카카오에서 Bearer Token 형식으로 액세스를 보내주기 때문에 굳이 또 jwt화 시킬 필요 없음(2024.08.08)
// app/api/auth/callback/kakao/route.ts

import { NextRequest, NextResponse } from 'next/server';
import jwt from 'jsonwebtoken';
import { calExpireDate } from '@/utils/cal-date';

export async function POST(req: NextRequest) {
    try {
        const body = await req.json();

        const { code } = body;
        if (!code) {
            return new NextResponse('Code is missing', { status: 402 });
        }

        const tokenResponse = await fetch(
            'https://kauth.kakao.com/oauth/token',
            {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                },
                body: new URLSearchParams({
                    grant_type: 'authorization_code',
                    client_id: process.env.NEXT_PUBLIC_KAKAO_CLIENT_ID!,
                    redirect_uri: process.env.NEXT_PUBLIC_KAKAO_REDIRECT_URI!,
                    client_secret: process.env.NEXT_PUBLIC_KAKAO_CLIENT_SECRET!,
                    code,
                }),
            }
        );

        const tokenData = await tokenResponse.json();

        const payload = {
            accessToken: tokenData.access_token,
        };
        // jwt의 유효기간을 받아온 액세스 토큰의 유효기간과 동일시 해준다. 초 단위 값을 넣으면 자동으로 현재 시간으로부터 x초가 지난 시간이 exp에 담겨짐
        const jwtToken = jwt.sign(payload, process.env.SECRET_KEY!, {
            algorithm: 'HS256',
            expiresIn: tokenData.expires_in,
        });

        const response = NextResponse.json({
            jwt: jwtToken,
        });

        response.cookies.set('refresh_token', tokenData.refresh_token, {
            httpOnly: true,
            secure: process.env.NODE_ENV === 'production',
            expires: calExpireDate(tokenData.refresh_token_expires_in),
            path: '/',
        });

        return response;
    } catch (error) {
        console.log(error);
        return new NextResponse('Internal Server Error', {
            status: 500,
            statusText: 'Internal Server Error',
        });
    }
}

 

  • 클라이언트 쪽에서 액세스 토큰을 활용하여 유저에 대한 정보를 요청하기 위해서는 jwt 에 대한 검증이 필요
'use server';
import jwt, { JwtPayload } from 'jsonwebtoken';
import getUserInfo from './get-userInfo';

export const verifyJwt = (jwtToken: string) => {
    try {
        jwt.verify(jwtToken, process.env.SECRET_KEY!, async (err, decoded) => {
            if (err) throw new Error('Failed to verify JWT');

            const curDate = new Date();
            if (!decoded || typeof decoded === 'string')
                throw new Error('Failed to decode JWT');
            const decodedPayload = decoded as JwtPayload;

            if (!decodedPayload.exp) {
                throw new Error('JWT does not contain an exp claim');
            }
            // 현재 시간에 대해 만료되었다면 액세스 재발급
            const expireDate = new Date(decodedPayload.exp * 1000);
            if (curDate.getTime() > expireDate.getTime()) {
                const renewToken = await refetchAccessToken()
            }

            const userData = await getUserInfo(decodedPayload.accessToken);
            console.log(await userData);
        });
    } catch (error) {
        console.log(error);
    }
};

//재발급 요청
const refetchAccessToken = async () => {
    try {
        const res = await fetch('/api/auth/callback/kakao/refetch', {
            method: 'POST',
        });
        if (res.ok) console.log(await res.json());
    } catch (error) {
        console.log(error);
    }
};

아래는 리프레쉬 토큰을 통해 액세스 토큰을 재발급하는 과정

https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#refresh-token

//  /api/auth/callback/kakao/refetch

import { NextRequest, NextResponse } from 'next/server';

export async function POST(req: NextRequest) {
    try {
        const refreshToken = req.cookies.get('refresh_token');
        if (!refreshToken) {
            return new NextResponse('No refresh token', { status: 401 });
        }

        const { value: refresh_token } = refreshToken;

        const body = new URLSearchParams({
            grant_type: 'refresh_token',
            client_id: process.env.NEXT_PUBLIC_KAKAO_CLIENT_ID!,
            client_secret: process.env.NEXT_PUBLIC_KAKAO_CLIENT_SECRET!,
            refresh_token,
        }).toString();

        const res = await fetch('https://kauth.kakao.com/oauth/token', {
            method: 'POST',
            headers: {
                'Content-Type':
                    'application/x-www-form-urlencoded;charset=utf-8',
            },
            body,
        });

        if (res.ok) {
            const data = await res.json();
            return NextResponse.json({ accessToken: data.access_token });
        }

        const errorData = await res.json();
        console.error('Failed to refresh token:', errorData);
        return new NextResponse('Failed to refresh token', { status: 401 });
    } catch (error) {
        console.error('Internal Server Error:', error);
        return new NextResponse('Internal Server Error', { status: 500 });
    }
}