OAuth 2.0 시스템 프론트엔드 개발기 - 2. 컴포넌트 라이브러리 제작과 배포

김채은·2024년 5월 30일
1
post-thumbnail

들어가며

약 세 달 전부터 개발했던 OAuth 2.0 시스템 라이브러리를 배포해서 서비스하게 됐다. 실제로 단국대학교 총학생회와 제휴를 통해 진행한 Text Me! 이벤트 버전에서 해당 라이브러리를 적용했고, 결과적으로 회원수가 기존의 일반 회원가입 대비 46.5% 증가했다. OAuth 로그인 방식이 회원가입의 진입장벽을 크게 낮췄다.

구현 목표

  • PKCE를 적용한 OAuth 2.0 시스템 구현 ✅
  • 총학생회 로그인을 편리하게 활용할 수 있도록 리액트 컴포넌트 라이브러리 배포 ✅

개발 환경 세팅

디렉터리 구조

📦 src
 ┣ 📂 constants
 ┃ ┣ 📜 key.ts
 ┃ ┗ 📜 path.ts
 ┣ 📂 core
 ┃ ┗ 📜 pkce.ts
 ┣ 📂 utils
 ┃ ┣ 📜 api.ts
 ┃ ┣ 📜 store.ts
 ┃ ┗ 📜 style.ts
 ┣ 📜 index.tsx
 ┗ 📜 types.ts
📜 pacakage.json
📜 rollup.config.js
📜 tsconfig.json
📜 .npmignore

번들러 세팅

컴포넌트는 index.tsx에 정의하고, Rollup으로 번들링했다.

input에 index.tsx를, output에 내보내고 싶은 파일 이름과 포맷을 정해주고, 플러그인을 세팅한다. package.json 스크립트를 작성하여 rollup -c를 실행하면 dist 디렉터리에 빌드 파일이 생성된다.

  • rollup.config.js
import babel from "@rollup/plugin-babel";
import typescript from "@rollup/plugin-typescript";
import resolve from "@rollup/plugin-node-resolve";
import commonjs from "rollup-plugin-commonjs";
import peerDepsExternal from "rollup-plugin-peer-deps-external";
import { dts } from "rollup-plugin-dts";
export default [
  {
    input: "./src/index.tsx",
    output: [
      {
        file: "dist/index.cjs",
        format: "cjs",
        sourcemap: true,
      },
      {
        file: "dist/index.mjs",
        format: "esm",
        sourcemap: true,
      },
    ],
    plugins: [
      babel({
        babelHelpers: "bundled",
        presets: [
          "@babel/preset-env",
          "@babel/preset-react",
          "@babel/preset-typescript",
        ],
        extensions: [".js", ".jsx", ".ts", ".tsx"],
      }),
      typescript(),
      resolve(),
      commonjs(),
      peerDepsExternal(),
    ],
    external: ["react", "crypto-js"],
  },
  {
    input: "./src/index.tsx",
    output: [{ file: "dist/index.d.ts", format: "cjs" }],
    plugins: [dts()],
  },
];

NPM 배포 세팅

npm login으로 사용자 정보를 입력하고 package.json을 세팅한 뒤 npm publish를 하면 자동으로 NPM에 패키지가 배포된다.

  • package.json
{
  "name": "dankook-student-council-login",
  "version": "1.0.7",
  "repository": {
    "type": "git",
    "url": "git://github.com/chchaeun/dankook-student-council-login.git"
  },
  "author": "Kim Chae Eun <85024598+chchaeun@users.noreply.github.com>",
  "license": "MIT",
  "main": "dist/index.cjs",
  "module": "dist/index.mjs",
  "browser": "dist/index.cjs",
  "types": "dist/index.d.ts",
  "private": false,
  "type": "module",
  "description": "단국대학교 총학생회 로그인 모듈",
  "keywords": [
    "dankook",
    "login",
    "oauth"
  ],
}

구현

  1. 로그인 버튼을 누르면 codeVerifier를 생성하고, localStorage에 저장한다. 생성한 codeVerifier를 통해 codeChallenge를 생성하여 /authorize로 요청하면 Redirect Url을 반환하고, 해당 url로 이동한다.

자세한 플로우는 이전 글을 참고하는 게 좋다.

  const onClick = () => {
    const codeVerifier = getCodeVerifier();

    store(KEY.CODE_VERIFIER).set(codeVerifier);

    api()
      .get(PATH.AUTHORIZE, {
        codeChallenge: getCodeChallenge(codeVerifier),
        clientId,
        redirectUri,
        responseType: "code",
        codeChallengeMethod: "S256",
      })
      .then((res) => {
        const { redirectUri } = res;
        if (redirectUri) {
          window.location.href = redirectUri;
          return;
        }
      })
      .catch((err) => {
        onError && onError(err);
      });
  };

   return
	<button
      type="button"
      onClick={onClick}
      style={{ ...DEFAULT_STYLE, ...style }}
    >
      단국대학교 총학생회 로그인
    </button>
  1. OAuth용 로그인 페이지로 가서 로그인한다.

2-1. 총학생회를 통해 가입돼있지 않은 회원인 경우 약관 동의를 거친다.

  1. 로그인에 성공하면 authCode와 함께 원래의 페이지로 리다이렉트한다. url 파라미터를 파싱해서 authCode가 존재하면 onSuccess를 실행한다. 여기서 사용자가 /token로 요청하여 토큰을 발급하는 코드를 작성하게 된다.

    토큰 발급 부분은 프론트엔드에서 요청하는 것과 백엔드에서 요청하는 것으로 두 가지 플로우를 사용할 수 있어서 남겨뒀는데, 조금 더 코드를 단순화하여 token 발급까지 라이브러리에서 자동화해주는 기능을 추가하면 좋을 것 같다.

  useEffect(() => {
    const authCode = new URL(window.location.href).searchParams.get(
      KEY.AUTH_CODE_PARAM
    );

    authCode &&
      onSuccess({ authCode, codeVerifier: store(KEY.CODE_VERIFIER).get() });

    store(KEY.CODE_VERIFIER).delete();
  }, [window.location.href]);

겪었던 문제

1. 302 Found Redirect에서의 CORS 에러

/authorize/login 등 리다이렉트가 필요한 경우 서버에서 302 코드를 응답해서 처리하려고 했다.

하지만 계속해서 CORS 오류가 발생했고, Access-Control-Allow-Origin 헤더를 넣어도 문제가 해결되지 않았다. MDN 문서를 보면 외부로 인한 리다이렉트의 경우 CORS 에러를 발생시킨다고 명시되어있다. 불법적인 사이트로의 리다이렉트 등을 방지하기 위함이라고 생각된다.

API 응답 코드를 200으로 변경하고 response body로 url을 받아서 프론트엔드에서 해당 url로 이동하는 방식으로 변경했다. 추가적인 코드는 발생하지만, 보안적으로 우수한 방식이라고 생각한다.

api()
  .get(PATH.AUTHORIZE, {...})
  .then((res) => {
    const { redirectUri } = res;
    if (redirectUri) {
      window.location.href = redirectUri;
      return;
    }
  })
  .catch((err) => {
  	onError && onError(err);
  });

2. Base64 인코딩 URL 사용 문제

PKCE 적용을 위한 code verifier와 code challenge는, 랜덤 문자열을 Base64 인코딩한 것이다. 이를 백엔드로 보내서 일치 검증을 하는데, get의 parameter로 전달하다보니 URL에서 사용되는 문자열(/, +, =)이 포함되어 이상하게 변경됐다.
따라서 Base64Url 인코딩 방식을 사용해서 URL safe한 문자들만 사용하도록 code verifier와 code challenge를 만들었고, 검증에 성공했다.
crypto-js 라이브러리를 사용하여 쉽게 교체할 수 있었다.

import CryptoJS from "crypto-js";

const getCodeVerifier = () => {
  return CryptoJS.lib.WordArray.random(32).toString(
    CryptoJS.enc.Base64url
  ) as string;
};
const getCodeChallenge = (codeVerifier: string) => {
  return CryptoJS.SHA256(codeVerifier).toString(
    CryptoJS.enc.Base64url
  ) as string;
};
export { getCodeVerifier, getCodeChallenge };

마치며

라이브러리를 만들어본 적이 한번도 없었는데 이런 기회가 생겨서 즐겁게 개발했다. 어려운 부분도 있었고, 해결이 안돼서 답답한 부분도 있었지만 결국에 해결책을 찾아서 배포까지하고, 진짜 이 기능을 사용하는 회원들이 생겨서 뿌듯했다. 또 이렇게 모인 회원들을 어떻게 꾸준히 접속시킬 수 있을지 생각하며 지속 가능한 서비스를 만들고 싶다는 고민을 해보게 됐다.

그리고 이번에 사내 프로젝트를 하며 라이브러리를 배포하여 타 본부와 협업할 일이 생겼는데, 팀원 중 라이브러리 개발과 배포 경험을 가진 사람이 나뿐이어서 내가 담당자가 되었다. 사이드 프로젝트를 통해 얻은 기술로 회사에 기여할 수 있는 좋은 경험이 될 것 같아 기쁘다.

profile
배워서 남주는 개발자 김채은입니다 ( •̀ .̫ •́ )✧

0개의 댓글