OAuth 2.0 메타데이터와 엔드포인트 동적 발견

OAuth 2.0 메타데이터와 엔드포인트 동적 발견

OAuth 2.0 스펙은 /authorize/token 같은 엔드포인트가 존재해야 한다고는 말하지만, 실제로 어떤 경로에 두어야 하는지는 정하지 않습니다. 그래서 한동안 관행은 단순했어요. “구글은 accounts.google.com/o/oauth2/v2/auth, GitHub는 github.com/login/oauth/authorize” 같은 정보를 사람이 공식 문서에서 읽어다가 클라이언트 코드에 박아 넣는 방식이었죠.

이 방식은 금방 한계를 드러냈어요. 새로운 Identity Provider(이하 IdP)를 추가할 때마다 코드를 수정해야 하고, IdP가 경로를 바꾸면 클라이언트가 한꺼번에 깨집니다. 한 IdP가 멀티 테넌트로 각 테넌트마다 다른 엔드포인트를 제공하는 경우라면 이런 정적 매핑으로는 감당이 안 되고요.

IETF가 이 문제를 풀기 위해 내놓은 답이 두 개의 메타데이터 스펙입니다. 2018년의 RFC 8414는 Authorization Server(이하 AS)가 자기 자신의 엔드포인트와 기능을 공개하는 방식을, 2025년 초 확정된 RFC 9728은 Resource Server가 자신을 보호하는 AS를 공개하는 방식을 정의합니다. 여기에 audience 범위를 제한하는 RFC 8707 Resource Indicators가 더해져서, 클라이언트가 아무것도 하드코딩하지 않고 OAuth 연동을 시작할 수 있는 흐름이 만들어졌는데요. 이 글은 세 스펙을 한 묶음으로 풀어내고, 마지막에 “클라이언트가 실제로 어떻게 탐색하는가”를 시나리오로 정리합니다.

엔드포인트 자체의 의미와 파라미터가 궁금하다면 OAuth 2.0 엔드포인트 제대로 이해하기를 먼저 읽어주세요.

Authorization Server Metadata

RFC 8414가 정의한 Authorization Server Metadata(AS Metadata)는 Authorization Server가 자기 정보를 JSON 문서로 공개하는 규약입니다. 클라이언트가 AS의 issuer URL만 알면 /.well-known/oauth-authorization-server 경로에서 이 문서를 GET으로 가져올 수 있어요.

AS Metadata 조회
curl https://as.example.com/.well-known/oauth-authorization-server

OpenID Connect 진영에서는 같은 목적으로 /.well-known/openid-configuration을 먼저 써왔습니다. RFC 8414는 이 OIDC Discovery를 일반화한 후계자 스펙이라고 봐도 무방한데, 실제로 대부분의 AS는 두 경로 모두에서 거의 같은 JSON을 반환합니다. MCP 같은 최신 생태계는 RFC 8414 경로를 기본으로 쓰지만, 구글이나 페이스북처럼 OIDC에 뿌리를 둔 IdP는 openid-configuration을 더 안정적으로 제공합니다.

issuer URL과 .well-known 경로의 조합 방식이 의외로 까다롭습니다. 스펙은 issuer URL 뒤에 .well-known/...을 붙이는 것이 아니라, host 바로 뒤에 붙이고 그다음에 issuer의 path를 붙이라고 규정합니다. 예를 들어 issuerhttps://as.example.com/tenant-a라면, metadata URL은 https://as.example.com/.well-known/oauth-authorization-server/tenant-a가 됩니다. 멀티 테넌트 AS에서 자주 놓치는 부분이에요.

AS Metadata의 주요 필드

응답 JSON은 수십 개 필드를 가질 수 있지만, 실무에서 자주 보는 건 몇 개로 좁혀집니다.

AS Metadata 응답 예시
{
  "issuer": "https://as.example.com",
  "authorization_endpoint": "https://as.example.com/oauth2/authorize",
  "token_endpoint": "https://as.example.com/oauth2/token",
  "revocation_endpoint": "https://as.example.com/oauth2/revoke",
  "introspection_endpoint": "https://as.example.com/oauth2/introspect",
  "jwks_uri": "https://as.example.com/.well-known/jwks.json",
  "registration_endpoint": "https://as.example.com/oauth2/register",
  "response_types_supported": ["code"],
  "grant_types_supported": [
    "authorization_code",
    "refresh_token",
    "client_credentials"
  ],
  "scopes_supported": ["openid", "profile", "email", "calendar.read"],
  "token_endpoint_auth_methods_supported": [
    "client_secret_basic",
    "private_key_jwt"
  ],
  "code_challenge_methods_supported": ["S256"]
}

issuer는 이 AS의 식별자 URL로, 받은 토큰의 iss 클레임과 이 값이 일치해야 토큰 위조를 잡을 수 있습니다. authorization_endpoint·token_endpoint·revocation_endpoint·introspection_endpoint는 각 엔드포인트의 절대 URL을 알려줍니다. jwks_uri는 토큰 서명 검증에 쓸 공개 키들의 JSON Web Key Set 주소로, Resource Server가 JWT access token을 자체적으로 검증한다면(즉 매 요청마다 /introspect를 호출하지 않는다면) 반드시 캐시해 둬야 하는 값입니다.

기능 지원 범위를 알리는 배열 필드도 중요합니다. response_types_supported, grant_types_supported, scopes_supported, token_endpoint_auth_methods_supported, code_challenge_methods_supported가 “이 AS가 허용하는 것들의 목록”을 담는데요. 클라이언트는 이걸 읽어서 자기 요청이 허용 범위 안에 들어가는지 런타임에 검사할 수 있습니다. 예를 들어 code_challenge_methods_supportedS256이 없다면 이 AS는 PKCE를 지원하지 않는다는 뜻이니, 보안 정책상 아예 연결을 거부할지 판단할 수 있어요.

registration_endpoint는 RFC 7591 Dynamic Client Registration을 지원하는 AS만 노출하는 필드입니다. 클라이언트가 사람 손을 거치지 않고 프로그램으로 자기 자신을 등록해 client_id를 받아 오는 경로인데, MCP 생태계처럼 “AI 에이전트가 처음 보는 IdP에 즉시 연결되어야 하는” 경우에 특히 유용합니다.

Protected Resource Metadata

AS Metadata가 “Authorization Server가 자기 자신을 설명하는 문서”라면, RFC 9728의 Protected Resource Metadata(PRM)는 Resource Server가 자기 자신을 설명하는 문서입니다. 어떤 API가 “나를 보호하는 Authorization Server는 이것들이다”라고 선언하는 방식이에요.

PRM 조회
curl https://api.example.com/.well-known/oauth-protected-resource

응답 JSON의 핵심 필드는 네 개로 요약됩니다. resource는 이 자원의 식별자(보통 base URL), authorization_servers는 이 자원 접근에 쓸 수 있는 AS들의 issuer URL 배열, scopes_supported는 이 자원이 이해하는 스코프 목록, bearer_methods_supported는 Bearer 토큰을 어떻게 전달받을지(header, body, query)를 알립니다.

PRM 응답 예시
{
  "resource": "https://api.example.com",
  "authorization_servers": ["https://as.example.com"],
  "scopes_supported": ["files:read", "files:write"],
  "bearer_methods_supported": ["header"]
}

PRM이 뒤늦게 표준화된 이유가 흥미롭습니다. 기존 OAuth 모델은 “클라이언트가 이미 어떤 AS를 쓸지 알고 있다”는 전제 위에 설계되어 있었거든요. 그런데 한 자원이 여러 AS를 허용하거나(멀티 테넌트 SaaS), 자원 공급자가 자체 AS를 돌리지 않고 외부 AS에 위임하는 경우(MCP·Cloudflare Access)에는 이 전제가 깨집니다. 이때 자원 자체가 “나와 연결된 AS는 이거다”라고 알려주지 않으면 클라이언트가 진입할 구멍이 없어지는 거죠.

WWW-Authenticate와 리소스 메타데이터 힌트

PRM은 클라이언트가 어디서부터 조회해야 할지 알아야 쓸모가 있는데요. RFC 9728은 이 시작점을 WWW-Authenticate 헤더에 심도록 규정합니다. Resource Server가 토큰 없는 요청이나 무효한 토큰을 받으면 다음과 같이 응답합니다.

401 응답 예시
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource",
                         scope="files:read"

resource_metadata 파라미터가 PRM URL을 알려주는 힌트이고, scope은 이 요청이 필요로 하는 최소 스코프의 힌트입니다. 클라이언트는 이 두 개만으로 전체 인가 흐름을 시작할 수 있어요. PRM을 조회해 AS 목록을 얻고, 그 AS의 AS Metadata를 조회해 authorization_endpoint를 찾고, 힌트로 받은 스코프로 인가 요청을 보내는 순서입니다.

403 상황에서도 비슷한 힌트를 쓸 수 있습니다. 토큰 자체는 유효한데 요청한 동작에 필요한 스코프가 빠져 있을 때를 위해, 스펙은 Step-Up Authorization이라는 흐름을 정의해 두었어요. Resource Server가 insufficient_scope 에러와 함께 필요한 스코프 목록을 WWW-Authenticate 헤더에 담아 내려주면, 클라이언트는 그 힌트만 보고 재인가를 돌릴 수 있습니다.

insufficient_scope 응답 예시
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
                         scope="files:read files:write user:profile",
                         resource_metadata="https://api.example.com/.well-known/oauth-protected-resource",
                         error_description="Additional file write permission required"

401 응답과 구조가 거의 같지만, error="insufficient_scope"가 붙어 “토큰은 받았는데 권한이 모자란다”를 명확히 알린다는 점이 다릅니다. 클라이언트는 이 응답을 받으면 헤더의 scope 값을 파싱해서 인가 요청에 실어 보낸 뒤, 새로 받은 토큰으로 원래 요청을 다시 시도하는 식으로 흐름을 이어갑니다. 사용자 입장에서는 “이 작업에는 파일 쓰기 권한이 더 필요합니다”라는 동의 화면이 한 번 더 뜨고 나서 원래 하려던 동작이 이어지는 경험이 되는 거죠.

서버 쪽에서는 스코프를 어떻게 돌려줄지가 의외로 까다로운 결정입니다. “이번 요청에 필요한 최소 스코프만 돌려줄지” 아니면 “기존에 이미 가진 스코프에 새 스코프를 더한 누적 목록을 돌려줄지”의 선택인데요. 관련 스펙과 모범 사례는 후자인 누적 방식을 권장합니다. 최소 스코프만 돌려주면 재인가를 거치면서 기존에 쓰던 권한이 빠져 버려 다른 기능이 한꺼번에 깨질 수 있기 때문이에요.

클라이언트 쪽에서도 조심할 부분이 있습니다. insufficient_scope가 떨어지자마자 무한정 재시도를 돌리면 사용자가 동의 화면을 계속 봐야 하는 재앙이 벌어지는데요. 스펙은 같은 자원·동작 조합에 대해 일정 횟수 이상 실패하면 영구 실패로 간주하고 멈추라고 권고합니다. 재인가를 시도했는데도 같은 스코프가 계속 부족하다고 나온다면, AS가 해당 스코프 부여를 거부하고 있다는 신호이므로 흐름을 끊고 사용자에게 원인을 알려주는 쪽이 낫습니다.

이 흐름은 MCP 서버가 필요한 권한을 동적으로 넓혀 가는 맥락에서 특히 자주 쓰이는데, MCP 특화 권고사항은 MCP Authentication에서 별도로 다룹니다.

Resource Indicators

클라이언트가 여러 자원에 접근하기 시작하면 또 다른 문제가 생깁니다. 한 access token이 여러 자원에서 그대로 통용되면, 한 자원이 공격당했을 때 다른 자원까지 함께 노출되는 위험이 있거든요. RFC 8707 Resource Indicators는 이 문제를 풀기 위해 인가·토큰 요청에 resource 파라미터를 덧붙입니다.

resource 파라미터 포함 인가 요청
GET /authorize
  ?response_type=code
  &client_id=s6BhdRkqt3
  &redirect_uri=https%3A%2F%2Fclient.example.com%2Fcb
  &scope=files%3Aread
  &resource=https%3A%2F%2Fapi.example.com
  &state=xyz HTTP/1.1

resource 값은 토큰으로 접근할 자원의 URL로, AS는 이 값을 발급되는 access token의 aud 클레임에 박아 넣습니다. 그러면 토큰을 받은 Resource Server는 aud가 자기 URL인 경우만 받아들이고 다른 자원용 토큰은 거부할 수 있게 돼요. 공격자가 한 자원의 토큰을 훔쳐도 다른 자원에서는 쓸 수 없게 만드는 최소한의 경계입니다.

PRM의 resource 필드와 인가 요청의 resource 파라미터는 짝이 맞아야 합니다. 클라이언트는 PRM에서 resource 값을 읽어 그대로 토큰 요청에 실어 보내는 식으로 체인을 완성하는데요. PRM이 “이 자원의 식별자는 이거다”를 선언하면, Resource Indicators가 그 식별자를 토큰의 aud로 옮겨 실제 검증 가능한 경계로 만들어주는 구조입니다. 두 스펙은 역할이 나뉘어 있지만 한 쪽만 있어서는 감사 사슬이 완성되지 않기 때문에, PRM 스펙이 Resource Indicators를 전제로 쓰도록 설계되어 있어요. aud 검증을 건너뛰거나 받은 토큰을 그대로 상류 API에 넘기는(token passthrough) 안티 패턴은 MCP Authentication 스펙에서도 명시적으로 금지하고 있어요.

클라이언트의 실전 발견 흐름

지금까지 소개한 세 스펙이 실제로 맞물려 돌아가는 모습을 한 시나리오로 정리해보겠습니다. 클라이언트가 https://api.example.com/files라는 자원에 접근하려는 상황이에요.

sequenceDiagram
    autonumber
    participant C as 클라이언트
    participant R as Resource Server
    participant AS as Authorization Server

    Note over C,R: 최초 요청과 힌트 수집
    C->>R: GET /files (토큰 없음)
    R-->>C: 401 Unauthorized<br/>WWW-Authenticate: resource_metadata="..."

    Note over C,R: PRM 조회
    C->>R: GET /.well-known/oauth-protected-resource
    R-->>C: { resource, authorization_servers, scopes_supported }

    Note over C,AS: AS Metadata 조회
    C->>AS: GET /.well-known/oauth-authorization-server
    AS-->>C: { authorization_endpoint, token_endpoint,<br/>registration_endpoint, ... }

    Note over C,AS: (선택) 동적 클라이언트 등록
    C->>AS: POST registration_endpoint
    AS-->>C: { client_id, client_secret }

    Note over C,AS: 인가 요청 (사용자 리다이렉트)
    C->>AS: GET authorization_endpoint<br/>response_type, client_id, redirect_uri,<br/>scope, resource, state, code_challenge
    AS-->>C: redirect_uri?code=...&state=...

    Note over C,AS: 토큰 교환
    C->>AS: POST token_endpoint<br/>grant_type=authorization_code, code,<br/>redirect_uri, code_verifier, resource
    AS-->>C: { access_token, token_type, expires_in }

    Note over C,R: 보호된 리소스 접근
    C->>R: GET /files<br/>Authorization: Bearer ...
    R-->>C: 200 OK

이 일곱 단계가 사전 합의 없이 자동으로 끝난다는 점이 핵심입니다. 새 IdP가 생겨도 클라이언트 코드는 그대로이고, IdP가 엔드포인트 경로를 바꿔도 다음 조회에서 클라이언트가 자동으로 따라잡아요. MCP·AI 에이전트 생태계가 OAuth 2.1과 이 메타데이터 스펙들을 기반으로 설계된 이유이기도 합니다.

마치며

OAuth 2.0 메타데이터 스펙들은 처음 볼 때는 여럿으로 쪼개져 있어서 복잡해 보이지만, 결국 “클라이언트가 아무것도 하드코딩하지 않아도 되게 만든다”는 한 가지 목표를 향합니다. 엔드포인트를 직접 박아 넣지 말고, WWW-Authenticate부터 시작해 PRM → AS Metadata → Resource Indicators로 이어지는 흐름을 기본값으로 삼는 게 최신 스펙 방향에 맞습니다.

이 흐름을 실전 맥락에서 깊이 본 사례로는 MCP Authentication이 있고, 메타데이터가 가리키는 엔드포인트 자체의 의미와 파라미터는 OAuth 2.0 엔드포인트 제대로 이해하기에서 다룹니다. OAuth 전반의 개념이 필요하다면 OAuth 2.0 쉽게 이해하기PKCE로 Authorization Code 흐름 안전하게 보호하기도 함께 참고해보세요.

더 자세한 내용은 RFC 8414 - OAuth 2.0 Authorization Server MetadataRFC 9728 - OAuth 2.0 Protected Resource Metadata를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord