Add client_secret_basic authentication support (#1334)
Co-authored-by: Paul Carleton <paulc@anthropic.com>
This commit is contained in:
parent
397089a78e
commit
c51936f61f
1 changed files with 695 additions and 59 deletions
|
|
@ -12,7 +12,7 @@ from urllib.parse import parse_qs, urlparse
|
|||
|
||||
import httpx
|
||||
import pytest
|
||||
from pydantic import AnyHttpUrl
|
||||
from pydantic import AnyHttpUrl, AnyUrl
|
||||
from starlette.applications import Starlette
|
||||
|
||||
from mcp.server.auth.provider import (
|
||||
|
|
@ -320,7 +320,7 @@ class TestAuthEndpoints:
|
|||
assert metadata["revocation_endpoint"] == "https://auth.example.com/revoke"
|
||||
assert metadata["response_types_supported"] == ["code"]
|
||||
assert metadata["code_challenge_methods_supported"] == ["S256"]
|
||||
assert metadata["token_endpoint_auth_methods_supported"] == ["client_secret_post"]
|
||||
assert metadata["token_endpoint_auth_methods_supported"] == ["client_secret_post", "client_secret_basic"]
|
||||
assert metadata["grant_types_supported"] == [
|
||||
"authorization_code",
|
||||
"refresh_token",
|
||||
|
|
@ -339,8 +339,8 @@ class TestAuthEndpoints:
|
|||
},
|
||||
)
|
||||
error_response = response.json()
|
||||
assert error_response["error"] == "invalid_request"
|
||||
assert "error_description" in error_response # Contains validation error messages
|
||||
assert error_response["error"] == "unauthorized_client"
|
||||
assert "error_description" in error_response # Contains error message
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_token_invalid_auth_code(
|
||||
|
|
@ -976,6 +976,330 @@ class TestAuthEndpoints:
|
|||
assert "response_types" in data
|
||||
assert data["response_types"] == ["code"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_client_secret_basic_authentication(
|
||||
self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider, pkce_challenge: dict[str, str]
|
||||
):
|
||||
"""Test that client_secret_basic authentication works correctly."""
|
||||
client_metadata = {
|
||||
"redirect_uris": ["https://client.example.com/callback"],
|
||||
"client_name": "Basic Auth Client",
|
||||
"token_endpoint_auth_method": "client_secret_basic",
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
}
|
||||
|
||||
response = await test_client.post("/register", json=client_metadata)
|
||||
assert response.status_code == 201
|
||||
client_info = response.json()
|
||||
assert client_info["token_endpoint_auth_method"] == "client_secret_basic"
|
||||
|
||||
auth_code = f"code_{int(time.time())}"
|
||||
mock_oauth_provider.auth_codes[auth_code] = AuthorizationCode(
|
||||
code=auth_code,
|
||||
client_id=client_info["client_id"],
|
||||
code_challenge=pkce_challenge["code_challenge"],
|
||||
redirect_uri=AnyUrl("https://client.example.com/callback"),
|
||||
redirect_uri_provided_explicitly=True,
|
||||
scopes=["read", "write"],
|
||||
expires_at=time.time() + 600,
|
||||
)
|
||||
|
||||
credentials = f"{client_info['client_id']}:{client_info['client_secret']}"
|
||||
encoded_credentials = base64.b64encode(credentials.encode()).decode()
|
||||
|
||||
response = await test_client.post(
|
||||
"/token",
|
||||
headers={"Authorization": f"Basic {encoded_credentials}"},
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": client_info["client_id"],
|
||||
"code": auth_code,
|
||||
"code_verifier": pkce_challenge["code_verifier"],
|
||||
"redirect_uri": "https://client.example.com/callback",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
token_response = response.json()
|
||||
assert "access_token" in token_response
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wrong_auth_method_without_valid_credentials_fails(
|
||||
self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider, pkce_challenge: dict[str, str]
|
||||
):
|
||||
"""Test that using the wrong authentication method fails when credentials are missing."""
|
||||
client_metadata = {
|
||||
"redirect_uris": ["https://client.example.com/callback"],
|
||||
"client_name": "Post Auth Client",
|
||||
"token_endpoint_auth_method": "client_secret_post",
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
}
|
||||
|
||||
response = await test_client.post("/register", json=client_metadata)
|
||||
assert response.status_code == 201
|
||||
client_info = response.json()
|
||||
assert client_info["token_endpoint_auth_method"] == "client_secret_post"
|
||||
|
||||
auth_code = f"code_{int(time.time())}"
|
||||
mock_oauth_provider.auth_codes[auth_code] = AuthorizationCode(
|
||||
code=auth_code,
|
||||
client_id=client_info["client_id"],
|
||||
code_challenge=pkce_challenge["code_challenge"],
|
||||
redirect_uri=AnyUrl("https://client.example.com/callback"),
|
||||
redirect_uri_provided_explicitly=True,
|
||||
scopes=["read", "write"],
|
||||
expires_at=time.time() + 600,
|
||||
)
|
||||
|
||||
# Try to use Basic auth when client_secret_post is registered (without secret in body)
|
||||
# This should fail because the secret is missing from the expected location
|
||||
|
||||
credentials = f"{client_info['client_id']}:{client_info['client_secret']}"
|
||||
encoded_credentials = base64.b64encode(credentials.encode()).decode()
|
||||
|
||||
response = await test_client.post(
|
||||
"/token",
|
||||
headers={"Authorization": f"Basic {encoded_credentials}"},
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": client_info["client_id"],
|
||||
# client_secret NOT in body where it should be
|
||||
"code": auth_code,
|
||||
"code_verifier": pkce_challenge["code_verifier"],
|
||||
"redirect_uri": "https://client.example.com/callback",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
error_response = response.json()
|
||||
assert error_response["error"] == "unauthorized_client"
|
||||
assert "Client secret is required" in error_response["error_description"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_basic_auth_without_header_fails(
|
||||
self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider, pkce_challenge: dict[str, str]
|
||||
):
|
||||
"""Test that omitting Basic auth when client_secret_basic is registered fails."""
|
||||
client_metadata = {
|
||||
"redirect_uris": ["https://client.example.com/callback"],
|
||||
"client_name": "Basic Auth Client",
|
||||
"token_endpoint_auth_method": "client_secret_basic",
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
}
|
||||
|
||||
response = await test_client.post("/register", json=client_metadata)
|
||||
assert response.status_code == 201
|
||||
client_info = response.json()
|
||||
assert client_info["token_endpoint_auth_method"] == "client_secret_basic"
|
||||
|
||||
auth_code = f"code_{int(time.time())}"
|
||||
mock_oauth_provider.auth_codes[auth_code] = AuthorizationCode(
|
||||
code=auth_code,
|
||||
client_id=client_info["client_id"],
|
||||
code_challenge=pkce_challenge["code_challenge"],
|
||||
redirect_uri=AnyUrl("https://client.example.com/callback"),
|
||||
redirect_uri_provided_explicitly=True,
|
||||
scopes=["read", "write"],
|
||||
expires_at=time.time() + 600,
|
||||
)
|
||||
|
||||
response = await test_client.post(
|
||||
"/token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": client_info["client_id"],
|
||||
"client_secret": client_info["client_secret"], # Secret in body (ignored)
|
||||
"code": auth_code,
|
||||
"code_verifier": pkce_challenge["code_verifier"],
|
||||
"redirect_uri": "https://client.example.com/callback",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
error_response = response.json()
|
||||
assert error_response["error"] == "unauthorized_client"
|
||||
assert "Missing or invalid Basic authentication" in error_response["error_description"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_basic_auth_invalid_base64_fails(
|
||||
self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider, pkce_challenge: dict[str, str]
|
||||
):
|
||||
"""Test that invalid base64 in Basic auth header fails."""
|
||||
client_metadata = {
|
||||
"redirect_uris": ["https://client.example.com/callback"],
|
||||
"client_name": "Basic Auth Client",
|
||||
"token_endpoint_auth_method": "client_secret_basic",
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
}
|
||||
|
||||
response = await test_client.post("/register", json=client_metadata)
|
||||
assert response.status_code == 201
|
||||
client_info = response.json()
|
||||
|
||||
auth_code = f"code_{int(time.time())}"
|
||||
mock_oauth_provider.auth_codes[auth_code] = AuthorizationCode(
|
||||
code=auth_code,
|
||||
client_id=client_info["client_id"],
|
||||
code_challenge=pkce_challenge["code_challenge"],
|
||||
redirect_uri=AnyUrl("https://client.example.com/callback"),
|
||||
redirect_uri_provided_explicitly=True,
|
||||
scopes=["read", "write"],
|
||||
expires_at=time.time() + 600,
|
||||
)
|
||||
|
||||
# Send invalid base64
|
||||
response = await test_client.post(
|
||||
"/token",
|
||||
headers={"Authorization": "Basic !!!invalid-base64!!!"},
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": client_info["client_id"],
|
||||
"code": auth_code,
|
||||
"code_verifier": pkce_challenge["code_verifier"],
|
||||
"redirect_uri": "https://client.example.com/callback",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
error_response = response.json()
|
||||
assert error_response["error"] == "unauthorized_client"
|
||||
assert "Invalid Basic authentication header" in error_response["error_description"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_basic_auth_no_colon_fails(
|
||||
self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider, pkce_challenge: dict[str, str]
|
||||
):
|
||||
"""Test that Basic auth without colon separator fails."""
|
||||
client_metadata = {
|
||||
"redirect_uris": ["https://client.example.com/callback"],
|
||||
"client_name": "Basic Auth Client",
|
||||
"token_endpoint_auth_method": "client_secret_basic",
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
}
|
||||
|
||||
response = await test_client.post("/register", json=client_metadata)
|
||||
assert response.status_code == 201
|
||||
client_info = response.json()
|
||||
|
||||
auth_code = f"code_{int(time.time())}"
|
||||
mock_oauth_provider.auth_codes[auth_code] = AuthorizationCode(
|
||||
code=auth_code,
|
||||
client_id=client_info["client_id"],
|
||||
code_challenge=pkce_challenge["code_challenge"],
|
||||
redirect_uri=AnyUrl("https://client.example.com/callback"),
|
||||
redirect_uri_provided_explicitly=True,
|
||||
scopes=["read", "write"],
|
||||
expires_at=time.time() + 600,
|
||||
)
|
||||
|
||||
# Send base64 without colon (invalid format)
|
||||
import base64
|
||||
|
||||
invalid_creds = base64.b64encode(b"no-colon-here").decode()
|
||||
response = await test_client.post(
|
||||
"/token",
|
||||
headers={"Authorization": f"Basic {invalid_creds}"},
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": client_info["client_id"],
|
||||
"code": auth_code,
|
||||
"code_verifier": pkce_challenge["code_verifier"],
|
||||
"redirect_uri": "https://client.example.com/callback",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
error_response = response.json()
|
||||
assert error_response["error"] == "unauthorized_client"
|
||||
assert "Invalid Basic authentication header" in error_response["error_description"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_basic_auth_client_id_mismatch_fails(
|
||||
self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider, pkce_challenge: dict[str, str]
|
||||
):
|
||||
"""Test that client_id mismatch between body and Basic auth fails."""
|
||||
client_metadata = {
|
||||
"redirect_uris": ["https://client.example.com/callback"],
|
||||
"client_name": "Basic Auth Client",
|
||||
"token_endpoint_auth_method": "client_secret_basic",
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
}
|
||||
|
||||
response = await test_client.post("/register", json=client_metadata)
|
||||
assert response.status_code == 201
|
||||
client_info = response.json()
|
||||
|
||||
auth_code = f"code_{int(time.time())}"
|
||||
mock_oauth_provider.auth_codes[auth_code] = AuthorizationCode(
|
||||
code=auth_code,
|
||||
client_id=client_info["client_id"],
|
||||
code_challenge=pkce_challenge["code_challenge"],
|
||||
redirect_uri=AnyUrl("https://client.example.com/callback"),
|
||||
redirect_uri_provided_explicitly=True,
|
||||
scopes=["read", "write"],
|
||||
expires_at=time.time() + 600,
|
||||
)
|
||||
|
||||
# Send different client_id in Basic auth header
|
||||
import base64
|
||||
|
||||
wrong_creds = base64.b64encode(f"wrong-client-id:{client_info['client_secret']}".encode()).decode()
|
||||
response = await test_client.post(
|
||||
"/token",
|
||||
headers={"Authorization": f"Basic {wrong_creds}"},
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": client_info["client_id"], # Correct client_id in body
|
||||
"code": auth_code,
|
||||
"code_verifier": pkce_challenge["code_verifier"],
|
||||
"redirect_uri": "https://client.example.com/callback",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
error_response = response.json()
|
||||
assert error_response["error"] == "unauthorized_client"
|
||||
assert "Client ID mismatch" in error_response["error_description"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_none_auth_method_public_client(
|
||||
self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider, pkce_challenge: dict[str, str]
|
||||
):
|
||||
"""Test that 'none' authentication method works for public clients."""
|
||||
client_metadata = {
|
||||
"redirect_uris": ["https://client.example.com/callback"],
|
||||
"client_name": "Public Client",
|
||||
"token_endpoint_auth_method": "none",
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
}
|
||||
|
||||
response = await test_client.post("/register", json=client_metadata)
|
||||
assert response.status_code == 201
|
||||
client_info = response.json()
|
||||
assert client_info["token_endpoint_auth_method"] == "none"
|
||||
# Public clients should not have a client_secret
|
||||
assert "client_secret" not in client_info or client_info.get("client_secret") is None
|
||||
|
||||
auth_code = f"code_{int(time.time())}"
|
||||
mock_oauth_provider.auth_codes[auth_code] = AuthorizationCode(
|
||||
code=auth_code,
|
||||
client_id=client_info["client_id"],
|
||||
code_challenge=pkce_challenge["code_challenge"],
|
||||
redirect_uri=AnyUrl("https://client.example.com/callback"),
|
||||
redirect_uri_provided_explicitly=True,
|
||||
scopes=["read", "write"],
|
||||
expires_at=time.time() + 600,
|
||||
)
|
||||
|
||||
# Token request without any client secret
|
||||
response = await test_client.post(
|
||||
"/token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": client_info["client_id"],
|
||||
"code": auth_code,
|
||||
"code_verifier": pkce_challenge["code_verifier"],
|
||||
"redirect_uri": "https://client.example.com/callback",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
token_response = response.json()
|
||||
assert "access_token" in token_response
|
||||
|
||||
|
||||
class TestAuthorizeEndpointErrors:
|
||||
"""Test error handling in the OAuth authorization endpoint."""
|
||||
|
|
|
|||
Loading…
Reference in a new issue