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>
334 lines
10 KiB
Python
334 lines
10 KiB
Python
"""
|
|
Tests for RFC 8707 resource parameter support in OAuth 2.1 flow.
|
|
|
|
Validates that the resource parameter is:
|
|
1. Accepted and stored in authorization codes
|
|
2. Passed through to JWT access tokens as aud claim
|
|
3. Optional -- existing flows without resource continue to work
|
|
"""
|
|
|
|
import os
|
|
import tempfile
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
import jwt
|
|
import pytest
|
|
|
|
from core.oauth.schemas import AuthorizationCode
|
|
from core.oauth.token_manager import TokenManager
|
|
|
|
|
|
@pytest.fixture
|
|
def token_manager():
|
|
"""Create a fresh TokenManager for tests."""
|
|
os.environ["OAUTH_JWT_SECRET_KEY"] = "test_secret_key_resource"
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
os.environ["OAUTH_STORAGE_PATH"] = tmpdir
|
|
yield TokenManager()
|
|
os.environ.pop("OAUTH_JWT_SECRET_KEY", None)
|
|
os.environ.pop("OAUTH_STORAGE_PATH", None)
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_storage():
|
|
"""Create temporary storage for OAuth server tests."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
os.environ["OAUTH_STORAGE_PATH"] = tmpdir
|
|
os.environ["OAUTH_JWT_SECRET_KEY"] = "test_secret_key_resource"
|
|
|
|
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
|
|
|
|
# Teardown
|
|
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
|
|
|
|
|
|
@pytest.fixture
|
|
def oauth_components(temp_storage):
|
|
"""Get fresh OAuth components."""
|
|
from core.oauth import get_client_registry, get_oauth_server, get_storage, get_token_manager
|
|
|
|
return {
|
|
"server": get_oauth_server(),
|
|
"client_registry": get_client_registry(),
|
|
"token_manager": get_token_manager(),
|
|
"storage": get_storage(),
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def test_client(oauth_components):
|
|
"""Create a test OAuth client."""
|
|
client_registry = oauth_components["client_registry"]
|
|
|
|
client_id, client_secret = client_registry.create_client(
|
|
client_name="Resource Test Client",
|
|
redirect_uris=["http://localhost:3000/callback"],
|
|
grant_types=["authorization_code", "refresh_token"],
|
|
allowed_scopes=["read", "write"],
|
|
)
|
|
|
|
return {
|
|
"client_id": client_id,
|
|
"client_secret": client_secret,
|
|
"redirect_uri": "http://localhost:3000/callback",
|
|
}
|
|
|
|
|
|
# --- AuthorizationCode schema tests ---
|
|
|
|
|
|
def test_authorization_code_accepts_resource():
|
|
"""Test that AuthorizationCode model accepts and stores the resource field."""
|
|
auth_code = AuthorizationCode(
|
|
code="auth_test123",
|
|
client_id="test_client",
|
|
redirect_uri="http://localhost:3000/callback",
|
|
scope="read write",
|
|
code_challenge="abc123",
|
|
code_challenge_method="S256",
|
|
expires_at=datetime.now(UTC) + timedelta(minutes=5),
|
|
resource="https://mcp.example.com",
|
|
)
|
|
|
|
assert auth_code.resource == "https://mcp.example.com"
|
|
|
|
|
|
def test_authorization_code_resource_defaults_to_none():
|
|
"""Test that AuthorizationCode resource defaults to None when not provided."""
|
|
auth_code = AuthorizationCode(
|
|
code="auth_test456",
|
|
client_id="test_client",
|
|
redirect_uri="http://localhost:3000/callback",
|
|
scope="read",
|
|
code_challenge="xyz789",
|
|
code_challenge_method="S256",
|
|
expires_at=datetime.now(UTC) + timedelta(minutes=5),
|
|
)
|
|
|
|
assert auth_code.resource is None
|
|
|
|
|
|
# --- create_authorization_code tests ---
|
|
|
|
|
|
def test_create_authorization_code_with_resource(oauth_components, test_client):
|
|
"""Test that create_authorization_code accepts and stores resource parameter."""
|
|
from core.oauth import generate_code_challenge, generate_code_verifier
|
|
|
|
code_verifier = generate_code_verifier()
|
|
code_challenge = generate_code_challenge(code_verifier)
|
|
|
|
oauth_server = oauth_components["server"]
|
|
resource_url = "https://mcp.example.com"
|
|
|
|
code = oauth_server.create_authorization_code(
|
|
client_id=test_client["client_id"],
|
|
redirect_uri=test_client["redirect_uri"],
|
|
scope="read write",
|
|
code_challenge=code_challenge,
|
|
code_challenge_method="S256",
|
|
resource=resource_url,
|
|
)
|
|
|
|
assert code.startswith("auth_")
|
|
|
|
# Verify resource is stored in the authorization code
|
|
stored_code = oauth_components["storage"].get_authorization_code(code)
|
|
assert stored_code is not None
|
|
assert stored_code.resource == resource_url
|
|
|
|
|
|
def test_create_authorization_code_without_resource(oauth_components, test_client):
|
|
"""Test that create_authorization_code works without resource (backward compat)."""
|
|
from core.oauth import generate_code_challenge, generate_code_verifier
|
|
|
|
code_verifier = generate_code_verifier()
|
|
code_challenge = generate_code_challenge(code_verifier)
|
|
|
|
oauth_server = oauth_components["server"]
|
|
|
|
code = oauth_server.create_authorization_code(
|
|
client_id=test_client["client_id"],
|
|
redirect_uri=test_client["redirect_uri"],
|
|
scope="read write",
|
|
code_challenge=code_challenge,
|
|
code_challenge_method="S256",
|
|
)
|
|
|
|
assert code.startswith("auth_")
|
|
|
|
stored_code = oauth_components["storage"].get_authorization_code(code)
|
|
assert stored_code is not None
|
|
assert stored_code.resource is None
|
|
|
|
|
|
# --- generate_access_token / JWT aud claim tests ---
|
|
|
|
|
|
def test_generate_access_token_with_resource_sets_aud(token_manager):
|
|
"""Test that resource parameter flows through to JWT aud claim."""
|
|
resource_url = "https://mcp.example.com"
|
|
|
|
token = token_manager.generate_access_token(
|
|
client_id="test_client",
|
|
scope="read write",
|
|
user_id="user_123",
|
|
resource=resource_url,
|
|
)
|
|
|
|
# Decode JWT and verify aud claim
|
|
payload = jwt.decode(
|
|
token,
|
|
"test_secret_key_resource",
|
|
algorithms=["HS256"],
|
|
audience=resource_url,
|
|
)
|
|
assert payload["aud"] == resource_url
|
|
assert payload["client_id"] == "test_client"
|
|
assert payload["sub"] == "user_123"
|
|
|
|
|
|
def test_generate_access_token_without_resource_no_aud(token_manager):
|
|
"""Test that JWT has no aud claim when resource is not provided."""
|
|
token = token_manager.generate_access_token(
|
|
client_id="test_client",
|
|
scope="read write",
|
|
user_id="user_123",
|
|
)
|
|
|
|
payload = jwt.decode(
|
|
token,
|
|
"test_secret_key_resource",
|
|
algorithms=["HS256"],
|
|
)
|
|
assert "aud" not in payload
|
|
assert payload["client_id"] == "test_client"
|
|
|
|
|
|
def test_generate_access_token_resource_none_no_aud(token_manager):
|
|
"""Test that resource=None does not add aud claim."""
|
|
token = token_manager.generate_access_token(
|
|
client_id="test_client",
|
|
scope="read",
|
|
resource=None,
|
|
)
|
|
|
|
payload = jwt.decode(
|
|
token,
|
|
"test_secret_key_resource",
|
|
algorithms=["HS256"],
|
|
)
|
|
assert "aud" not in payload
|
|
|
|
|
|
def test_generate_access_token_resource_empty_string_no_aud(token_manager):
|
|
"""Test that empty string resource does not add aud claim."""
|
|
token = token_manager.generate_access_token(
|
|
client_id="test_client",
|
|
scope="read",
|
|
resource="",
|
|
)
|
|
|
|
payload = jwt.decode(
|
|
token,
|
|
"test_secret_key_resource",
|
|
algorithms=["HS256"],
|
|
)
|
|
assert "aud" not in payload
|
|
|
|
|
|
# --- Full flow: resource through code exchange ---
|
|
|
|
|
|
def test_full_flow_resource_to_jwt_aud(oauth_components, test_client):
|
|
"""Test that resource flows from auth code creation through to JWT aud claim."""
|
|
from core.oauth import generate_code_challenge, generate_code_verifier
|
|
|
|
code_verifier = generate_code_verifier()
|
|
code_challenge = generate_code_challenge(code_verifier)
|
|
|
|
oauth_server = oauth_components["server"]
|
|
resource_url = "https://mcp.example.com"
|
|
|
|
# Step 1: Create authorization code with resource
|
|
code = oauth_server.create_authorization_code(
|
|
client_id=test_client["client_id"],
|
|
redirect_uri=test_client["redirect_uri"],
|
|
scope="read write",
|
|
code_challenge=code_challenge,
|
|
code_challenge_method="S256",
|
|
api_key_id="master",
|
|
api_key_project_id="*",
|
|
api_key_scope="read write",
|
|
resource=resource_url,
|
|
)
|
|
|
|
# Step 2: Exchange code for tokens
|
|
token_response = oauth_server.exchange_code_for_tokens(
|
|
client_id=test_client["client_id"],
|
|
client_secret=test_client["client_secret"],
|
|
code=code,
|
|
redirect_uri=test_client["redirect_uri"],
|
|
code_verifier=code_verifier,
|
|
)
|
|
|
|
# Step 3: Verify JWT contains aud claim
|
|
payload = jwt.decode(
|
|
token_response.access_token,
|
|
os.environ["OAUTH_JWT_SECRET_KEY"],
|
|
algorithms=["HS256"],
|
|
audience=resource_url,
|
|
)
|
|
assert payload["aud"] == resource_url
|
|
|
|
|
|
def test_full_flow_without_resource_no_aud(oauth_components, test_client):
|
|
"""Test that full flow without resource produces JWT without aud claim."""
|
|
from core.oauth import generate_code_challenge, generate_code_verifier
|
|
|
|
code_verifier = generate_code_verifier()
|
|
code_challenge = generate_code_challenge(code_verifier)
|
|
|
|
oauth_server = oauth_components["server"]
|
|
|
|
# Step 1: Create authorization code WITHOUT resource
|
|
code = oauth_server.create_authorization_code(
|
|
client_id=test_client["client_id"],
|
|
redirect_uri=test_client["redirect_uri"],
|
|
scope="read write",
|
|
code_challenge=code_challenge,
|
|
code_challenge_method="S256",
|
|
api_key_id="master",
|
|
api_key_project_id="*",
|
|
api_key_scope="read write",
|
|
)
|
|
|
|
# Step 2: Exchange code for tokens
|
|
token_response = oauth_server.exchange_code_for_tokens(
|
|
client_id=test_client["client_id"],
|
|
client_secret=test_client["client_secret"],
|
|
code=code,
|
|
redirect_uri=test_client["redirect_uri"],
|
|
code_verifier=code_verifier,
|
|
)
|
|
|
|
# Step 3: Verify JWT does NOT contain aud claim
|
|
payload = jwt.decode(
|
|
token_response.access_token,
|
|
os.environ["OAUTH_JWT_SECRET_KEY"],
|
|
algorithms=["HS256"],
|
|
)
|
|
assert "aud" not in payload
|