MCP ID-JAG 인증

MCP ID-JAG 인증 (Okta)

ID-JAG(Identity Assertion Authorization Grant, draft-ietf-oauth-identity-assertion-authz-grant)은 LiteLLM이 사용자의 ID 공급자와 다른 authorization server를 가진 MCP 서버의 액세스 토큰을 얻을 수 있게 해줘요. Okta는 이를 AI agent token exchange로 제공해요.

ID-JAG을 사용할 때:

  • MCP 서버가 사용자를 인증하는 org authorization server와 분리된 리소스 authorization server(예: Okta 사용자 지정 authorization server)를 신뢰할 때.
  • 대화형 동의 화면이 아니라 ID 공급자의 관리자 정책이 게이트웨이가 사용자를 대신해 MCP를 호출할 수 있는지 결정하게 하고 싶을 때. 이것이 헤드리스 에이전트에서 작동하게 하는 이유예요.
  • 게이트웨이가 받는 접근이 사용자 스코프이고, 감사 가능하며, ID 공급자에서 철회 가능하길 원할 때.

ID-JAG은 OBO 토큰 교환과 다릅니다. OBO는 하나의 authorization server에 대한 단일 RFC 8693 교환이지만, ID-JAG은 두 authorization server를 가로지르는 두 개의 다리(leg)예요.

출처: 문서

본문

동작 방식 (How It Works)

요약하자면:

  1. 클라이언트가 사용자의 id_token과 함께 LiteLLM에 요청을 보냅니다.
  2. LiteLLM은 그 id_token을 RFC 8693 subject_token으로 사용해 org authorization server(token_exchange_endpoint)에서 ID-JAG assertion으로 교환해요.
  3. LiteLLM은 RFC 7523 jwt-bearer 그랜트로 리소스 authorization server(id_jag_resource_token_endpoint)에 ID-JAG을 제시하고 MCP 액세스 토큰을 받아요.
  4. LiteLLM은 액세스 토큰만 MCP 서버에 전달해요.
  5. LiteLLM은 액세스 토큰이 만료될 때까지 캐시하므로 같은 사용자의 반복 호출이 두 authorization server 왕복을 피해요.

LiteLLM은 두 authorization server에 private-key-JWT client_assertion(RFC 7523)으로 인증하는데, 이것이 Okta가 요구하는 방식이에요. 개인 키가 구성되지 않으면 client_secret으로 폴백해요.

Okta 설정 (Set Up Okta)

ID-JAG은 Okta for AI Agents 구독이 필요해요. 개략적으로:

  1. LiteLLM 게이트웨이를 OAuth 앱(에이전트)으로 등록. private_key_jwt 클라이언트 인증으로 구성하고 공개 키를 JWKS로 업로드하며, LiteLLM용 일치하는 개인 키는 보관. kid를 기록.
  2. org authorization server 토큰 엔드포인트 https://<your-org>.okta.com/oauth2/v1/token을 확인. 이것이 leg 1의 token_exchange_endpoint예요.
  3. MCP가 신뢰하는 리소스(사용자 지정) authorization server를 그 토큰 엔드포인트 https://<your-org>.okta.com/oauth2/<custom-as-id>/v1/token으로 설정. 이것이 leg 2의 id_jag_resource_token_endpoint예요. 그 issuer 식별자가 leg 1의 audience예요.
  4. 게이트웨이 앱이 리소스의 ID-JAG을 얻도록 허가하는 크로스 앱(교차 앱) 액세스 정책을 구성하고, 요청할 수 있는 스코프를 포함.

클릭별 설정은 Okta의 AI agent token exchange guide를 참고해 주세요.

ID-JAG용 MCP 서버 구성 (Configure an MCP Server for ID-JAG)

MCP 서버에 auth_type: oauth2_id_jag를 설정하세요.

config.yaml:

mcp_servers:
  internal_tools:
    url: "https://mcp.example.com/mcp"
    transport: "http"
    auth_type: oauth2_id_jag

    # Org authorization server token endpoint (leg 1: token exchange -> ID-JAG)
    token_exchange_endpoint: "https://your-org.okta.com/oauth2/v1/token"

    # Resource (custom) authorization server token endpoint (leg 2: jwt-bearer -> access token)
    id_jag_resource_token_endpoint: "https://your-org.okta.com/oauth2/<custom-as-id>/v1/token"

    # Gateway app registered with Okta
    client_id: "<okta-agent-client-id>"

    # Private-key-JWT client authentication (RFC 7523). Okta requires this.
    client_private_key: |
      [REDACTED PRIVATE KEY]

    client_private_key_id: "<jwks-kid>"
    client_assertion_signing_alg: "RS256"

    # Resource authorization server identifier; sent as the leg-1 audience
    audience: "https://your-org.okta.com/oauth2/<custom-as-id>"

    # Optional RFC 8707 resource indicator for leg 1
    id_jag_resource: "https://mcp.example.com/"

    # Optional scopes requested for the access token
    scopes:
      - "mcp.tools.read"
      - "mcp.tools.execute"

구성 필드 (Config Fields)

필드 필수 설명
auth_type oauth2_id_jag여야 함.
token_exchange_endpoint leg 1용 org authorization server 토큰 엔드포인트(RFC 8693 토큰 교환).
id_jag_resource_token_endpoint leg 2용 리소스 authorization server 토큰 엔드포인트(RFC 7523 jwt-bearer 그랜트).
client_id authorization server에서 게이트웨이 앱의 OAuth 클라이언트 식별자.
client_private_key 권장 LiteLLM이 client_assertion 서명에 사용하는 PEM 개인 키. Okta에 필요.
client_private_key_id 선택 client_assertion JWT 헤더에서 kid로 광고되는 키 id.
client_assertion_signing_alg 선택 client_assertion의 서명 알고리즘. 기본값 RS256.
client_secret 선택 client_private_key가 설정되지 않은 경우에만 폴백으로 사용.
audience 권장 리소스 authorization server 식별자. LiteLLM이 이를 leg-1 audience로 보냄.
id_jag_resource 선택 leg 1에 보내는 RFC 8707 리소스 표시기.
scopes 선택 LiteLLM이 요청하는 스코프. OAuth scope 파라미터로 연결됨.
subject_token_type 선택 leg 1용 subject 토큰 타입. ID-JAG의 기본값 urn:ietf:params:oauth:token-type:id_token.

두 다리 (The Two Legs)

Leg 1: ID-JAG용 토큰 교환

캐시되지 않은 subject 토큰과 MCP 서버 쌍마다 LiteLLM은 token_exchange_endpoint에 RFC 8693 토큰 교환을 POST해요:

POST /oauth2/v1/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&requested_token_type=urn:ietf:params:oauth:token-type:id-jag
&subject_token=<user-id-token>
&subject_token_type=urn:ietf:params:oauth:token-type:id_token
&audience=https://your-org.okta.com/oauth2/<custom-as-id>
&resource=https://mcp.example.com/
&scope=mcp.tools.read mcp.tools.execute
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=<signed-jwt>

org authorization server가 관리자 정책을 적용하고 ID-JAG assertion을 반환해요:

{
  "issued_token_type": "urn:ietf:params:oauth:token-type:id-jag",
  "access_token": "<id-jag-jwt>",
  "token_type": "N_A",
  "expires_in": 300
}

ID-JAG는 typ: oauth-id-jag+jwt이고 aud가 리소스 authorization server인 서명된 JWT예요.

Leg 2: 액세스 토큰용 jwt-bearer

LiteLLM이 RFC 7523 그랜트로 id_jag_resource_token_endpoint에 ID-JAG을 제시해요:

POST /oauth2/<custom-as-id>/v1/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
&assertion=<id-jag-jwt>
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=<signed-jwt>

리소스 authorization server가 ID-JAG을 검증하고 액세스 토큰을 반환해요:

{
  "access_token": "access-token-for-mcp-server",
  "token_type": "Bearer",
  "expires_in": 3600
}

그런 다음 LiteLLM이 MCP 서버를 호출합니다:

Authorization: Bearer access...rver

ID-JAG MCP 서버 호출 (Calling an ID-JAG MCP Server)

Leg 1은 주장할 ID 토큰이 필요해요. LiteLLM은 두 곳 중 하나에서 가져와요. 요청이 Authorization에 사용자의 id_token을 담으면 그 토큰이 subject예요. 그렇지 않으면 LiteLLM은 사용자가 LiteLLM SSO로 로그인할 때 캡처한 인증된 사용자의 신원 assertion을 사용하며, 이는 오직 LiteLLM 가상 키만 가진 에이전트가 그 키가 속한 사용자로 MCP 서버에 도달하게 해줘요. 사용자는 항상 가상 키가 해석하는 사람이며, 어떤 요청 필드도 업스트림에서 누구의 신원이 주장되는지 선택할 수 없어요.

id_token을 직접 보낼 때는 LiteLLM 키를 x-litellm-api-key에 두고 Authorization은 사용자 토큰에 비워 두세요:

직접 MCP 호출:

curl -X POST "https://litellm.example.com/internal_tools/mcp" \
  -H "Content-Type: application/json" \
  -H "x-litellm-api-key: *** <litellm-api-key>" \
  -H "Authorization: Bearer <user-...ken>" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

tip — MCP 클라이언트가 Authorization 헤더 하나만 보낼 수 있다면 LiteLLM 키에 x-litellm-api-key를 사용하고 Authorization은 사용자의 id_token에 예약하세요. LiteLLM은 leg-1 subject_token으로 id_token이 필요해요.

가상 키만으로 저장소에서 가져온 subject (Store-sourced subject with a virtual key only)

Authorization 헤더 없이도 같은 호출이 LiteLLM SSO로 한 번이라도 로그인했고 assertion이 만료되지 않은 모든 사용자에게 작동해요:

가상 키만:

curl -X POST "https://litellm.example.com/internal_tools/mcp" \
  -H "Content-Type: application/json" \
  -H "x-litellm-api-key: *** <litellm-api-key>" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

assertion 읽기는 MCP_SSO_ASSERTION_CACHE_TTL_SECONDS(기본 60) 동안 프로세스별로 캐시되므로, 한 팟의 새 SSO 로그인이 하나의 TTL 안에 다른 팟에 보이게 돼요.

연결 시 자격 증명 오류 (Credential Errors at Connect Time)

/internal_tools/mcp/mcp/internal_tools 같은 단일 서버 경로에서 LiteLLM은 MCP 세션을 열기 전에 ID-JAG 자격 증명을 해석하므로, 실패가 도구가 없는 것처럼 보이는 세션이 아니라 클라이언트가 대응할 수 있는 HTTP 상태로 돌아와요. 이는 저장소 소스 흐름(Authorization 헤더 없음)에 대해 실행되며, 정확히 아래 실패들이 authorization server를 호출하기 전에 결정되는 곳이에요.

상태 의미 해결책
412 Precondition Failed 이 사용자에 대한 신원 assertion이 저장되어 있지 않거나 저장된 것이 만료됨. 본문이 어느 쪽인지 명시. 사용자가 LiteLLM SSO로 로그인해 게이트웨이가 현재 assertion을 캡처하게 함.
503 Service Unavailable assertion 저장소(LiteLLM 데이터베이스)에 도달할 수 없음. 사용자 측에서는 할 일 없음. 데이터베이스 연결 확인.

412는 OAuth 챌린지가 아닌 JSON 본문이 있는 일반 상태예요. WWW-Authenticate 헤더가 없는데, 그 이유는 클라이언트가 authorization server 메타데이터를 가져와 재시도해서 해결할 수 없고 오직 LiteLLM SSO 로그인만 해결하기 때문이에요.

$ curl -s -i -X POST https://litellm.example.com/mcp/internal_tools \
    -H "x-litellm-api-key: *** <litellm-api-key>" \
    -H 'Content-Type: application/json' \
    -H 'Accept: application/json, text/event-stream' \
    -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
HTTP/1.1 412 Precondition Failed
content-type: application/json

{"detail":"precondition required: ID-JAG requires an IdP identity assertion for this user and none is stored. Sign in through LiteLLM SSO so the gateway captures one."}

같은 경로의 tools/call은 도구가 없음을 보고하는 대신 같은 412를 반환해요.

여러 서버를 한 번에 제공하는 집계 /mcp 경로에서는 한 서버의 자격 증명 실패가 전체 연결을 실패시키면 안 되므로 서버별로 보고돼요. tools/list는 성공하고 실패한 서버는 도구를 기여하지 않으며 이유는 응답의 _meta에 들어가요:

{"_meta":{"litellm.ai/server_outcomes":{"internal_tools":{"status":"internal","http_status":412}}},"tools":[]}

이 형태가 보이고 상태·메시지를 직접 원한다면 단일 서버 경로에서 서버를 호출하세요.

알려진 제한: 요청이 Authorization 베어러를 담으면 tools/list는 현재 저장된 assertion에서 subject를 해석하는 반면 tools/call은 베어러를 사용해요. 따라서 유효한 id_token이 있지만 저장된 assertion이 없는 호출자는 연결 시 상태 대신 위의 _meta 412가 있는 빈 목록을 얻고, 요청에 대해 프리플라이트가 실행되지 않아 도구 호출이 받아들일 자격 증명을 거부할 수 없어요. LiteLLM SSO로 한 번 로그인하면 불일치가 사라져요.

캐싱 동작 (Caching Behavior)

LiteLLM은 subject 토큰과 MCP 서버 ID로 leg-2 액세스 토큰을 캐시하므로 두 명의 다른 사용자는 별도 토큰을 받고 같은 사용자의 같은 MCP 서버 반복 호출은 만료될 때까지 캐시된 토큰을 재사용해요. 캐시 TTL은 leg-2 expires_in에서 LiteLLM의 OAuth 만료 버퍼를 뺀 값을 기반으로 해요. expires_in이 없거나 유효하지 않으면 LiteLLM은 기본 OAuth 토큰 캐시 TTL을 사용해요.

문제 해결 (Troubleshooting)

증상 확인할 것
MCP 서버가 LiteLLM 키를 받음 LiteLLM 키를 x-litellm-api-key로 옮기고 Authorization은 사용자 id_token에 사용.
연결 시 412 Precondition Failed 이 사용자에 대한 저장된 SSO assertion이 없거나 만료됨. 사용자가 LiteLLM SSO로 로그인한 후 재시도. Credential Errors at Connect Time 참고.
연결 시 503 Service Unavailable assertion 저장소에 도달할 수 없음. LiteLLM 데이터베이스 연결 확인.
tools/list가 도구 없음을 반환하고 _metahttp_status: 412 집계 /mcp 경로에 있음. 상태·메시지를 직접 얻으려면 단일 서버 경로 호출.
Leg 1이 400 또는 403 반환 크로스 앱 액세스 정책이 게이트웨이 앱을 리소스·스코프에 대해 허가하는지, audience가 리소스 authorization server 식별자와 일치하는지 확인.
Leg 1이 401 반환 client_id, client_private_key, client_private_key_id가 게이트웨이 앱의 등록된 JWKS와 일치하는지 확인.
Leg 2가 assertion 거부 id_jag_resource_token_endpoint가 org authorization server를 신뢰하는 리소스 authorization server를 가리키는지, 그 시계와 ID-JAG exp가 일치하는지 확인.
매 요청마다 authorization server 호출 leg 2가 expires_in을 반환하는지, 같은 사용자 id_token과 MCP 서버가 재사용되는지 확인.

더 알아보기 (Learn more)