MCP 보안 II — OAuth 2.1, 리소스 지시자, 점진적 범위
원격 MCP 서버에는 인증(authentication)뿐 아니라 인가(authorization)가 필요합니다. 2025-11-25 스펙은 OAuth 2.1 + PKCE + 리소스 지시자(resource indicators, RFC 8707) + 보호 리소스 메타데이터(protected-resource metadata, RFC 9728)에 맞춰졌습니다. SEP-835는 403 WWW-Authenticate 응답을 활용한 상향 인가(step-up authorization)로 점진적 범위 동의(incremental scope consent)를 추가합니다. 이 lesson은 모든 단계(hop)를 추적할 수 있도록 상향 인가 흐름을 상태 머신으로 구현합니다.
유형: Build
언어: Python (표준 라이브러리, OAuth 상태 머신 시뮬레이터)
선수 학습: Phase 13 · 09 (전송 방식), Phase 13 · 15 (보안 I)
소요 시간: 약 75분
학습 목표
- 리소스 서버(resource server)와 인가 서버(authorization server)의 책임을 구분합니다.
- PKCE로 보호되는 OAuth 2.1 인가 코드 흐름(authorization code flow)을 따라갑니다.
- 혼동된 대리자 공격(confused-deputy attack)을 막기 위해
resource(RFC 8707)와 보호 리소스 메타데이터(RFC 9728)를 사용합니다.
- 상향 인가(step-up authorization)를 구현합니다. 서버는 더 높은 범위를 요구하는
WWW-Authenticate 헤더와 함께 403을 응답하고, 클라이언트는 사용자 동의를 다시 요청한 뒤 재시도합니다.
문제
초기 MCP(2025년 이전)는 원격 서버에 임시 API 키(ad-hoc API key)를 사용하거나 인증을 아예 두지 않은 채 배포되었습니다. 2025-11-25 스펙은 완전한 OAuth 2.1 프로필로 이 공백을 닫습니다.
현실 세계의 필요는 세 가지입니다.
- 일반 원격 서버. 사용자가 Notion / GitHub / Gmail에 접근하는 원격 MCP 서버를 설치합니다. PKCE가 포함된 OAuth 2.1이 올바른 형태입니다.
- 범위 상승(Scope escalation).
notes:read를 부여받은 노트 서버가 특정 작업에서 나중에 notes:write를 필요로 할 수 있습니다. 전체 흐름을 다시 진행하지 않고, 상향 인가(SEP-835)로 추가 범위만 요청합니다.
- 혼동된 대리자 방지. 클라이언트가 서버 A를 대상(audience)으로 하는 토큰을 보유합니다. 서버 A가 악의적이라면 그 토큰을 서버 B에 제시하려 할 수 있습니다. 리소스 지시자(RFC 8707)는 토큰을 의도한 대상에 고정합니다.
OAuth 2.1 자체는 새롭지 않습니다. 새로운 것은 MCP가 정의한 프로필입니다. 특정 필수 흐름(인가 코드 + PKCE만 허용, 암묵적 흐름(implicit flow) 없음, 기본적으로 클라이언트 자격 증명(client credentials) 없음), 모든 토큰 요청에 리소스 지시자(resource indicator) 필수, 그리고 클라이언트가 어디로 가야 하는지 알 수 있도록 보호 리소스 메타데이터 게시가 포함됩니다.
개념
역할
- 클라이언트(Client). MCP 클라이언트입니다. Claude Desktop, Cursor 등이 해당합니다.
- 리소스 서버(Resource server). MCP 서버입니다. 노트, GitHub, Postgres 등 무엇이든 될 수 있습니다.
- 인가 서버(Authorization server). 토큰을 발급합니다. 리소스 서버와 같은 서비스일 수도 있고, 별도의 신원 공급자(IdP; Identity Provider)인 Auth0, Keycloak, Cognito 등이 될 수도 있습니다.
MCP 프로필에서는 리소스 서버와 인가 서버가 같은 호스트일 수 있지만, URL로는 구분하는 것이 좋습니다(SHOULD).
인가 코드(Authorization code) + PKCE
흐름은 다음과 같습니다.
- 클라이언트가
code_verifier(무작위 값)와 code_challenge(SHA256 해시)를 생성합니다.
- 클라이언트가 사용자를
/authorize?response_type=code&client_id=...&redirect_uri=...&scope=notes:read&code_challenge=...&resource=https://notes.example.com로 리다이렉트(redirect)합니다.
- 사용자가 동의합니다. 인가 서버가
redirect_uri?code=...로 리다이렉트합니다.
- 클라이언트가
/token?grant_type=authorization_code&code=...&code_verifier=...&resource=...로 POST 요청을 보냅니다.
- 인가 서버가 저장된
code_challenge와 code_verifier의 해시를 비교해 검증하고 액세스 토큰(access token)을 발급합니다.
- 클라이언트는 리소스 서버에 대한 모든 요청에서
Authorization: Bearer ... 헤더로 토큰을 사용합니다.
PKCE는 인가 코드 탈취 공격을 막습니다. 리소스 지시자는 토큰이 다른 곳에서 유효하지 않도록 막습니다.
보호 리소스 메타데이터(RFC 9728)
리소스 서버는 .well-known/oauth-protected-resource 문서를 게시합니다.
{
"resource": "https://notes.example.com",
"authorization_servers": ["https://auth.example.com"],
"scopes_supported": ["notes:read", "notes:write", "notes:delete"]
}
클라이언트는 리소스 서버를 통해 인가 서버를 발견합니다. 덕분에 설정이 줄어들고, 클라이언트는 리소스 URL만 알면 됩니다.
리소스 지시자(RFC 8707)
토큰 요청의 resource 매개변수는 토큰의 의도된 대상(audience)을 고정합니다. 발급된 토큰에는 aud: "https://notes.example.com"이 들어갑니다. 이 토큰을 받은 다른 MCP 서버는 aud 값을 확인하고 거부합니다.
범위 모델
범위(scope)는 공백으로 구분된 문자열입니다. 흔한 MCP 관례는 다음과 같습니다.
notes:read, notes:write, notes:delete
- 관리자 기능을 위한
admin:* (드물게 사용)
- 신원 확인을 위한
profile:read
범위 선택은 최소 권한(least privilege)을 따라야 합니다. 지금 필요한 것만 요청하고, 더 필요해지면 그때 상향(step-up)합니다.
상향 인가(Step-up authorization, SEP-835)
사용자가 notes:read를 부여했다고 가정합니다. 이후 사용자가 에이전트에게 노트를 삭제하라고 요청합니다. 서버는 다음과 같이 응답합니다.
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
scope="notes:delete", resource="https://notes.example.com"
클라이언트는 insufficient_scope 오류를 확인한 뒤, 추가 범위에 대한 동의 대화상자를 사용자에게 보여주고, 이를 위한 작은 OAuth 흐름을 수행한 다음 새 토큰으로 요청을 재시도합니다.
토큰 대상(audience) 검증
모든 요청에서 서버는 token.aud == self.resource_url인지 확인합니다. 불일치하면 401을 반환합니다. 이는 서버 간 토큰 재사용을 막습니다.
짧은 수명의 토큰과 회전
액세스 토큰(access token)은 짧은 수명을 가져야 합니다(SHOULD). 기본값은 1시간입니다. 리프레시 토큰(refresh token)은 갱신(refresh)할 때마다 회전됩니다. 클라이언트는 백그라운드에서 사용자에게 보이지 않게 토큰 갱신을 처리합니다.
토큰 전달 금지
샘플링 서버(Phase 13 · 11)는 클라이언트 토큰을 다른 서비스로 전달해서는 안 됩니다(MUST NOT). 샘플링 요청이 경계입니다.
혼동된 대리자 방지
토큰은 aud에 묶입니다. 클라이언트는 client_id에 묶입니다. 모든 요청은 둘 다 기준으로 검증됩니다. 스펙은 MCP 이전 원격 도구 생태계에서 흔했던 오래된 "pass-the-token" 패턴을 명시적으로 금지합니다.
클라이언트 ID 발견
각 MCP 클라이언트는 고정된 URL에 자신의 메타데이터를 게시합니다. 인가 서버는 클라이언트의 메타데이터 문서를 가져와 리다이렉트 URI(redirect URI)와 연락처 정보를 확인할 수 있습니다. 이를 통해 수동 클라이언트 등록 절차가 사라집니다.
게이트웨이와 OAuth
Phase 13 · 17은 엔터프라이즈 게이트웨이(enterprise gateway)가 OAuth를 처리하는 방식을 보여줍니다. 게이트웨이는 업스트림 서버(upstream server)의 자격 증명을 보관하고, 클라이언트에 발급되는 토큰은 게이트웨이가 직접 발급하며, 업스트림 토큰은 게이트웨이 밖으로 나가지 않습니다. 이렇게 신뢰 모델이 뒤집힙니다. 사용자는 게이트웨이에 한 번만 인증하고, 게이트웨이가 N개의 서버에 대한 인가를 대신 처리합니다.
사용해보기
code/main.py는 전체 OAuth 2.1 상향 인가 흐름을 상태 머신으로 시뮬레이션합니다. 다음을 구현합니다.
- PKCE의
code_verifier / code_challenge 생성
- 리소스 지시자가 포함된 인가 코드 흐름(authorization code flow)
- 보호 리소스 메타데이터 엔드포인트
- 대상(audience) 검사가 포함된 토큰 검증
insufficient_scope 응답에 대한 상향 인가
이 lesson에는 별도의 HTTP 서버가 없습니다. 상태 머신은 메모리에서 실행되므로 모든 단계(hop)를 직접 추적할 수 있습니다. Phase 13 · 17의 게이트웨이 lesson은 이를 실제 전송 계층에 연결합니다.
산출물 만들기
이 lesson은 outputs/skill-oauth-scope-planner.md를 만듭니다. 도구 목록이 주어진 원격 MCP 서버에 대해, 이 스킬(skill)은 범위 집합, 고정(pinning) 규칙, 상향 인가 정책을 설계합니다.
연습문제
-
code/main.py를 실행합니다. 두 단계 범위 상향 인가 흐름을 추적합니다. 상향 인가 시 어떤 단계(hop)가 반복되는지 확인합니다.
-
리프레시 토큰(refresh token) 회전을 추가합니다. 갱신할 때마다 새 리프레시 토큰을 발급하고 이전 토큰은 무효화합니다. 탈취된 리프레시 토큰이 회전 이후에 사용되는 상황을 시뮬레이션하고, 요청이 실패하는지 확인합니다.
-
보호 리소스 메타데이터 엔드포인트를 표준 라이브러리 http.server로 실제 HTTP 응답까지 구현합니다. Lesson 09의 /mcp 엔드포인트를 참고합니다.
-
GitHub MCP 서버를 위한 범위 계층(scope hierarchy)을 설계합니다. 리포지토리(repo) 읽기, PR(pull request) 쓰기, PR 승인, PR 병합(merge), 관리자(admin) 권한을 둡니다. 각 수준 사이에 상향 인가를 사용합니다.
-
RFC 8707과 RFC 9728을 읽습니다. 9728에서 MCP가 RFC 예시와 다르게 사용하는 필드 하나를 찾습니다. 힌트: scopes_supported와 관련이 있습니다.
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| OAuth 2.1 | "현대 OAuth" | PKCE를 요구하고 암묵적 흐름(implicit flow)을 금지하는 통합 RFC |
| PKCE | "소유 증명(proof-of-possession)" | 인가 코드 탈취를 막는 code_verifier + code_challenge |
| 리소스 지시자(Resource indicator) | "토큰 대상(audience)" | 토큰을 하나의 서버에 고정하는 RFC 8707 resource 매개변수 |
| 보호 리소스 메타데이터(Protected-resource metadata) | "발견 문서(discovery doc)" | RFC 9728 .well-known/oauth-protected-resource |
| 상향 인가(Step-up authorization) | "점진적 동의" | 필요할 때 범위를 추가하는 SEP-835 흐름 |
insufficient_scope | "WWW-Authenticate가 포함된 403" | 더 큰 범위에 대한 재동의를 요청하는 서버 신호 |
| 혼동된 대리자(Confused deputy) | "서비스 간 토큰 재사용" | 신뢰받는 보유자가 토큰을 부적절하게 전달하는 공격 |
| 짧은 수명 토큰(Short-lived token) | "액세스 토큰 TTL" | 빠르게 만료되는 베어러 토큰(bearer). 리프레시 토큰이 갱신함 |
| 범위 계층(Scope hierarchy) | "최소 권한 스택" | 수준 사이에 상향 인가를 두는 단계적 범위 집합 |
| 클라이언트 ID 메타데이터(Client ID metadata) | "클라이언트 발견 문서" | 클라이언트가 자신의 OAuth 메타데이터를 게시하는 URL |
더 읽을거리