Skip to content

Commit 408023f

Browse files
committed
Add OAuth token, introspect, and revoke endpoints
1 parent 7762013 commit 408023f

File tree

5 files changed

+180
-5
lines changed

5 files changed

+180
-5
lines changed

notion_client/api_endpoints.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,3 +468,54 @@ def send(self, file_upload_id: str, **kwargs: Any) -> SyncAsync[Any]:
468468
form_data=pick(kwargs, "file", "part_number"),
469469
auth=kwargs.get("auth"),
470470
)
471+
472+
473+
class OAuthEndpoint(Endpoint):
474+
def token(
475+
self, client_id: str, client_secret: str, **kwargs: Any
476+
) -> SyncAsync[Any]:
477+
"""Get token.
478+
479+
*[🔗 Endpoint documentation](https://developers.notion.com/reference/create-a-token)*
480+
""" # noqa: E501
481+
return self.parent.request(
482+
path="oauth/token",
483+
method="POST",
484+
body=pick(
485+
kwargs,
486+
"grant_type",
487+
"code",
488+
"redirect_uri",
489+
"external_account",
490+
"refresh_token",
491+
),
492+
auth={"client_id": client_id, "client_secret": client_secret},
493+
)
494+
495+
def introspect(
496+
self, client_id: str, client_secret: str, **kwargs: Any
497+
) -> SyncAsync[Any]:
498+
"""Introspect token.
499+
500+
*[🔗 Endpoint documentation](https://developers.notion.com/reference/oauth-introspect)*
501+
""" # noqa: E501
502+
return self.parent.request(
503+
path="oauth/introspect",
504+
method="POST",
505+
body=pick(kwargs, "token"),
506+
auth={"client_id": client_id, "client_secret": client_secret},
507+
)
508+
509+
def revoke(
510+
self, client_id: str, client_secret: str, **kwargs: Any
511+
) -> SyncAsync[Any]:
512+
"""Revoke token.
513+
514+
*[🔗 Endpoint documentation](https://developers.notion.com/reference/oauth-revoke)*
515+
""" # noqa: E501
516+
return self.parent.request(
517+
path="oauth/revoke",
518+
method="POST",
519+
body=pick(kwargs, "token"),
520+
auth={"client_id": client_id, "client_secret": client_secret},
521+
)

notion_client/client.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Synchronous and asynchronous clients for Notion's API."""
22

3+
import base64
34
import json
45
import logging
56
from abc import abstractmethod
@@ -19,6 +20,7 @@
1920
SearchEndpoint,
2021
UsersEndpoint,
2122
FileUploadsEndpoint,
23+
OAuthEndpoint,
2224
)
2325
from notion_client.errors import (
2426
APIResponseError,
@@ -82,6 +84,7 @@ def __init__(
8284
self.search = SearchEndpoint(self)
8385
self.comments = CommentsEndpoint(self)
8486
self.file_uploads = FileUploadsEndpoint(self)
87+
self.oauth = OAuthEndpoint(self)
8588

8689
@property
8790
def client(self) -> Union[httpx.Client, httpx.AsyncClient]:
@@ -108,11 +111,18 @@ def _build_request(
108111
query: Optional[Dict[Any, Any]] = None,
109112
body: Optional[Dict[Any, Any]] = None,
110113
form_data: Optional[Dict[Any, Any]] = None,
111-
auth: Optional[str] = None,
114+
auth: Optional[Union[str, Dict[str, str]]] = None,
112115
) -> Request:
113116
headers = httpx.Headers()
114117
if auth:
115-
headers["Authorization"] = f"Bearer {auth}"
118+
if isinstance(auth, dict):
119+
client_id = auth.get("client_id", "")
120+
client_secret = auth.get("client_secret", "")
121+
credentials = f"{client_id}:{client_secret}"
122+
encoded_credentials = base64.b64encode(credentials.encode()).decode()
123+
headers["Authorization"] = f"Basic {encoded_credentials}"
124+
else:
125+
headers["Authorization"] = f"Bearer {auth}"
116126
self.logger.info(f"{method} {self.client.base_url}{path}")
117127
self.logger.debug(f"=> {query} -- {body} -- {form_data}")
118128

@@ -182,7 +192,7 @@ def request(
182192
query: Optional[Dict[Any, Any]] = None,
183193
body: Optional[Dict[Any, Any]] = None,
184194
form_data: Optional[Dict[Any, Any]] = None,
185-
auth: Optional[str] = None,
195+
auth: Optional[Union[str, Dict[str, str]]] = None,
186196
) -> SyncAsync[Any]:
187197
# noqa
188198
pass
@@ -228,7 +238,7 @@ def request(
228238
query: Optional[Dict[Any, Any]] = None,
229239
body: Optional[Dict[Any, Any]] = None,
230240
form_data: Optional[Dict[Any, Any]] = None,
231-
auth: Optional[str] = None,
241+
auth: Optional[Union[str, Dict[str, str]]] = None,
232242
) -> Any:
233243
"""Send an HTTP request."""
234244
request = self._build_request(method, path, query, body, form_data, auth)
@@ -279,7 +289,7 @@ async def request(
279289
query: Optional[Dict[Any, Any]] = None,
280290
body: Optional[Dict[Any, Any]] = None,
281291
form_data: Optional[Dict[Any, Any]] = None,
282-
auth: Optional[str] = None,
292+
auth: Optional[Union[str, Dict[str, str]]] = None,
283293
) -> Any:
284294
"""Send an HTTP request asynchronously."""
285295
request = self._build_request(method, path, query, body, form_data, auth)

requirements/tests.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
pytest
33
pytest-asyncio
44
pytest-cov
5+
pytest-mock
56
pytest-timeout
67
pytest-vcr
78
vcrpy==6.0.2

tests/conftest.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,24 @@ def token() -> Optional[str]:
5151
return os.environ.get("NOTION_TOKEN")
5252

5353

54+
@pytest.fixture(scope="session")
55+
def oauth_client_id() -> Optional[str]:
56+
"""OAuth client ID for testing OAuth endpoints"""
57+
return os.environ.get("NOTION_OAUTH_CLIENT_ID")
58+
59+
60+
@pytest.fixture(scope="session")
61+
def oauth_client_secret() -> Optional[str]:
62+
"""OAuth client secret for testing OAuth endpoints"""
63+
return os.environ.get("NOTION_OAUTH_CLIENT_SECRET")
64+
65+
66+
@pytest.fixture(scope="session")
67+
def oauth_token() -> Optional[str]:
68+
"""OAuth token for testing OAuth introspect and revoke endpoints"""
69+
return os.environ.get("NOTION_OAUTH_TOKEN")
70+
71+
5472
@pytest.fixture(scope="module", autouse=True)
5573
def parent_page_id(vcr) -> str:
5674
"""this is the ID of the Notion page where the tests will be executed

tests/test_endpoints.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,3 +393,98 @@ def test_file_uploads_complete(client, part_uploaded_file_upload_id):
393393
assert response["content_type"] == "text/plain"
394394
assert response["number_of_parts"]["total"] == 3
395395
assert response["number_of_parts"]["sent"] == 3
396+
397+
398+
def test_oauth_introspect(client, mocker):
399+
"""Test OAuth token introspection with mock"""
400+
mock_response = {"active": False, "request_id": "test-request-id"}
401+
mock_request = mocker.patch.object(client, "request", return_value=mock_response)
402+
403+
response = client.oauth.introspect(
404+
client_id="test_client_id",
405+
client_secret="test_client_secret",
406+
token="test_token",
407+
)
408+
409+
assert "active" in response
410+
assert isinstance(response["active"], bool)
411+
mock_request.assert_called_once_with(
412+
path="oauth/introspect",
413+
method="POST",
414+
body={"token": "test_token"},
415+
auth={"client_id": "test_client_id", "client_secret": "test_client_secret"},
416+
)
417+
418+
419+
def test_oauth_revoke(client, mocker):
420+
"""Test OAuth token revocation with mock (can't use cassette - token becomes invalid)"""
421+
mock_response = {}
422+
mock_request = mocker.patch.object(client, "request", return_value=mock_response)
423+
424+
response = client.oauth.revoke(
425+
client_id="test_client_id",
426+
client_secret="test_client_secret",
427+
token="test_token",
428+
)
429+
430+
assert response == {}
431+
mock_request.assert_called_once_with(
432+
path="oauth/revoke",
433+
method="POST",
434+
body={"token": "test_token"},
435+
auth={"client_id": "test_client_id", "client_secret": "test_client_secret"},
436+
)
437+
438+
439+
def test_oauth_token_authorization_code(client, mocker):
440+
mock_response = {
441+
"access_token": "secret_test_token",
442+
"token_type": "bearer",
443+
"bot_id": "bot_123",
444+
"workspace_id": "ws_456",
445+
"workspace_name": "Test Workspace",
446+
"owner": {"type": "user", "user": {"object": "user", "id": "user_789"}},
447+
}
448+
449+
mock_request = mocker.patch.object(client, "request", return_value=mock_response)
450+
451+
response = client.oauth.token(
452+
client_id="test_client_id",
453+
client_secret="test_client_secret",
454+
grant_type="authorization_code",
455+
code="test_code",
456+
redirect_uri="http://localhost:3000/callback",
457+
)
458+
459+
assert response["access_token"] == "secret_test_token"
460+
assert response["bot_id"] == "bot_123"
461+
mock_request.assert_called_once()
462+
call_kwargs = mock_request.call_args[1]
463+
assert call_kwargs["path"] == "oauth/token"
464+
assert call_kwargs["method"] == "POST"
465+
assert call_kwargs["auth"] == {
466+
"client_id": "test_client_id",
467+
"client_secret": "test_client_secret",
468+
}
469+
470+
471+
def test_oauth_token_refresh_token(client, mocker):
472+
mock_response = {
473+
"access_token": "secret_refreshed_token",
474+
"token_type": "bearer",
475+
"bot_id": "bot_123",
476+
}
477+
478+
mock_request = mocker.patch.object(client, "request", return_value=mock_response)
479+
480+
response = client.oauth.token(
481+
client_id="test_client_id",
482+
client_secret="test_client_secret",
483+
grant_type="refresh_token",
484+
refresh_token="test_refresh_token",
485+
)
486+
487+
assert response["access_token"] == "secret_refreshed_token"
488+
mock_request.assert_called_once()
489+
call_kwargs = mock_request.call_args[1]
490+
assert call_kwargs["path"] == "oauth/token"

0 commit comments

Comments
 (0)