Add 3 features for claude.ai Connectors compatibility: - FEATURE-3: client_secret_basic auth on token endpoint (RFC 6749 §2.3.1) - FEATURE-2: Token revocation endpoint /oauth/revoke (RFC 7009) - FEATURE-4: resource parameter support with JWT aud claim (RFC 8707) 23 new tests (481 total), all passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
166 lines
6.1 KiB
Python
166 lines
6.1 KiB
Python
"""Tests for client_secret_basic (HTTP Basic Auth) on the OAuth token endpoint."""
|
|
|
|
import base64
|
|
import os
|
|
import tempfile
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
|
|
def _make_request(headers=None, body=None, content_type="application/x-www-form-urlencoded"):
|
|
"""Create a mock Starlette Request for oauth_token."""
|
|
request = AsyncMock()
|
|
request.headers = MagicMock()
|
|
_headers = {"content-type": content_type}
|
|
if headers:
|
|
_headers.update(headers)
|
|
request.headers.get = lambda key, default="": _headers.get(key.lower(), default)
|
|
|
|
form_data = body or {}
|
|
form = AsyncMock(return_value=form_data)
|
|
request.form = form
|
|
return request
|
|
|
|
|
|
def _encode_basic(client_id: str, client_secret: str) -> str:
|
|
"""Encode client_id:client_secret as Basic Auth header value."""
|
|
raw = f"{client_id}:{client_secret}"
|
|
encoded = base64.b64encode(raw.encode("utf-8")).decode("utf-8")
|
|
return f"Basic {encoded}"
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_storage():
|
|
"""Create temporary storage for OAuth tests."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
os.environ["OAUTH_STORAGE_PATH"] = tmpdir
|
|
os.environ["OAUTH_JWT_SECRET_KEY"] = "test_secret_key_for_basic_auth_tests"
|
|
|
|
from core.oauth import client_registry, server, storage, token_manager
|
|
|
|
client_registry._client_registry = None
|
|
token_manager._token_manager = None
|
|
storage._storage = None
|
|
server._oauth_server = None
|
|
|
|
yield tmpdir
|
|
|
|
os.environ.pop("OAUTH_STORAGE_PATH", None)
|
|
os.environ.pop("OAUTH_JWT_SECRET_KEY", None)
|
|
|
|
client_registry._client_registry = None
|
|
token_manager._token_manager = None
|
|
storage._storage = None
|
|
server._oauth_server = None
|
|
|
|
|
|
async def _call_oauth_token(request):
|
|
"""Import and call the oauth_token endpoint from server.py."""
|
|
from server import oauth_token
|
|
|
|
return await oauth_token(request)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestClientSecretBasicAuth:
|
|
"""Tests for client_secret_basic authentication on the token endpoint."""
|
|
|
|
async def test_basic_auth_header_parsed(self, temp_storage):
|
|
"""Basic Auth header is correctly parsed (base64 encoded client_id:client_secret)."""
|
|
request = _make_request(
|
|
headers={"authorization": _encode_basic("my_client_id", "my_client_secret")},
|
|
body={"grant_type": "client_credentials"},
|
|
)
|
|
|
|
response = await _call_oauth_token(request)
|
|
|
|
# The request will likely fail on actual client validation,
|
|
# but we're testing that it gets past the credential parsing stage.
|
|
# If it returned invalid_request with "Missing client_id", Basic Auth parsing failed.
|
|
import json
|
|
|
|
data = json.loads(response.body)
|
|
assert data.get("error") != "invalid_request" or "Missing client_id" not in data.get(
|
|
"error_description", ""
|
|
)
|
|
|
|
async def test_body_params_take_priority_over_basic_auth(self, temp_storage):
|
|
"""Body params take priority over Basic Auth (setdefault behavior)."""
|
|
request = _make_request(
|
|
headers={"authorization": _encode_basic("basic_id", "basic_secret")},
|
|
body={
|
|
"grant_type": "client_credentials",
|
|
"client_id": "body_id",
|
|
"client_secret": "body_secret",
|
|
},
|
|
)
|
|
|
|
response = await _call_oauth_token(request)
|
|
|
|
import json
|
|
|
|
data = json.loads(response.body)
|
|
# The body params should be used, not the Basic Auth ones.
|
|
# If the error mentions "body_id", body params were used.
|
|
# If it mentions "basic_id", Basic Auth overrode body params (wrong).
|
|
# Since the client won't exist, we expect invalid_client error.
|
|
# The key check: it should NOT have replaced body_id with basic_id.
|
|
if data.get("error") == "invalid_client":
|
|
# The error came from actual client validation, meaning
|
|
# credentials were parsed. The body params were used.
|
|
pass
|
|
else:
|
|
# Should not get "Missing client_id" error
|
|
assert "Missing client_id" not in data.get("error_description", "")
|
|
|
|
async def test_malformed_basic_auth_returns_invalid_client(self, temp_storage):
|
|
"""Malformed Basic Auth header returns invalid_client with 401 status."""
|
|
request = _make_request(
|
|
headers={"authorization": "Basic !!!not-valid-base64!!!"},
|
|
body={"grant_type": "client_credentials"},
|
|
)
|
|
|
|
response = await _call_oauth_token(request)
|
|
|
|
import json
|
|
|
|
data = json.loads(response.body)
|
|
assert data["error"] == "invalid_client"
|
|
assert "Invalid Basic authentication header" in data["error_description"]
|
|
assert response.status_code == 401
|
|
|
|
async def test_missing_credentials_returns_invalid_request(self, temp_storage):
|
|
"""Missing credentials (no body, no Basic header) returns invalid_request."""
|
|
request = _make_request(
|
|
body={"grant_type": "client_credentials"},
|
|
)
|
|
|
|
response = await _call_oauth_token(request)
|
|
|
|
import json
|
|
|
|
data = json.loads(response.body)
|
|
assert data["error"] == "invalid_request"
|
|
assert "Missing client_id or client_secret" in data["error_description"]
|
|
|
|
async def test_client_secret_with_colon(self, temp_storage):
|
|
"""client_secret containing ':' is handled correctly (split on first ':' only)."""
|
|
secret_with_colon = "my:secret:with:colons"
|
|
request = _make_request(
|
|
headers={"authorization": _encode_basic("my_client_id", secret_with_colon)},
|
|
body={"grant_type": "client_credentials"},
|
|
)
|
|
|
|
response = await _call_oauth_token(request)
|
|
|
|
import json
|
|
|
|
data = json.loads(response.body)
|
|
# Should not get invalid_request about missing credentials
|
|
assert data.get("error") != "invalid_request" or "Missing client_id" not in data.get(
|
|
"error_description", ""
|
|
)
|
|
# Should not get invalid_client from Basic Auth parsing
|
|
assert data.get("error_description") != "Invalid Basic authentication header"
|