OAuth 기초 원리 이해하기

AI로 구현하더라도 OAuth 기초 원리는 이해해야 합니다.

OAuth 구현은 일반적인 서비스 개발을 하다 보면 한 번쯤은 맞닥뜨리게 됩니다. 사실 과거에는 OAuth를 구현하는 게 약간 짜치는 일이었습니다...만, 지금은 AI와 BetterAuth 같은 인증 전용 프레임워크를 사용하면 후딱 구현할 수 있습니다. 그럼에도 AI로 구현을 하기 전에 내가 맡기려는 시스템이 구조적으로 어떻게 돌아가는지는 알아야 한다고 생각해서 이 글을 작성하게 되었습니다. 제 이해를 보강하기 위해 쓰는 글이지만, 읽고 계신 분들에게도 도움이 되었으면 좋겠습니다.

OAuth란?

OAuth(Open Authorization)는 무엇일까요? 사전적 의미는 "사용자가 자신의 비밀번호를 타사 앱이나 웹사이트에 직접 제공하지 않고도, 특정 정보나 기능에 대한 접근 권한을 위임할 수 있도록 만든 개방형 표준(인가 프레임워크)"입니다.

굉장히 추상적인 설명이지만, 쉽게 말하면 "내 애플리케이션에 구글/카카오 계정 정보를 넘기지 않고도 구글/카카오 계정 데이터를 가져올 수 있게 해 주는 방법"이라고 풀어 설명할 수 있을 것 같습니다. 어떤 앱에서 "구글로 로그인하기"를 누르면 구글 로그인 화면이 뜨고, 그 화면에서 로그인하면 내 앱이 구글 데이터를 가져올 수 있게 되는 방식이죠. 이런 형태는 소셜 로그인이라는 이름으로 흔히 쓰이고 있습니다.

많이들 보신 화면일 겁니다.

앞서 말했듯 OAuth 구현 자체는 인증 전용 프레임워크로 쉽게 구현할 수 있고, AI를 활용하면 솔직히 몇 분이면 충분합니다. 하지만 OAuth가 어떻게 돌아가는지는 알아야 구현할 때 망설임이 없어집니다. 그래서 오늘은 OAuth의 전체적인 이해에 초점을 맞춰 글을 써 보려고 합니다.

OAuth 등장인물

표준화된 방식에는 고정된 등장인물이 존재합니다. (고정된 것이 없으면 표준화되지 않았다는 말이 되니까요.)

OAuth의 핵심 등장인물은 아래 넷입니다. OAuth는 이 넷의 티키타카로 이루어지고, 이 티키타카 속에서 인가 코드(Authorization Code)를 액세스 토큰(Access Token)으로 바꿔 데이터를 가져오는 흐름 등이 일어납니다.

  • Resource Owner : 사용자입니다. 정확히는 데이터의 주인입니다. 구글 OAuth를 쓸 때는 구글 계정의 주인이 Resource Owner가 됩니다.
  • Client : 애플리케이션입니다. 이름 때문에 사용자로 착각하기 쉽지만, 외부 데이터를 가져다 쓰는 애플리케이션이라고 생각해야 합니다.
  • Authorization Server : 인가 서버입니다. 구글이나 카카오 등이 OAuth를 제공하기 위해 직접 운영하는 서버입니다. 기본적으로 /authorize, /token 엔드포인트를 운영합니다.
  • Resource Server : Client가 실제로 가져다 쓸 데이터가 보관된 서버입니다.

시작 전 준비: 클라이언트 등록

등장인물은 아니지만, OAuth를 구현하기 전에 꼭 거쳐야 하는 단계가 있습니다. 바로 클라이언트 등록(Client Registration)입니다.

인가 서버 입장에서는 처음 보는 앱이 "사용자 데이터를 주세요"라고 하면 줄 수가 없겠죠. 그래서 내 앱을 제공자의 인가 서버에 미리 알려 두어야 합니다. 이 등록은 보통 제공자가 운영하는 개발자 콘솔(구글 클라우드 콘솔, 카카오 디벨로퍼스 등)에서 합니다.

등록할 때는 내가 알려 주는 것과 내가 받는 것이 있습니다.

구분항목설명
알려 주는 것앱 이름, 로고사용자 동의 화면에 "OO 앱이 캘린더를 읽으려 합니다"처럼 표시됩니다.
알려 주는 것Redirect URI인가 코드를 돌려받을 주소입니다. 등록하지 않은 주소로는 인가 코드를 보내 주지 않습니다.
받는 것Client ID앱을 구분하는 ID입니다. URL에 실려 다니므로 공개되어도 괜찮습니다.
받는 것Client Secret앱의 비밀번호입니다. 서버에만 두고, 프론트엔드 코드나 Git에 절대 올리면 안 됩니다.

이 값들은 뒤에서 볼 흐름에 그대로 등장하니 참고해주세요!

제공자마다 부르는 이름이 조금씩 다릅니다. 예를 들어 카카오는 Client ID 자리에 "REST API 키"를 씁니다.

OAuth 인가 방식

OAuth 인가 방식에는 여러 가지가 있습니다. 기본적인 것만 꼽으면 아래 세 가지입니다.

  • Implicit
    • 인가 코드를 액세스 토큰으로 교환하는 과정 없이, 클라이언트에 바로 액세스 토큰을 발급하는 방식입니다. 보안상 권장되지 않아 이제는 쓰지 않습니다.
    • 대신 Authorization Code + PKCE 방식을 권장합니다.
  • Client Credentials
    • 사용자 없이, 클라이언트가 Client ID와 Client Secret만으로 액세스 토큰을 발급받아 쓰는 방식입니다.
    • 에이전트나 봇처럼 사용자가 직접 인증할 수 없는 프로그램이 OAuth를 써야 할 때 사용합니다.
    • 최근 에이전트 개발이 늘면서 많이 쓰이게 된 방식입니다.
  • Authorization Code(+PKCE)
    • OAuth 흐름상 클라이언트(애플리케이션)에 노출될 수밖에 없는 단계에서는 먼저 인가 코드(Authorization Code)를 발급하고,
    • 이 인가 코드를 클라이언트의 믿을 수 있는 서버 환경에서 액세스 토큰으로 교환합니다.
    • 이 흐름에 PKCE(Proof Key for Code Exchange)라는 보안 장치를 더한 것이 Authorization Code(+PKCE) 방식입니다.

사실 Implicit 방식은 볼 필요가 없습니다. Client Credentials 방식은 믿을 수 있는 내 프로그램에 Client ID와 Client Secret을 심어 두는 방식이라 흐름이 크게 복잡하지 않습니다.

핵심은 인가 코드(Authorization Code) 방식입니다. 대부분의 사용자 환경에서 이 방식을 쓰고, 이 방식을 이해하면 나머지는 사실 매우 쉽게 이해됩니다. 그럼 인가 코드 방식의 흐름을 알아보겠습니다.

인가 코드 방식 흐름

이 방식은 한 번에 이해하기에는 장치가 꽤 많습니다. 그래서 먼저 전체 흐름을 따라가 보고, 그 안에 등장하는 보안 장치(state, PKCE)는 뒤에서 따로 자세히 보겠습니다. 처음 읽을 때는 state, code_verifier, code_challenge가 "이런 값이 오가는구나" 정도로만 보고 넘어가셔도 됩니다.

1. 전체 흐름

예시는 "내 앱에서 구글 캘린더 일정을 가져오는" 상황입니다.

sequenceDiagram
    autonumber
    actor RO as 사용자
    participant B as 브라우저
    participant CS as 클라이언트 서버
    participant AS as 인가 서버
    participant RS as 리소스 서버

    RO->>B: 1. "구글로 로그인" 클릭
    B->>CS: GET 2. /login
    Note over CS: state, code_verifier, code_challenge 생성 후 저장
    CS-->>B: 3. /authorize 주소로 이동시킴
    B->>AS: 4. GET /authorize (state, code_challenge 포함)
    AS-->>RO: 5. 로그인, 동의 화면
    RO->>AS: 6. 로그인 및 동의
    AS-->>B: 7. redirect_uri로 이동 (code, state 포함)
    B->>CS: 8. GET /callback?code=...&state=...
    Note over CS: state가 저장해 둔 값과 같은지 확인
    CS->>AS: 9. POST /token (code, code_verifier, client_secret)
    Note over AS: code_verifier를 해시해서 code_challenge와 비교
    AS-->>CS: 10. access_token
    CS->>RS: 11. GET /calendar/events (access_token)
    RS-->>CS: 12. 캘린더 데이터

1 ~ 3. 준비: 보안 값을 만들고 인가 서버로 보내기

사용자가 "구글로 로그인"을 누르면(1), 브라우저는 먼저 우리 앱의 클라이언트 서버로 갑니다(2). 서버는 보안에 쓸 값 세 개를 만들어 세션에 저장합니다.

const state = crypto.randomBytes(16).toString('base64url');
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');

code_verifier와 code_challenge는 PKCE의 구성요소로, 글 하단에 따로 정리를 해두었으니 일단은 보안을 위해 이런 값을 사용하는구나 하고 자연스럽게 읽어주세요.

인가 서버는 인가 코드 발급을 위해 기본적으로 /authorize [GET] 엔드포인트를 제공합니다. 클라이언트 서버는 이 주소 뒤에 방금 만든 값들을 쿼리 파라미터로 붙인 뒤, 브라우저에게 "이 주소로 가세요"라고 리다이렉트 응답을 보냅니다(3).

4. 인가 요청

브라우저가 리다이렉트로 인가 서버의 /authorize로 이동합니다(4).

GET https://auth.example.com/authorize
  ?response_type=code
  &client_id=my-app
  &redirect_uri=https://myapp.com/callback
  &scope=calendar.readonly
  &state=af0ifjsldkj
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256
  • response_type=code: response_type은 여러개의 타입을 가지고 있습니다. code는 인가코드를 달라는 의미로 사용됩니다.
  • client_id: 인가 서버에 앱을 등록할 때 받은 앱의 ID입니다.
  • redirect_uri: 인가 코드를 돌려받을 주소입니다. 등록해 둔 주소와 정확히 같아야 합니다.
  • scope: 요청하는 권한의 범위입니다. 동의 화면에 그대로 표시됩니다.
  • state: 돌아온 응답이 내가 보낸 요청인지 확인하는 값입니다. 공격자가 자신의 인가 코드를 담은 콜백 요청을 피해자에게 보내는 공격(CSRF)을 막기 위해 사용합니다.
  • code_challenge, code_challenge_method: PKCE용 값으로, 글 하단에 후술합니다.

5 ~ 6. 로그인과 동의

인가 서버가 로그인 화면과 "이 앱이 캘린더를 읽으려 합니다" 같은 동의 화면을 보여 주고(5), 사용자가 로그인하고 동의합니다(6). 이 과정은 전부 인가 서버의 화면에서 일어납니다. 그래서 내 앱은 사용자의 비밀번호를 볼 일이 없습니다!!!

7 ~ 8. 인가 코드 받기

인가 서버가 브라우저를 redirect_uri로 돌려보냅니다(7). 이때 4번에서 받은 state를 그대로 붙여 줍니다.

HTTP/1.1 302 Found
Location: https://myapp.com/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=af0ifjsldkj

브라우저가 클라이언트 서버의 /callback으로 이동하면(8), 서버는 돌아온 state가 2번에서 저장해 둔 값과 같은지 먼저 확인합니다.

인가 코드는 아직 데이터를 꺼낼 수 있는 출입증이 아닙니다. 출입증으로 바꿀 수 있는 교환권일 뿐이고, 수명이 짧고 한 번만 쓸 수 있습니다.

9 ~ 10. 토큰 교환

인가 서버는 인가 코드를 액세스 토큰으로 교환해 주기 위해 기본적으로 /token [POST] 엔드포인트를 제공합니다.

클라이언트 서버는 이 엔드포인트로 인가 코드와 함께, 지금까지 숨겨 두었던 code_verifier를 보냅니다(9). 이 요청은 브라우저를 거치지 않고 서버와 서버가 직접 통신합니다.

POST https://auth.example.com/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https://myapp.com/callback
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
&client_id=my-app
&client_secret=my-secret

인가 서버는 code_verifier를 해시한 값이 4번에서 받은 code_challenge와 같은지 확인합니다. 같으면 액세스 토큰을 내려 줍니다(10).

{
  "access_token": "eyJhbGciOi...",
  "token_type": "Bearer",
  "expires_in": 3600
}

11 ~ 12. 데이터 가져오기

액세스 토큰을 헤더에 담아 리소스 서버에 요청하고(11), 캘린더 데이터를 받습니다(12).

GET https://api.example.com/calendar/events
Authorization: Bearer eyJhbGciOi...

2. 보안 장치 자세히 보기: state와 PKCE

흐름 중간에 등장한 state, code_verifier, code_challenge는 각각 하나의 구멍을 막기 위한 장치입니다.

구멍 1. 돌아온 응답이 내가 보낸 요청의 응답이 맞을까? → state

공격자가 자기 계정으로 로그인해서 인가 코드를 받은 뒤, 그 코드가 담긴 콜백 링크(https://myapp.com/callback?code=공격자의_코드)를 피해자에게 클릭하게 만들 수 있습니다. 앱이 이 요청을 그대로 받아들이면, 피해자의 앱이 공격자의 계정과 연결되는 상황이 발생합니다.(CSRF)

문제는 앱 스스로 콜백에 대해 내가 보낸 요청에 대한 응답인지, 남이 보낸 요청에 대한 응답인지 알 방법이 없다는 점에 있습니다. 그래서 요청을 보낼 때 표시를 남겨두고, 콜백을 받았을 때 그 표시를 확인하는데 그 값이 state입니다.

  1. 앱이 무작위 값(state)을 만들어 사용자의 세션에 저장해 두고, /authorize [GET] 요청에 함께 보냅니다.
  2. 인가 서버는 콜백 redirect_uri에 그 값을 그대로 돌려줍니다.
  3. 앱은 돌아온 state와 세션에 저장해 둔 state를 비교합니다. 다르거나 없으면 앱이 보낸 요청이 아니므로 거부합니다.

공격자가 보낸 링크에는 피해자의 세션에 저장된 state가 들어 있을 수 없기 때문에 이 방법으로 CSRF 공격을 막을 수 있습니다.

구멍 2. 인가 코드를 누가 가로채면? → PKCE

인가 코드는 콜백 URL(/callback?code=...)에 실려 브라우저를 거치기 때문에 브라우저 기록, 서버 로그, 악성 확장 프로그램 등을 통해 노출될 위험성이 존재합니다. 즉, 공격자가 이 코드를 손에 넣고 먼저 /token에 보내면, 액세스 토큰을 대신 받아 갈 수 있게 됩니다.

문제는 인가 서버 입장에서 /token에 코드를 들고 온 쪽이 처음 /authorize를 요청한 그 앱인지 알 방법이 없다는 점입니다. 그래서 처음 요청할 때 단서(Proof Key)를 맡겨 두고, 토큰을 교환할 때 그 단서로 "내가 처음 요청한 앱이 맞다"를 증명합니다. 이 방식이 PKCE(Proof Key for Code Exchange)입니다.

원리는 비밀번호를 저장하는 방식과 같습니다. 서버는 비밀번호 원본 대신 해시값을 저장해 두고, 로그인때 입력된 비밀번호를 해시해서 저장된 값과 비교하죠. PKCE도 똑같습니다.

  • code_verifier: 앱이 만든 무작위 비밀값입니다. 비밀번호 원본에 해당합니다.
  • code_challenge: code_verifier를 SHA-256으로 해시한 값입니다. 저장해 둔 비밀번호 해시에 해당합니다.
  1. 앱은 code_verifier를 만들어 세션에 보관하고, 이를 해시한 code_challenge만 /authorize 요청에 함께 보냅니다. 어떤 해시를 썼는지도 code_challenge_method=S256으로 알려 줍니다. 인가 서버는 이 값을 기억해 둡니다.
  2. 토큰을 교환할 때 앱은 /token 요청에 code_verifier 원본을 보냅니다.
  3. 인가 서버는 받은 code_verifier를 SHA-256으로 해시해서, 기억해 둔 code_challenge와 같은지 비교합니다. 같을 때만 토큰을 내줍니다.

공격자가 볼 수 있는 건 URL에 실렸던 인가 코드와 code_challenge뿐입니다. 해시는 원본으로 해시값을 만들기는 쉽지만, 해시값으로 원본을 알아내는 건 불가능합니다. 그래서 code_challenge를 봐도 code_verifier를 알 수 없습니다. 게다가 code_verifier는 앱 안에만 있다가 서버 간 통신인 /token 요청으로만 나갑니다. 결국 공격자는 가로챈 인가 코드를 토큰으로 바꿀 수 없습니다.

마치며

이 흐름을 기억해 두시면, BetterAuth 같은 인증 라이브러리를 쓰거나 AI가 만들어 준 인증 코드를 볼 때 OAuth가 어떻게 굴러가는지 보이실 겁니다. 문제가 생겼을 때 어떤 코드를 고쳐야 하는지, 개발자 콘솔에서 무엇을 빼먹어서 동작하지 않는지도 금방 찾으실 수 있을 겁니다.

사실 이 글은 MCP 게이트웨이의 OAuth 인증 방식을 정리하는 과정에서 쓰게 되었습니다. 다음 글에서는 OIDC를 다루고, 이어서 CIMD(Client ID Metadata Document)도 다뤄 보려고 합니다. 읽어주셔서 감사합니다.

댓글

아직 댓글이 없습니다.