728x90

GeekNews에 OAuth를 제대로 이해하고 쓰고 있는지 되묻는 팟캐스트가 올라왔다. 주제는 새롭지 않다. 소셜 로그인 붙여본 사람이면 다 아는 얘기다. 그런데 막상 "인가 코드를 왜 따로 받아요?", "PKCE는 SPA만 쓰는 거 아닌가요?" 물어보면 답이 갈린다. 나도 몇 년간 "라이브러리가 알아서 해주니까"로 넘겼던 구간이 있었고, 사내 SSO를 직접 붙이면서 리다이렉트 URI 검증 하나 때문에 반나절을 날린 뒤에야 스펙을 제대로 읽었다.

이 글은 그 삽질을 정리한 것이다. 프로토콜 교과서 요약이 아니라, 실제로 어디서 터지고 왜 터지는지에 초점을 맞춘다.

1. OAuth는 인증이 아니라 인가다 — 구성 요소부터 다시

가장 많이 하는 오해가 "OAuth 로그인"이라는 표현이다. OAuth 2.0은 인가(Authorization) 프레임워크다. "이 앱이 내 구글 드라이브 파일 목록을 읽어도 된다"를 위임하는 프로토콜이지, "이 사람이 누구인지"를 증명하는 프로토콜이 아니다. 사용자 신원 확인은 그 위에 얹은 OpenID Connect(OIDC)의 영역이고, 그때 나오는 게 ID Token(JWT)이다.

이 구분이 실무에서 왜 중요하냐면, 액세스 토큰으로 사용자를 식별하려다 사고가 나기 때문이다. 액세스 토큰은 "이 요청이 어떤 권한 범위를 가지냐"를 담을 뿐, 그 토큰이 어느 클라이언트에게 발급됐는지 검증하지 않고 신원 근거로 쓰면 다른 앱에서 받은 토큰을 그대로 던져서 남의 계정으로 들어가는 시나리오가 열린다. 이게 흔히 말하는 confused deputy 문제다. 신원이 필요하면 OIDC의 ID Token을 쓰고, aud(audience)가 내 클라이언트 ID인지 반드시 확인해야 한다.

구성 요소는 네 개다. 비유하자면 호텔이다.

  • Resource Owner (사용자) — 방 주인. 권한의 원천이다.
  • Client (앱) — 방 청소를 대행하러 온 업체. 주인의 허락이 있어야 들어간다.
  • Authorization Server (인가 서버) — 프런트 데스크. 주인 확인 후 카드키를 발급한다.
  • Resource Server (자원 서버) — 객실 문. 카드키만 확인하고 열어준다. 주인이 누군지는 모른다.

핵심은 자원 서버가 사용자 비밀번호를 절대 모른다는 점이다. 카드키(액세스 토큰)는 유효기간이 있고, 특정 층(scope)만 열리고, 분실해도 재발급으로 무효화된다. 비밀번호를 앱에 직접 주는 방식(그래서 Resource Owner Password Credentials Grant는 OAuth 2.1에서 제거됐다)과 비교하면 차이가 명확하다.

2. 왜 코드와 토큰을 두 번에 나눠 받나 — Front/Back Channel

Authorization Code Grant의 흐름을 압축하면 이렇다.

[1] 브라우저 → 인가 서버   GET /authorize?response_type=code
                          &client_id=my-app&redirect_uri=https://app.example.com/callback
                          &scope=openid profile&state=xyz
                          &code_challenge=E9Me...&code_challenge_method=S256
[2] 사용자 로그인 + 동의
[3] 인가 서버 → 브라우저   302 Location: https://app.example.com/callback?code=AUTH_CODE&state=xyz
[4] 앱 백엔드 → 인가 서버  POST /token  (code + client_secret + code_verifier)
[5] 인가 서버 → 앱 백엔드  { "access_token": "...", "refresh_token": "...", "expires_in": 3600 }

여기서 [1]~[3]이 Front Channel, [4]~[5]가 Back Channel이다. 이 구분이 전부라고 봐도 된다.

Front Channel은 브라우저 리다이렉트를 타고 흐른다. 즉 URL에 실린다. URL은 브라우저 히스토리에 남고, Referer 헤더로 새나가고, 프록시/WAF/CDN 접근 로그에 그대로 찍히고, 사용자가 주소창을 복사해서 슬랙에 붙일 수도 있다. 여기에 액세스 토큰을 실으면 그 토큰은 사실상 여러 군데에 복사본이 남는다.

그래서 Front Channel로는 일회용·단기 인가 코드만 흘린다. 코드는 그 자체로 아무 리소스에도 접근할 수 없다. 실제 토큰은 서버끼리 직접 통신하는 Back Channel(TLS로 보호되는 서버-투-서버 POST)에서만 오간다. 이 채널에서는 client_secret으로 클라이언트 신원까지 확인한다.

비유하자면 인가 코드는 "택배 수령 번호"다. 남이 봐도 번호만으로는 물건을 못 받는다. 신분증(client_secret)까지 있어야 창구에서 물건(토큰)을 준다. 반면 Implicit Grant는 프런트 채널에 물건 자체를 던지는 방식이었고, 그래서 OAuth 2.1에서 제거 대상이 됐다.

인가 코드에는 세 가지 방어가 걸린다. (1) 짧은 수명 — RFC 6749는 최대 10분을 권고하고 실제 구현체들은 보통 1분 내외로 잡는다. (2) 일회성 — 한 번 교환되면 즉시 폐기. (3) 재사용 감지 시 해당 코드로 발급된 토큰까지 함께 무효화. 3번을 구현 안 한 인가 서버가 은근히 있으니 자체 구현할 땐 꼭 챙겨야 한다.

redirect_uri 검증이 무너지면

공격자가 redirect_uri를 자기 서버로 바꿔치기할 수 있으면 위 설계가 통째로 무너진다. 코드가 공격자 서버로 배달되니까. 그래서 인가 서버는 사전 등록된 URI와 완전 일치(exact match)로 비교해야 한다. 부분 일치나 와일드카드를 허용하면 오픈 리다이렉트와 조합돼서 뚫린다.

실무에서 제일 자주 보는 에러가 이거다.

Error 400: redirect_uri_mismatch
The redirect URI in the request, http://localhost:3000/callback,
does not match the ones authorized for the OAuth client.

원인 대부분은 사소하다. 끝의 슬래시 유무(/callback vs /callback/), http/https 차이, 로컬 개발 시 localhost127.0.0.1 혼용, 그리고 ALB/Nginx 뒤에서 X-Forwarded-Proto를 안 넘겨줘서 앱이 스킴을 http로 조립하는 경우다. 마지막 케이스가 특히 악질인데, 로컬에선 되고 스테이징에 올리면 깨진다.

# Nginx 리버스 프록시 뒤 Spring Boot에서 흔한 원인
location / {
    proxy_pass http://app:8080;
    proxy_set_header Host              $host;
    proxy_set_header X-Forwarded-Proto $scheme;   # 이거 빠지면 http로 조립됨
    proxy_set_header X-Forwarded-Host  $host;
    proxy_set_header X-Forwarded-Port  $server_port;
}

Spring Boot라면 여기에 server.forward-headers-strategy=framework(또는 native)를 함께 켜야 실제로 반영된다. 프레임워크별 설정 키는 버전마다 다르니 각 공식 문서 확인 필요.

3. PKCE는 왜 모두의 필수가 되었나

PKCE(Proof Key for Code Exchange, RFC 7636)는 원래 모바일 앱을 위해 나왔다. 네이티브 앱은 client_secret을 안전하게 못 숨긴다(APK 뜯으면 나온다). 그리고 커스텀 스킴 리다이렉트(myapp://callback)는 같은 스킴을 등록한 악성 앱이 가로챌 수 있다. 코드를 탈취당하면 시크릿이 없어도 토큰 교환이 되어버린다.

PKCE의 아이디어는 단순하다. 클라이언트가 매 요청마다 랜덤 문자열 code_verifier를 만들고, 그 SHA-256 해시를 code_challenge로 인가 요청에 실어 보낸다. 토큰 교환 때 원본 verifier를 제시한다. 인가 서버는 해시가 맞는지 확인한다. 코드만 훔친 공격자는 verifier를 모르니 교환에 실패한다.

# PKCE 값 직접 만들어보기 (bash)
code_verifier=$(openssl rand -base64 60 | tr -d '\n=+/' | cut -c1-64)
code_challenge=$(printf '%s' "$code_verifier" \
  | openssl dgst -sha256 -binary \
  | openssl base64 | tr '+/' '-_' | tr -d '=')

echo "verifier : $code_verifier"
echo "challenge: $code_challenge"
verifier : Xk2pQ7mLdR9vNzTfA3sB8cJyH5gW1eU0iO6rP4nMqK
challenge: p3sVYw-Kk9c1nA2fRb7ZQxL0dEmT8uJhGvNyI4oS6XU

주의할 점: base64url이라 +-, /_로 치환하고 패딩 =는 제거해야 한다. 이걸 안 해서 invalid_grant 나는 케이스를 여러 번 봤다.

{
  "error": "invalid_grant",
  "error_description": "PKCE verification failed: code_verifier does not match code_challenge"
}

또 하나 흔한 함정은 verifier 저장 위치다. SPA에서 리다이렉트 왕복 사이에 verifier를 어딘가 보관해야 하는데, 이걸 메모리에만 두면 새로고침이나 새 탭 리다이렉트에서 날아간다. sessionStorage가 현실적인 절충안이고, 백엔드가 있다면 서버 세션에 담는 BFF(Backend for Frontend) 패턴이 더 낫다.

OAuth 2.1은 이 PKCE를 퍼블릭 클라이언트뿐 아니라 컨피덴셜 클라이언트에도 요구하는 방향으로 정리됐다. "서버에 secret 있는데 왜?"라는 반문이 나오는데, PKCE가 막는 건 시크릿 유출이 아니라 인가 코드 주입(authorization code injection) 공격이기 때문이다. 공격자가 자기 세션에서 얻은 코드를 피해자 브라우저에 주입해 피해자 계정에 자기 계정을 연결시키는 식의 시나리오는 시크릿이 있어도 성립한다. PKCE는 "이 코드를 요청한 브라우저 세션"과 "이 코드를 교환하는 세션"이 같은지를 묶어준다.

OAuth 2.1이 정리하는 주요 변경 사항은 대체로 이렇게 알려져 있다. Implicit Grant와 Password Grant 제거, PKCE 필수화, redirect_uri 완전 일치 요구, 베어러 토큰을 쿼리 스트링에 싣는 것 금지. 다만 2.1은 여전히 드래프트 단계를 거치는 중이라 최종 문서의 정확한 문구와 상태는 공식 문서 확인 필요.

4. 실무 트러블슈팅과 아키텍처 선택

리프레시 토큰 로테이션의 함정

퍼블릭 클라이언트에 리프레시 토큰을 줄 때는 로테이션이 권장된다. 리프레시할 때마다 새 리프레시 토큰을 발급하고 기존 것은 폐기한다. 폐기된 토큰이 다시 쓰이면 "탈취됐다"고 보고 해당 토큰 패밀리 전체를 무효화한다.

여기서 실제로 터지는 게 동시 요청 레이스다. 탭 3개가 동시에 만료된 액세스 토큰으로 API를 호출하면 세 개의 리프레시 요청이 동시에 나간다. 하나만 성공하고 나머지 둘은 이미 폐기된 토큰을 쓴 게 되어 패밀리 전체가 무효화되고, 사용자는 아무 이유 없이 로그아웃된다.

{
  "error": "invalid_grant",
  "error_description": "Refresh token has been revoked (token reuse detected)"
}

대응은 두 갈래다. 클라이언트 쪽에서는 리프레시 요청을 단일 프로미스로 묶어 큐잉한다(모든 대기 요청이 하나의 리프레시 결과를 공유). 서버 쪽에서는 짧은 유예 시간(grace period)을 둬서 방금 로테이션된 토큰의 재사용은 몇 초간 허용하는 구현이 많다. 이 유예 지원 여부는 IdP마다 다르니 도입 전 확인해야 한다.

// 리프레시 단일화 — 프런트에서 가장 자주 필요한 패턴
let refreshing = null;

async function getAccessToken() {
  if (isExpired(token)) {
    refreshing ??= fetch('/auth/refresh', { method: 'POST', credentials: 'include' })
      .then(r => {
        if (!r.ok) throw new Error('refresh failed');
        return r.json();
      })
      .finally(() => { refreshing = null; });   // 성공/실패 무관하게 해제
    token = await refreshing;
  }
  return token.access_token;
}

게이트웨이에서 토큰 검증

API 게이트웨이나 서비스 메시에서 JWT를 검증할 때 자주 빠뜨리는 게 있다. iss, aud, exp는 대부분 챙기는데 알고리즘 고정을 안 한다. 검증 라이브러리가 헤더의 alg를 그대로 신뢰하면 alg: none이나 HS256 혼동 공격 여지가 생긴다. 허용 알고리즘을 화이트리스트로 못박아야 한다.

JWKS 캐싱도 함정이다. 인가 서버가 키를 로테이션했는데 게이트웨이가 JWKS를 무기한 캐싱하면 전 서비스가 401을 뱉는다. 반대로 캐시 없이 매 요청 JWKS를 조회하면 인가 서버가 죽는다. 보통 캐시 TTL을 두되, 모르는 kid가 오면 즉시 한 번 재조회하는 방식(rate limit 걸어서)이 정석이다.

# 발급받은 토큰 검사 — 디버깅 첫 단계
curl -s https://idp.example.com/.well-known/openid-configuration | jq '.issuer, .jwks_uri, .token_endpoint'

# 페이로드만 빠르게 까보기 (검증 아님, 디버깅 전용)
echo "$ACCESS_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq .
"https://idp.example.com"
"https://idp.example.com/.well-known/jwks.json"
"https://idp.example.com/oauth2/token"

{
  "iss": "https://idp.example.com",
  "a
728x90

+ Recent posts