diff --git a/core/oauth/schemas.py b/core/oauth/schemas.py index 5c96236..c2687ec 100644 --- a/core/oauth/schemas.py +++ b/core/oauth/schemas.py @@ -63,6 +63,7 @@ class AuthorizationCode(BaseModel): api_key_id: str | None = None # API Key ID for scope/project inheritance api_key_project_id: str | None = None # Project ID from API Key api_key_scope: str | None = None # Scope from API Key + resource: str | None = None # RFC 8707: Resource indicator def is_expired(self) -> bool: """Check if code is expired""" diff --git a/core/oauth/server.py b/core/oauth/server.py index d6e93b8..6ac83f3 100644 --- a/core/oauth/server.py +++ b/core/oauth/server.py @@ -143,6 +143,7 @@ class OAuthServer: api_key_id: str | None = None, api_key_project_id: str | None = None, api_key_scope: str | None = None, + resource: str | None = None, ) -> str: """ Create authorization code (Step 2 of Authorization Code flow) @@ -157,6 +158,7 @@ class OAuthServer: api_key_id: Optional API Key ID for scope/project inheritance api_key_project_id: Optional project ID from API Key api_key_scope: Optional scope from API Key + resource: Optional resource indicator (RFC 8707) Returns: Authorization code (valid for 5 minutes) @@ -178,6 +180,7 @@ class OAuthServer: api_key_id=api_key_id, api_key_project_id=api_key_project_id, api_key_scope=api_key_scope, + resource=resource, ) # Save to storage @@ -257,12 +260,14 @@ class OAuthServer: # If authorization code has API Key metadata, use it for scoping project_id = auth_code.api_key_project_id or "*" token_scope = auth_code.api_key_scope or auth_code.scope + resource = auth_code.resource access_token = self.token_manager.generate_access_token( client_id=client_id, scope=token_scope, user_id=auth_code.user_id or auth_code.api_key_id, project_id=project_id, + resource=resource, ) refresh_token = self.token_manager.generate_refresh_token( diff --git a/core/oauth/token_manager.py b/core/oauth/token_manager.py index 548cc7a..4c4a89f 100644 --- a/core/oauth/token_manager.py +++ b/core/oauth/token_manager.py @@ -54,7 +54,12 @@ class TokenManager: self.refresh_token_ttl = int(os.getenv("OAUTH_REFRESH_TOKEN_TTL", "604800")) # 7 days def generate_access_token( - self, client_id: str, scope: str, user_id: str | None = None, project_id: str = "*" + self, + client_id: str, + scope: str, + user_id: str | None = None, + project_id: str = "*", + resource: str | None = None, ) -> str: """ Generate JWT access token. @@ -64,6 +69,7 @@ class TokenManager: scope: Granted scopes (space-separated) user_id: User ID (optional, for user-based auth) project_id: Project ID for scoping (default: "*" for global) + resource: Resource indicator for aud claim (RFC 8707) Returns: JWT access token @@ -87,6 +93,9 @@ class TokenManager: if user_id: payload["sub"] = user_id # Subject (user ID) + if resource: + payload["aud"] = resource + # Encode JWT token = jwt.encode(payload, self.jwt_secret, algorithm=self.jwt_algorithm) diff --git a/core/templates/oauth/authorize.html b/core/templates/oauth/authorize.html index 824ffab..cfcc6ad 100644 --- a/core/templates/oauth/authorize.html +++ b/core/templates/oauth/authorize.html @@ -68,6 +68,9 @@ {% if state %} {% endif %} + {% if resource %} + + {% endif %} diff --git a/server.py b/server.py index fbf98b0..661f79e 100644 --- a/server.py +++ b/server.py @@ -18,6 +18,7 @@ Environment Variables: LOG_LEVEL: Logging level (DEBUG, INFO, WARNING, ERROR) """ +import base64 import copy import logging import os @@ -2018,6 +2019,7 @@ async def oauth_metadata(request: Request) -> JSONResponse: "authorization_endpoint": f"{base_url}/oauth/authorize", "token_endpoint": f"{base_url}/oauth/token", "registration_endpoint": f"{base_url}/oauth/register", # RFC 7591 (requires auth) + "revocation_endpoint": f"{base_url}/oauth/revoke", # Supported Features "response_types_supported": ["code"], "grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"], @@ -2025,7 +2027,7 @@ async def oauth_metadata(request: Request) -> JSONResponse: "scopes_supported": ["read", "write", "admin"], "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], # Token Configuration - "revocation_endpoint_auth_methods_supported": ["client_secret_post"], + "revocation_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], "introspection_endpoint_auth_methods_supported": ["client_secret_post"], # Additional OAuth 2.1 Features "response_modes_supported": ["query"], @@ -2380,6 +2382,15 @@ async def oauth_authorize(request: Request): state=params.get("state"), ) + # RFC 8707: Accept resource parameter + resource = params.get("resource") + if resource: + expected_resource = get_oauth_base_url(request) + if resource.rstrip("/") != expected_resource.rstrip("/"): + logger.warning( + f"OAuth resource mismatch: got {resource}, expected {expected_resource}" + ) + # Generate CSRF token csrf_token = csrf_manager.generate_token() @@ -2417,6 +2428,7 @@ async def oauth_authorize(request: Request): "scope": validated["scope"], "scopes": scopes, "state": validated.get("state", ""), + "resource": resource or "", "csrf_token": csrf_token, "lang": lang, # Language code (en/fa) "t": translations, # All translations for this language @@ -2493,6 +2505,7 @@ async def oauth_authorize_confirm(request: Request): # Parse form data form = await request.form() params = dict(form) + resource = params.get("resource", "") # Validate action action = params.get("action") @@ -2616,6 +2629,7 @@ async def oauth_authorize_confirm(request: Request): api_key_id=api_key_id, api_key_project_id=api_key_project_id, api_key_scope=api_key_scope, + resource=resource, ) # Redirect back with authorization code @@ -2708,9 +2722,27 @@ async def oauth_token(request: Request) -> JSONResponse: error="invalid_request", error_description="Missing grant_type parameter" ) + # Support client_secret_basic: extract client credentials from Authorization header + # RFC 6749 Section 2.3.1 — client_id:client_secret as HTTP Basic Auth + if "client_id" not in body or "client_secret" not in body: + auth_header = request.headers.get("authorization", "") + if auth_header.startswith("Basic "): + try: + decoded = base64.b64decode(auth_header[6:]).decode("utf-8") + basic_client_id, basic_client_secret = decoded.split(":", 1) + body.setdefault("client_id", basic_client_id) + body.setdefault("client_secret", basic_client_secret) + except Exception: + raise OAuthError( + error="invalid_client", + error_description="Invalid Basic authentication header", + status_code=401, + ) + if "client_id" not in body or "client_secret" not in body: raise OAuthError( - error="invalid_request", error_description="Missing client_id or client_secret" + error="invalid_request", + error_description="Missing client_id or client_secret", ) grant_type = body["grant_type"] @@ -2773,6 +2805,79 @@ async def oauth_token(request: Request) -> JSONResponse: return JSONResponse({"error": "server_error", "error_description": str(e)}, status_code=500) +@mcp.custom_route("/oauth/revoke", methods=["POST"]) +async def oauth_revoke(request: Request) -> JSONResponse: + """OAuth 2.0 Token Revocation (RFC 7009).""" + try: + content_type = request.headers.get("content-type", "") + if "application/json" in content_type: + body = await request.json() + else: + form = await request.form() + body = dict(form) + + # Support client_secret_basic + if "client_id" not in body or "client_secret" not in body: + auth_header = request.headers.get("authorization", "") + if auth_header.startswith("Basic "): + try: + decoded = base64.b64decode(auth_header[6:]).decode("utf-8") + basic_client_id, basic_client_secret = decoded.split(":", 1) + body.setdefault("client_id", basic_client_id) + body.setdefault("client_secret", basic_client_secret) + except Exception: + pass # Per RFC 7009, don't error on this + + # Validate client credentials + client_id = body.get("client_id") + client_secret = body.get("client_secret") + + if not client_id or not client_secret: + raise OAuthError( + error="invalid_client", + error_description="Client authentication required", + status_code=401, + ) + + from core.oauth import get_client_registry + + client_registry = get_client_registry() + if not client_registry.validate_client_secret(client_id, client_secret): + raise OAuthError( + error="invalid_client", + error_description="Invalid client credentials", + status_code=401, + ) + + # Get token to revoke + token = body.get("token") + if not token: + return JSONResponse({}, status_code=200) + + token_type_hint = body.get("token_type_hint", "") + + # Revoke the token + from core.oauth import get_token_manager + + token_manager = get_token_manager() + + # Try revoking as refresh token (access tokens are JWT/stateless, can't be revoked) + token_manager.revoke_token(token, token_type="refresh") + + logger.info(f"OAuth token revoked: client_id={client_id}, hint={token_type_hint}") + + return JSONResponse({}, status_code=200) + + except OAuthError as e: + return JSONResponse( + {"error": e.error, "error_description": e.error_description}, + status_code=e.status_code, + ) + except Exception as e: + logger.error(f"Token revocation error: {e}") + return JSONResponse({}, status_code=200) + + # === OAUTH CLIENT MANAGEMENT TOOLS === @@ -4661,6 +4766,7 @@ def create_multi_endpoint_app(transport: str = "streamable-http"): Route("/oauth/authorize", oauth_authorize, methods=["GET"]), Route("/oauth/authorize/confirm", oauth_authorize_confirm, methods=["POST"]), Route("/oauth/token", oauth_token, methods=["POST"]), + Route("/oauth/revoke", oauth_revoke, methods=["POST"]), # Multi-Endpoint MCP (Phase X + D.1 + X.3 + F + G + H + I + J) # Mount each FastMCP app - they handle their own /mcp path internally Mount("/system", app=system_app, name="mcp_system"), # Phase X.3 diff --git a/tests/test_oauth_basic_auth.py b/tests/test_oauth_basic_auth.py new file mode 100644 index 0000000..84e2600 --- /dev/null +++ b/tests/test_oauth_basic_auth.py @@ -0,0 +1,165 @@ +"""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" diff --git a/tests/test_oauth_resource.py b/tests/test_oauth_resource.py new file mode 100644 index 0000000..b65399c --- /dev/null +++ b/tests/test_oauth_resource.py @@ -0,0 +1,333 @@ +""" +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 diff --git a/tests/test_oauth_revoke.py b/tests/test_oauth_revoke.py new file mode 100644 index 0000000..87b0715 --- /dev/null +++ b/tests/test_oauth_revoke.py @@ -0,0 +1,227 @@ +"""Tests for OAuth 2.0 Token Revocation endpoint (RFC 7009).""" + +import base64 +import json +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_revoke.""" + 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 + + if "application/json" in content_type: + request.json = AsyncMock(return_value=form_data) + + 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_revoke_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 + + +@pytest.fixture +def registered_client(temp_storage): + """Create a registered OAuth client and return (client_id, client_secret).""" + from core.oauth import get_client_registry + + registry = get_client_registry() + client_id, client_secret = registry.create_client( + client_name="Test Revoke Client", + redirect_uris=["http://localhost:3000/callback"], + ) + return client_id, client_secret + + +async def _call_oauth_revoke(request): + """Import and call the oauth_revoke endpoint from server.py.""" + from server import oauth_revoke + + return await oauth_revoke(request) + + +@pytest.mark.unit +class TestOAuthRevoke: + """Tests for the /oauth/revoke endpoint (RFC 7009).""" + + async def test_valid_token_revocation_returns_200(self, registered_client): + """Valid token revocation returns 200 with empty body.""" + client_id, client_secret = registered_client + + request = _make_request( + body={ + "client_id": client_id, + "client_secret": client_secret, + "token": "rt_some_refresh_token", + }, + ) + + response = await _call_oauth_revoke(request) + + assert response.status_code == 200 + data = json.loads(response.body) + assert data == {} + + async def test_missing_token_returns_200(self, registered_client): + """Per RFC 7009, missing token returns 200 (no-op).""" + client_id, client_secret = registered_client + + request = _make_request( + body={ + "client_id": client_id, + "client_secret": client_secret, + }, + ) + + response = await _call_oauth_revoke(request) + + assert response.status_code == 200 + data = json.loads(response.body) + assert data == {} + + async def test_invalid_client_credentials_returns_401(self, registered_client): + """Invalid client credentials return 401 with invalid_client error.""" + client_id, _ = registered_client + + request = _make_request( + body={ + "client_id": client_id, + "client_secret": "wrong_secret", + "token": "rt_some_token", + }, + ) + + response = await _call_oauth_revoke(request) + + assert response.status_code == 401 + data = json.loads(response.body) + assert data["error"] == "invalid_client" + assert "Invalid client credentials" in data["error_description"] + + async def test_missing_client_credentials_returns_401(self, temp_storage): + """Missing client credentials return 401 with invalid_client error.""" + request = _make_request( + body={ + "token": "rt_some_token", + }, + ) + + response = await _call_oauth_revoke(request) + + assert response.status_code == 401 + data = json.loads(response.body) + assert data["error"] == "invalid_client" + assert "Client authentication required" in data["error_description"] + + async def test_revocation_with_refresh_token_hint(self, registered_client): + """Revocation with token_type_hint=refresh_token works correctly.""" + client_id, client_secret = registered_client + + request = _make_request( + body={ + "client_id": client_id, + "client_secret": client_secret, + "token": "some_token_value", + "token_type_hint": "refresh_token", + }, + ) + + response = await _call_oauth_revoke(request) + + assert response.status_code == 200 + data = json.loads(response.body) + assert data == {} + + async def test_unknown_token_returns_200(self, registered_client): + """Per RFC 7009, revoking an unknown/invalid token still returns 200.""" + client_id, client_secret = registered_client + + request = _make_request( + body={ + "client_id": client_id, + "client_secret": client_secret, + "token": "completely_nonexistent_token_xyz", + }, + ) + + response = await _call_oauth_revoke(request) + + assert response.status_code == 200 + data = json.loads(response.body) + assert data == {} + + async def test_basic_auth_works_for_revocation(self, registered_client): + """Client authentication via Basic Auth header works on the revoke endpoint.""" + client_id, client_secret = registered_client + + request = _make_request( + headers={"authorization": _encode_basic(client_id, client_secret)}, + body={ + "token": "rt_some_refresh_token", + }, + ) + + response = await _call_oauth_revoke(request) + + assert response.status_code == 200 + data = json.loads(response.body) + assert data == {} + + async def test_metadata_includes_revocation_endpoint(self, temp_storage): + """OAuth metadata includes the revocation_endpoint field.""" + from server import oauth_metadata + + # Create a mock request with a host header + request = AsyncMock() + request.headers = MagicMock() + _headers = {"host": "localhost:8000"} + request.headers.get = lambda key, default="": _headers.get(key.lower(), default) + request.url = MagicMock() + request.url.scheme = "http" + + response = await oauth_metadata(request) + + data = json.loads(response.body) + assert "revocation_endpoint" in data + assert data["revocation_endpoint"].endswith("/oauth/revoke") + assert "client_secret_basic" in data.get("revocation_endpoint_auth_methods_supported", [])