본문 바로가기

React

React 앱에 OAuth 2.0 로그인 기능 추가하기 (Authorization Code + PKCE)

이 글은 React SPA에 OAuth 2.0 Authorization Code with PKCE 방식으로 로그인 기능을 추가하는 실무 가이입니다. 공급자 벤더에 종속되지 않고 동작하는 최소 구현을 제시하며, 보안 및 운영 팁까지 간결하게 정리합니다.

1. 왜 Authorization Code + PKCE 인가

SPA에서 암호화된 클라이언트 비밀을 보관할 수 없기 때문에 PKCE를 적용한 Authorization Code 플로우가 권장됩니다. 흐름은 다음과 같습니다. 사용자가 로그인 버튼을 클릭합니다. 앱이 code_challenge를 생성하고 권한 서버로 리다이렉트합니다. 로그인 완료 후 콜백에서 code를 받고, code_verifier로 토큰을 교환합니다. 토큰으로 사용자 정보 API를 호출합니다.

2. 사전 준비

OAuth 공급자(예: Google, GitHub, Auth0, 자체 Authorization Server)에서 클라이언트를 등록합니다. Redirect URI를 http://localhost:5173/callback 같은 값으로 등록합니다. 다음 정보를 확보합니다. AUTHORIZATION_ENDPOINT, TOKEN_ENDPOINT, USERINFO_ENDPOINT(OIDC 사용 시), CLIENT_ID, REDIRECT_URI, SCOPE(openid profile email 등).

3. 환경 변수 설정 예시 (Vite 기준)

// .env.local
VITE_OAUTH_AUTHORIZATION_ENDPOINT=https://example.com/oauth2/authorize
VITE_OAUTH_TOKEN_ENDPOINT=https://example.com/oauth2/token
VITE_OAUTH_USERINFO_ENDPOINT=https://example.com/oauth2/userinfo
VITE_OAUTH_CLIENT_ID=YOUR_CLIENT_ID
VITE_OAUTH_REDIRECT_URI=http://localhost:5173/callback
VITE_OAUTH_SCOPE=openid profile email

4. PKCE 유틸 함수

// src/oauth/pkce.js
const base64UrlEncode = (arrayBuffer) => {
  const bytes = new Uint8Array(arrayBuffer);
  let binary = "";
  for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
};

export const randomString = (length = 64) => {
  const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
  const bytes = new Uint8Array(length);
  crypto.getRandomValues(bytes);
  return Array.from(bytes, (b) => charset[b % charset.length]).join('');
};

export const createCodeVerifier = () => randomString(64);

export const createCodeChallenge = async (verifier) => {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const digest = await crypto.subtle.digest('SHA-256', data);
  return base64UrlEncode(digest);
};

5. 로그인 요청 URL 만들기 및 리다이렉트

// src/oauth/login.js
import { createCodeVerifier, createCodeChallenge, randomString } from './pkce';

const AUTHORIZATION_ENDPOINT = import.meta.env.VITE_OAUTH_AUTHORIZATION_ENDPOINT;
const CLIENT_ID = import.meta.env.VITE_OAUTH_CLIENT_ID;
const REDIRECT_URI = import.meta.env.VITE_OAUTH_REDIRECT_URI;
const SCOPE = import.meta.env.VITE_OAUTH_SCOPE;

export const login = async () => {
  const state = randomString(32);
  const verifier = createCodeVerifier();
  const challenge = await createCodeChallenge(verifier);

  sessionStorage.setItem('oauth_state', state);
  sessionStorage.setItem('pkce_verifier', verifier);

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    scope: SCOPE,
    code_challenge: challenge,
    code_challenge_method: 'S256',
    state,
  });

  const url = `${AUTHORIZATION_ENDPOINT}?${params.toString()}`;
  window.location.assign(url);
};

6. 콜백에서 코드 교환(Token Exchange)

일부 공급자는 브라우저에서 토큰 엔드포인트 호출을 CORS로 막습니다. 이 경우 BFF(백엔드 프록시)가 필요합니다. 아래 예시는 CORS가 허용된 환경 또는 샌드박스에서의 데모용입니다.

// src/oauth/callback.js
const TOKEN_ENDPOINT = import.meta.env.VITE_OAUTH_TOKEN_ENDPOINT;
const CLIENT_ID = import.meta.env.VITE_OAUTH_CLIENT_ID;
const REDIRECT_URI = import.meta.env.VITE_OAUTH_REDIRECT_URI;

export const exchangeCodeForToken = async (code) => {
  const verifier = sessionStorage.getItem('pkce_verifier');
  const body = new URLSearchParams({
    grant_type: 'authorization_code',
    code,
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    code_verifier: verifier || '',
  });

  const res = await fetch(TOKEN_ENDPOINT, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body,
  });

  if (!res.ok) throw new Error('Token endpoint error');
  return res.json(); // { access_token, id_token?, refresh_token?, expires_in, token_type }
};

7. AuthContext로 토큰 관리

데모에서는 sessionStorage에 저장합니다. 실제 운영에서는 가능한 한 메모리 저장 또는 BFF로 HttpOnly 쿠키를 사용합니다.

// src/auth/AuthContext.jsx
import React, { createContext, useContext, useEffect, useState } from 'react';

const AuthContext = createContext(null);

export const AuthProvider = ({ children }) => {
  const [accessToken, setAccessToken] = useState(() => sessionStorage.getItem('access_token'));
  const [user, setUser] = useState(() => {
    const raw = sessionStorage.getItem('user');
    return raw ? JSON.parse(raw) : null;
  });

  const loginWithTokens = (tokens) => {
    if (tokens.access_token) {
      setAccessToken(tokens.access_token);
      sessionStorage.setItem('access_token', tokens.access_token);
    }
  };

  const logout = () => {
    setAccessToken(null);
    setUser(null);
    sessionStorage.removeItem('access_token');
    sessionStorage.removeItem('user');
    sessionStorage.removeItem('oauth_state');
    sessionStorage.removeItem('pkce_verifier');
  };

  return (
    <AuthContext.Provider value={{ accessToken, user, setUser, loginWithTokens, logout }}>
      {children}
    </AuthContext.Provider>
  );
};

export const useAuth = () => useContext(AuthContext);

8. 콜백 페이지 컴포넌트

// src/pages/Callback.jsx
import React, { useEffect } from 'react';
import { exchangeCodeForToken } from '../oauth/callback';
import { useAuth } from '../auth/AuthContext';

export default function Callback() {
  const { loginWithTokens } = useAuth();

  useEffect(() => {
    const run = async () => {
      const params = new URLSearchParams(window.location.search);
      const code = params.get('code');
      const state = params.get('state');
      const savedState = sessionStorage.getItem('oauth_state');

      if (!code) {
        console.error('Missing code');
        return;
      }
      if (!state || state !== savedState) {
        console.error('Invalid state');
        return;
      }

      try {
        const tokens = await exchangeCodeForToken(code);
        loginWithTokens(tokens);
        window.history.replaceState({}, '', '/');
      } catch (e) {
        console.error(e);
      }
    };
    run();
  }, [loginWithTokens]);

  return <p>로그인 처리 중입니다...</p>;
}

9. 보호 라우트와 라우터 구성

// src/router/ProtectedRoute.jsx
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '../auth/AuthContext';

export default function ProtectedRoute({ children }) {
  const { accessToken } = useAuth();
  if (!accessToken) return <Navigate to="/login" replace />;
  return children;
}
// src/main.jsx (React Router 예시)
import React from 'react';
import ReactDOM from 'react-dom/client';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { AuthProvider } from './auth/AuthContext';
import Callback from './pages/Callback';
import App from './App';
import Login from './pages/Login';
import ProtectedRoute from './router/ProtectedRoute';

const router = createBrowserRouter([
  { path: '/', element: <ProtectedRoute><App /></ProtectedRoute> },
  { path: '/login', element: <Login /> },
  { path: '/callback', element: <Callback /> },
]);

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <AuthProvider>
      <RouterProvider router={router} />
    </AuthProvider>
  </React.StrictMode>
);

10. 로그인/로그아웃 및 사용자 정보 불러오기

OIDC를 사용한다면 userinfo 엔드포인트로 프로필을 가져옵니다.

// src/pages/Login.jsx
import React from 'react';
import { login } from '../oauth/login';

export default function Login() {
  return (
    <div>
      <h2>로그인</h2>
      <button onClick={login}>OAuth로 로그인</button>
    </div>
  );
}
// src/App.jsx
import React, { useEffect } from 'react';
import { useAuth } from './auth/AuthContext';

const USERINFO_ENDPOINT = import.meta.env.VITE_OAUTH_USERINFO_ENDPOINT;

export default function App() {
  const { accessToken, user, setUser, logout } = useAuth();

  useEffect(() => {
    const fetchUser = async () => {
      if (!accessToken || !USERINFO_ENDPOINT) return;
      const res = await fetch(USERINFO_ENDPOINT, {
        headers: { Authorization: `Bearer ${accessToken}` },
      });
      if (res.ok) {
        const data = await res.json();
        setUser(data);
        sessionStorage.setItem('user', JSON.stringify(data));
      }
    };
    fetchUser();
  }, [accessToken, setUser]);

  return (
    <div>
      <h1>대시보드</h1>
      {user ? (
        <div>
          <p>안녕하세요, {user.name || user.preferred_username || '사용자'} 님</p>
          <button onClick={logout}>로그아웃</button>
        </div>
      ) : (
        <p>프로필 로딩 중...</p>
      )}
    </div>
  );
}

11. CORS와 BFF 백엔드 사용 권장

많은 공급자들은 보안을 위해 토큰 엔드포인트에 브라우저 접근을 허용하지 않습니다. 운영 환경에서는 BFF를 두고, 브라우저는 BFF에만 요청하도록 설계합니다. BFF는 Authorization Server와 통신하고, 세션은 HttpOnly, Secure, SameSite=Lax 또는 Strict 쿠키로 관리합니다. 이 구조는 XSS에 의한 토큰 탈취 가능성을 줄여줍니다.

12. 라이브러리 대안 (간단 소개)

OIDC 기반이면 oidc-client-ts, react-oidc-context, Auth0 React SDK, @azure/msal-react 같은 검증된 SDK 사용을 고려하십시오. 예를 들어 Auth0 React SDK는 라우트 보호와 토큰 갱신을 쉽게 제공합니다. 공급자에 따라 공식 SDK 사용이 유지보수에 유리합니다.

13. 보안 및 운영 체크리스트

토큰은 가능하면 메모리에만 보관하고 로컬스토리지 사용을 피합니다. XSS를 막기 위해 콘텐츠 보안 정책(CSP)과 엄격한 린팅을 적용합니다. Redirect URI를 정확히 등록하고 와일드카드를 피합니다. state와 PKCE 검증을 반드시 수행합니다. Refresh Token 회수를 허용한다면 회전(refresh token rotation)을 활성화합니다. HTTPS를 기본으로 사용하고 쿠키에는 Secure 플래그를 설정합니다. 로그에는 토큰을 남기지 않습니다.

위 예제를 기반으로 최소 구현을 빠르게 붙인 뒤, 실제 서비스에서는 BFF와 검증된 SDK를 조합해 보안과 유지보수성을 확보하시기 바랍니다.