diff --git a/notion_client/api_endpoints.py b/notion_client/api_endpoints.py index bc5447b6..d1feeb53 100644 --- a/notion_client/api_endpoints.py +++ b/notion_client/api_endpoints.py @@ -468,3 +468,54 @@ def send(self, file_upload_id: str, **kwargs: Any) -> SyncAsync[Any]: form_data=pick(kwargs, "file", "part_number"), auth=kwargs.get("auth"), ) + + +class OAuthEndpoint(Endpoint): + def token( + self, client_id: str, client_secret: str, **kwargs: Any + ) -> SyncAsync[Any]: + """Create an access token that a third-party service can use to authenticate with Notion. + + *[🔗 Endpoint documentation](https://developers.notion.com/reference/create-a-token)* + """ # noqa: E501 + return self.parent.request( + path="oauth/token", + method="POST", + body=pick( + kwargs, + "grant_type", + "code", + "redirect_uri", + "external_account", + "refresh_token", + ), + auth={"client_id": client_id, "client_secret": client_secret}, + ) + + def introspect( + self, client_id: str, client_secret: str, **kwargs: Any + ) -> SyncAsync[Any]: + """Get a token's active status, scope, and issued time. + + *[🔗 Endpoint documentation](https://developers.notion.com/reference/introspect-token)* + """ # noqa: E501 + return self.parent.request( + path="oauth/introspect", + method="POST", + body=pick(kwargs, "token"), + auth={"client_id": client_id, "client_secret": client_secret}, + ) + + def revoke( + self, client_id: str, client_secret: str, **kwargs: Any + ) -> SyncAsync[Any]: + """Revoke an access token. + + *[🔗 Endpoint documentation](https://developers.notion.com/reference/revoke-token)* + """ # noqa: E501 + return self.parent.request( + path="oauth/revoke", + method="POST", + body=pick(kwargs, "token"), + auth={"client_id": client_id, "client_secret": client_secret}, + ) diff --git a/notion_client/client.py b/notion_client/client.py index dd553d78..841b4bd4 100644 --- a/notion_client/client.py +++ b/notion_client/client.py @@ -1,5 +1,6 @@ """Synchronous and asynchronous clients for Notion's API.""" +import base64 import json import logging from abc import abstractmethod @@ -19,6 +20,7 @@ SearchEndpoint, UsersEndpoint, FileUploadsEndpoint, + OAuthEndpoint, ) from notion_client.errors import ( APIResponseError, @@ -82,6 +84,7 @@ def __init__( self.search = SearchEndpoint(self) self.comments = CommentsEndpoint(self) self.file_uploads = FileUploadsEndpoint(self) + self.oauth = OAuthEndpoint(self) @property def client(self) -> Union[httpx.Client, httpx.AsyncClient]: @@ -108,11 +111,18 @@ def _build_request( query: Optional[Dict[Any, Any]] = None, body: Optional[Dict[Any, Any]] = None, form_data: Optional[Dict[Any, Any]] = None, - auth: Optional[str] = None, + auth: Optional[Union[str, Dict[str, str]]] = None, ) -> Request: headers = httpx.Headers() if auth: - headers["Authorization"] = f"Bearer {auth}" + if isinstance(auth, dict): + client_id = auth.get("client_id", "") + client_secret = auth.get("client_secret", "") + credentials = f"{client_id}:{client_secret}" + encoded_credentials = base64.b64encode(credentials.encode()).decode() + headers["Authorization"] = f"Basic {encoded_credentials}" + else: + headers["Authorization"] = f"Bearer {auth}" self.logger.info(f"{method} {self.client.base_url}{path}") self.logger.debug(f"=> {query} -- {body} -- {form_data}") @@ -182,7 +192,7 @@ def request( query: Optional[Dict[Any, Any]] = None, body: Optional[Dict[Any, Any]] = None, form_data: Optional[Dict[Any, Any]] = None, - auth: Optional[str] = None, + auth: Optional[Union[str, Dict[str, str]]] = None, ) -> SyncAsync[Any]: # noqa pass @@ -228,7 +238,7 @@ def request( query: Optional[Dict[Any, Any]] = None, body: Optional[Dict[Any, Any]] = None, form_data: Optional[Dict[Any, Any]] = None, - auth: Optional[str] = None, + auth: Optional[Union[str, Dict[str, str]]] = None, ) -> Any: """Send an HTTP request.""" request = self._build_request(method, path, query, body, form_data, auth) @@ -279,7 +289,7 @@ async def request( query: Optional[Dict[Any, Any]] = None, body: Optional[Dict[Any, Any]] = None, form_data: Optional[Dict[Any, Any]] = None, - auth: Optional[str] = None, + auth: Optional[Union[str, Dict[str, str]]] = None, ) -> Any: """Send an HTTP request asynchronously.""" request = self._build_request(method, path, query, body, form_data, auth) diff --git a/requirements/tests.txt b/requirements/tests.txt index 70f900c7..4c0e9c26 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -2,6 +2,7 @@ pytest pytest-asyncio pytest-cov +pytest-mock pytest-timeout pytest-vcr vcrpy==6.0.2 diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 74869571..b7423540 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -393,3 +393,170 @@ def test_file_uploads_complete(client, part_uploaded_file_upload_id): assert response["content_type"] == "text/plain" assert response["number_of_parts"]["total"] == 3 assert response["number_of_parts"]["sent"] == 3 + + +def test_oauth_introspect(client, mocker): + """Test OAuth token introspection with mock - tests Basic auth encoding""" + mock_response = {"active": False, "request_id": "test-request-id"} + + mock_send = mocker.patch.object( + client.client, + "send", + return_value=mocker.Mock( + json=lambda: mock_response, raise_for_status=lambda: None + ), + ) + + response = client.oauth.introspect( + client_id="test_client_id", + client_secret="test_client_secret", + token="test_token", + ) + + assert "active" in response + assert isinstance(response["active"], bool) + + mock_send.assert_called_once() + request = mock_send.call_args[0][0] + assert "Authorization" in request.headers + assert request.headers["Authorization"].startswith("Basic ") + assert ( + request.headers["Authorization"] + == "Basic dGVzdF9jbGllbnRfaWQ6dGVzdF9jbGllbnRfc2VjcmV0" + ) + + +def test_oauth_token_with_basic_auth(client, mocker): + """Test OAuth token exchange with Basic auth - exercises auth encoding path""" + mock_response = { + "access_token": "secret_test_token", + "token_type": "bearer", + "bot_id": "bot_123", + } + + mock_send = mocker.patch.object( + client.client, + "send", + return_value=mocker.Mock( + json=lambda: mock_response, raise_for_status=lambda: None + ), + ) + + response = client.oauth.token( + client_id="test_client_id", + client_secret="test_client_secret", + grant_type="authorization_code", + code="test_code", + redirect_uri="http://localhost:3000/callback", + ) + + assert response["access_token"] == "secret_test_token" + + mock_send.assert_called_once() + request = mock_send.call_args[0][0] + assert "Authorization" in request.headers + assert request.headers["Authorization"].startswith("Basic ") + import base64 + + expected = base64.b64encode(b"test_client_id:test_client_secret").decode() + assert request.headers["Authorization"] == f"Basic {expected}" + + +def test_oauth_revoke_with_basic_auth(client, mocker): + """Test OAuth revoke with Basic auth - exercises auth encoding path""" + mock_response = {} + + mock_send = mocker.patch.object( + client.client, + "send", + return_value=mocker.Mock( + json=lambda: mock_response, raise_for_status=lambda: None + ), + ) + + response = client.oauth.revoke( + client_id="test_client_id", + client_secret="test_client_secret", + token="test_token", + ) + + assert response == {} + + mock_send.assert_called_once() + request = mock_send.call_args[0][0] + assert "Authorization" in request.headers + assert request.headers["Authorization"].startswith("Basic ") + + +def test_oauth_revoke(client, mocker): + """Test OAuth token revocation with mock (can't use cassette - token becomes invalid)""" + mock_response = {} + mock_request = mocker.patch.object(client, "request", return_value=mock_response) + + response = client.oauth.revoke( + client_id="test_client_id", + client_secret="test_client_secret", + token="test_token", + ) + + assert response == {} + mock_request.assert_called_once_with( + path="oauth/revoke", + method="POST", + body={"token": "test_token"}, + auth={"client_id": "test_client_id", "client_secret": "test_client_secret"}, + ) + + +def test_oauth_token_authorization_code(client, mocker): + mock_response = { + "access_token": "secret_test_token", + "token_type": "bearer", + "bot_id": "bot_123", + "workspace_id": "ws_456", + "workspace_name": "Test Workspace", + "owner": {"type": "user", "user": {"object": "user", "id": "user_789"}}, + } + + mock_request = mocker.patch.object(client, "request", return_value=mock_response) + + response = client.oauth.token( + client_id="test_client_id", + client_secret="test_client_secret", + grant_type="authorization_code", + code="test_code", + redirect_uri="http://localhost:3000/callback", + ) + + assert response["access_token"] == "secret_test_token" + assert response["bot_id"] == "bot_123" + mock_request.assert_called_once() + call_kwargs = mock_request.call_args[1] + assert call_kwargs["path"] == "oauth/token" + assert call_kwargs["method"] == "POST" + assert call_kwargs["auth"] == { + "client_id": "test_client_id", + "client_secret": "test_client_secret", + } + + +def test_oauth_token_refresh_token(client, mocker): + mock_response = { + "access_token": "secret_refreshed_token", + "token_type": "bearer", + "bot_id": "bot_123", + } + + mock_request = mocker.patch.object(client, "request", return_value=mock_response) + + response = client.oauth.token( + client_id="test_client_id", + client_secret="test_client_secret", + grant_type="refresh_token", + refresh_token="test_refresh_token", + ) + + assert response["access_token"] == "secret_refreshed_token" + mock_request.assert_called_once() + call_kwargs = mock_request.call_args[1] + assert call_kwargs["path"] == "oauth/token"