개발팀장님이 구현한 OAuth 2.0 엿보기🐸

|

OAuth 2.0 Authorization Code + PKCE 정리

사내 user-service에 OAuth IdP 기능을 붙이는 PR을 구경하다가, 그동안 막연히 “카카오 로그인 같은 거”로만 알던 OAuth를 처음으로 제대로 뜯어봤다. 들여다보면서 막혔던 부분 위주로 정리.

먼저, 뭘 하려는 건가

우리 서비스(almondyoung)에 다른 서비스(다뷰) 가 붙어서, 다뷰 사용자가 “almondyoung으로 로그인” 버튼을 누르면 우리 계정으로 로그인되게 하는 것. 카카오 로그인에서 “카카오” 자리에 우리가 앉는 구조다. 우리 user-service가 IdP(Identity Provider) 역할을 한다.

일반 로그인과 뭐가 다른가

가장 먼저 잡고 가야 할 개념.

  일반 로그인 OAuth
주체 유저가 직접 로그인 다른 서비스가 유저 대신
왕복 1번 (POST /login) 최소 2번
보내는 것 ID/비밀번호 code, verifier, clientId…
목적 이 서비스에 로그인 다른 서비스에 내 계정으로 로그인

OAuth가 복잡해 보이는 이유는 “비밀번호를 제3자에게 절대 주지 않으면서 권한만 위임” 해야 하기 때문이다. 이 전제를 먼저 깔고 나면 나머지 설계가 납득된다.

등장 인물 3명

┌──────────┐ ┌──────────┐ ┌──────────────┐
│ 다뷰      │───▶auth-web│───▶user-service│
│ (외부)    │ │ (BFF)    │ │ (IdP)        │
└──────────┘ └──────────┘ └──────────────┘
  • 다뷰: 로그인을 붙이고 싶은 외부 서비스
  • auth-web: 우리 쪽 웹서버. 유저 브라우저를 받아주고 로그인 여부 체크
  • user-service: 계정/토큰의 최종 권한자

PKCE라는 자물쇠

OAuth 공부하면서 제일 헷갈렸던 부분. 천천히 정리.

문제 상황

OAuth 흐름 중에 code라는 일회용 티켓을 유저 브라우저 URL로 주고받는 구간이 있다.

https://daview.com/callback?code=abc123

브라우저 주소창에 찍히면 히스토리, 프록시 로그, 악성 확장프로그램 등 여러 경로로 새어나갈 수 있다. 이 code를 공격자가 훔쳐도 토큰은 못 받게 해야 한다. 이걸 위한 장치가 PKCE.

어떻게 막나

값 두 개를 만든다.

verifier = 랜덤 원본 문자열
challenge = SHA256(verifier)

해시 함수는 일방향이라 challenge로는 verifier를 역산할 수 없다.

누가 뭘 쥐고 있나

  verifier challenge
다뷰 서버 숨겨 보관 보냄
user-service 모름 DB에 저장
공격자 없음 URL에서 가로챔

공격자가 code를 훔치고, URL에 있던 challenge까지 본다 해도, verifier 원본은 다뷰 서버 안에만 있다. 나중에 토큰 교환 시 verifier 원본을 제시하라고 요구하는데, 공격자는 이걸 만들 수 없다.


전체 흐름

1차 왕복 — code 받기

  [다뷰]              [auth-web]           [user-service]
    │                      │                      │
    │  ?challenge=...      │                      │
    ├─────────────────────▶│                      │
    │                      │ 로그인 쿠키 확인         │
    │                      │                      │
    │                      │ POST /internal/      │
    │                      │      issue-code      │
    │                      ├─────────────────────▶│
    │                      │              ┌───────┴────────┐
    │                      │              │ DB 저장:        │
    │                      │              │  - code        │
    │                      │              │  - challenge   │
    │                      │              │  (TTL 60s)     │
    │                      │              └───────┬────────┘
    │                      │      { code }        │
    │                      │◀─────────────────────┤
    │  302 redirect        │                      │
    │  ?code=abc123        │                      │
    │◀─────────────────────┤                      │
    │                      │                      │
  • 다뷰가 challenge를 보냄 (해시로 전환된 값)
  • auth-web이 로그인 쿠키로 “이 유저 지금 로그인돼 있나” 확인
  • user-service가 code를 만들어 DB에 저장 (challenge와 묶어서)
  • 다뷰로 code를 리다이렉트

여기서 code가 URL에 노출된다. 그래서 TTL을 60초로 짧게 잡는다.

2차 왕복 — 토큰 받기

  [다뷰]                                    [user-service]
    │ POST /oauth/token                           │
    │ { code, verifier, clientId, secret }        │
    ├────────────────────────────────────────────▶│
    │                                        ┌────┴──────┐
    │                                        │ 검증 :     │
    │                                        │  code유효  │
    │                                        │  만료전     │
    │                                        │  PKCE:    │
    │                                        │   SHA(ver)│
    │                                        │   ==DB    │
    │                                        │   challenge│
    │                                        │ code소모    │
    │                                        │ 토큰발급     │
    │                                        └────┬──────┘
    │ { accessToken, refreshToken }               │
    │◀────────────────────────────────────────────│
  • 다뷰 서버가 직접 user-service를 호출 (브라우저 안 거침)
  • code와 함께 verifier 원본을 보냄
  • user-service는 verifier를 해시해서 DB의 challenge와 비교
  • 일치하면 토큰 발급

    왕복을 두 번 나누는 이유

    브라우저 경유          서버-서버 직통
    ───────────           ────────────
       [code]             [access/refresh]
    (PKCE로 방어)           (노출 불가)
  • code는 브라우저 URL을 거쳐야 하므로 유출 리스크가 있다 → PKCE로 방어
  • 토큰은 노출되면 바로 계정 탈취 → 무조건 서버-서버로만

이 두 레이어를 분리하는 게 OAuth 설계의 핵심 아이디어다.


code를 보호하는 3중 방어

방어 내용
TTL 60초 시간이 짧아서 훔쳐도 쓸 시간 없음
1회성 (consumedAt) 한 번 쓰면 DB에 소모 표시, 재사용 불가
PKCE verifier 없으면 교환 실패

셋 다 뚫어야 공격 성공. 실질적으로 불가능에 가깝다.

핵심 검증 코드

user-service의 토큰 교환 로직.

async exchangeCodeForToken(input: TokenRequestDto) {
  return this.inTx(async (tx) => {
    const row = await this.repo.findUnconsumedCode(input.code, tx);
    if (!row) throw new Error('invalid or already used code');
    if (row.expiresAt < new Date()) throw new Error('code expired');
    if (row.clientId !== input.clientId) throw new Error('client mismatch');
    if (row.redirectUri !== input.redirectUri) throw new Error('redirect_uri mismatch');

    // PKCE 검증
    const computed = crypto
      .createHash('sha256')
      .update(input.codeVerifier)
      .digest('base64url');
    if (computed !== row.codeChallenge) {
      throw new Error('PKCE verification failed');
    }

    await this.repo.markCodeConsumed(row.code, tx);
    return this.mintTokenPair(row.userId, row.clientId, row.scope, undefined, tx);
  });
}

검증 6개를 다 통과해야 토큰이 나간다. 그리고 code 소모 처리까지 하나의 트랜잭션으로 묶여 있다 — 동시 요청이 들어와도 한쪽만 성공하도록.


발급되는 토큰 두 종류

  ┌──────────────┬───────────────────┬──────────────────┬──────────┐
  │     종류     │       형태        │    저장 위치     │   수명         │
  ├──────────────┼───────────────────┼──────────────────┼──────────┤
  │ accessToken  │ JWT (서명된 토큰) │ 없음 (stateless) │ 15분          │
  ├──────────────┼───────────────────┼──────────────────┼──────────┤
  │ refreshToken │ 랜덤 문자열       │ DB               │ 2주~90일     │
  └──────────────┴───────────────────┴──────────────────┴──────────┘
  • accessToken: JWT라서 서명만 검증하면 되고, DB 조회가 필요 없다 (stateless). 대신 유출 시 만료까지 막을 방법이 없어서 수명을 짧게.
  • refreshToken: DB에 저장된 값이라 언제든 폐기 가능. 그래서 수명이 길어도 안전.

공부하고 나니 납득된 것들

  • OAuth가 “복잡해 보이는” 이유 : 비밀번호 노출 없이 권한을 위임하려다 보니 불가피하게 단계가 늘어난 것
  • code / token을 따로 두는 이유 : 브라우저 경유 vs 서버 직통을 분리하기 위함
  • PKCE가 왜 “열쇠와 자물쇠” 비유로 설명되는지 : verifier(열쇠)는 다뷰만, challenge(자물쇠)는 IdP만 가짐

그럼에도 아직은 부족한 부분

  • 개발팀장님이 작성하신 로직중에 이미 폐기된 refreshToken이 또 들어오면 “공격이다”로 판단하고 체인 전체를 BFS로 훑어서 다 폐기하는 로직이있는데 이해가 좀 부족함