Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 53 additions & 4 deletions src/mcp/server/auth/handlers/token.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import base64
import hashlib
import inspect
import time
from dataclasses import dataclass
from typing import Annotated, Any, Literal
Expand Down Expand Up @@ -80,6 +81,47 @@ class TokenErrorResponse(BaseModel):
TokenSuccessResponse = OAuthToken


def _accepts_resource(func: Any) -> bool:
try:
sig = inspect.signature(func)
return "resource" in sig.parameters or any(
p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
)
except (ValueError, TypeError):
return True


async def _exchange_authorization_code(
provider: OAuthAuthorizationServerProvider[Any, Any, Any],
client_info: Any,
auth_code: Any,
resource: str | None,
) -> OAuthToken:
if _accepts_resource(provider.exchange_authorization_code):
try:
return await provider.exchange_authorization_code(client_info, auth_code, resource=resource)
except TypeError as te:
if "resource" not in str(te):
raise
return await provider.exchange_authorization_code(client_info, auth_code)


async def _exchange_refresh_token(
provider: OAuthAuthorizationServerProvider[Any, Any, Any],
client_info: Any,
refresh_token: Any,
scopes: list[str],
resource: str | None,
) -> OAuthToken:
if _accepts_resource(provider.exchange_refresh_token):
try:
return await provider.exchange_refresh_token(client_info, refresh_token, scopes, resource=resource)
except TypeError as te:
if "resource" not in str(te):
raise
return await provider.exchange_refresh_token(client_info, refresh_token, scopes)


@dataclass
class TokenHandler:
provider: OAuthAuthorizationServerProvider[Any, Any, Any]
Expand Down Expand Up @@ -119,8 +161,11 @@ async def handle(self, request: Request):

try:
form_data = await request.form()
# TODO(Marcelo): Can someone check if this `dict()` wrapper is necessary?
token_request = token_request_adapter.validate_python(dict(form_data))
# Parse token request from body form data, falling back to query parameters
# for parameters like RFC 8707 `resource` if provided in query string.
request_data = dict(request.query_params)
request_data.update(dict(form_data))
token_request = token_request_adapter.validate_python(request_data)
except ValidationError as validation_error:
return self.response(
TokenErrorResponse(
Expand Down Expand Up @@ -197,7 +242,9 @@ async def handle(self, request: Request):

try:
# Exchange authorization code for tokens
tokens = await self.provider.exchange_authorization_code(client_info, auth_code)
tokens = await _exchange_authorization_code(
self.provider, client_info, auth_code, token_request.resource
)
except TokenError as e:
return self.response(TokenErrorResponse(error=e.error, error_description=e.error_description))

Expand Down Expand Up @@ -235,7 +282,9 @@ async def handle(self, request: Request):

try:
# Exchange refresh token for new tokens
tokens = await self.provider.exchange_refresh_token(client_info, refresh_token, scopes)
tokens = await _exchange_refresh_token(
self.provider, client_info, refresh_token, scopes, token_request.resource
)
except TokenError as e:
return self.response(TokenErrorResponse(error=e.error, error_description=e.error_description))

Expand Down
8 changes: 7 additions & 1 deletion src/mcp/server/auth/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,13 +228,17 @@ async def load_authorization_code(
...

async def exchange_authorization_code(
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCodeT
self,
client: OAuthClientInformationFull,
authorization_code: AuthorizationCodeT,
resource: str | None = None,
) -> OAuthToken:
"""Exchanges an authorization code for an access token and refresh token.

Args:
client: The client exchanging the authorization code.
authorization_code: The authorization code to exchange.
resource: Optional RFC 8707 resource indicator requested by the client in the token request.

Returns:
The OAuth token, containing access and refresh tokens.
Expand All @@ -261,6 +265,7 @@ async def exchange_refresh_token(
client: OAuthClientInformationFull,
refresh_token: RefreshTokenT,
scopes: list[str],
resource: str | None = None,
) -> OAuthToken:
"""Exchanges a refresh token for an access token and refresh token.

Expand All @@ -270,6 +275,7 @@ async def exchange_refresh_token(
client: The client exchanging the refresh token.
refresh_token: The refresh token to exchange.
scopes: Optional scopes to request with the new access token.
resource: Optional RFC 8707 resource indicator requested by the client in the token request.

Returns:
The OAuth token, containing access and refresh tokens.
Expand Down
25 changes: 19 additions & 6 deletions tests/interaction/auth/_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ def __init__(
# The most recent jwt-bearer request the SDK handler passed to exchange_identity_assertion,
# for tests to assert what the client sent (None until the first exchange).
self.last_assertion_params: IdentityAssertionParams | None = None
self.last_auth_code_resource: str | None = None
self.last_refresh_resource: str | None = None

def _next_expires_in(self) -> int:
self._tokens_issued += 1
Expand Down Expand Up @@ -145,19 +147,24 @@ async def load_authorization_code(
return self.codes.get(authorization_code)

async def exchange_authorization_code(
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
self,
client: OAuthClientInformationFull,
authorization_code: AuthorizationCode,
resource: str | None = None,
) -> OAuthToken:
"""Mint an access token and a refresh token for a valid authorization code, then consume the code."""
assert client.client_id is not None
self.last_auth_code_resource = resource
target_resource = resource if resource is not None else authorization_code.resource
access = self.mint_access_token(
client_id=client.client_id, scopes=authorization_code.scopes, resource=authorization_code.resource
client_id=client.client_id, scopes=authorization_code.scopes, resource=target_resource
)
refresh = f"refresh_{secrets.token_hex(16)}"
self.refresh_tokens[refresh] = RefreshToken(
token=refresh,
client_id=client.client_id,
scopes=authorization_code.scopes,
resource=authorization_code.resource,
resource=target_resource,
)
del self.codes[authorization_code.code]
return OAuthToken(
Expand All @@ -177,17 +184,23 @@ async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_t
return self.refresh_tokens.get(refresh_token)

async def exchange_refresh_token(
self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]
self,
client: OAuthClientInformationFull,
refresh_token: RefreshToken,
scopes: list[str],
resource: str | None = None,
) -> OAuthToken:
"""Mint a new access token and rotate the refresh token, consuming the old one."""
assert client.client_id is not None
self.last_refresh_resource = resource
if self._fail_next_refresh:
self._fail_next_refresh = False
raise TokenError(error="invalid_grant", error_description="refresh denied by harness")
access = self.mint_access_token(client_id=client.client_id, scopes=scopes, resource=refresh_token.resource)
target_resource = resource if resource is not None else refresh_token.resource
access = self.mint_access_token(client_id=client.client_id, scopes=scopes, resource=target_resource)
new_refresh = f"refresh_{secrets.token_hex(16)}"
self.refresh_tokens[new_refresh] = RefreshToken(
token=new_refresh, client_id=client.client_id, scopes=scopes, resource=refresh_token.resource
token=new_refresh, client_id=client.client_id, scopes=scopes, resource=target_resource
)
del self.refresh_tokens[refresh_token.token]
return OAuthToken(
Expand Down
Loading
Loading