Skip to content

Commit 54184aa

Browse files
committed
Add OAuth token, introspect, and revoke endpoints
1 parent 7762013 commit 54184aa

File tree

5 files changed

+193
-5
lines changed

5 files changed

+193
-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: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,3 +393,111 @@ 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 - tests Basic auth encoding"""
400+
mock_response = {"active": False, "request_id": "test-request-id"}
401+
402+
# Mock at HTTP level to test Basic auth header encoding
403+
mock_send = mocker.patch.object(
404+
client.client,
405+
"send",
406+
return_value=mocker.Mock(
407+
json=lambda: mock_response, raise_for_status=lambda: None
408+
),
409+
)
410+
411+
response = client.oauth.introspect(
412+
client_id="test_client_id",
413+
client_secret="test_client_secret",
414+
token="test_token",
415+
)
416+
417+
assert "active" in response
418+
assert isinstance(response["active"], bool)
419+
420+
# Verify the Basic auth header was properly encoded
421+
mock_send.assert_called_once()
422+
request = mock_send.call_args[0][0]
423+
assert "Authorization" in request.headers
424+
assert request.headers["Authorization"].startswith("Basic ")
425+
# The base64 of "test_client_id:test_client_secret" is "dGVzdF9jbGllbnRfaWQ6dGVzdF9jbGllbnRfc2VjcmV0"
426+
assert (
427+
request.headers["Authorization"]
428+
== "Basic dGVzdF9jbGllbnRfaWQ6dGVzdF9jbGllbnRfc2VjcmV0"
429+
)
430+
431+
432+
def test_oauth_revoke(client, mocker):
433+
"""Test OAuth token revocation with mock (can't use cassette - token becomes invalid)"""
434+
mock_response = {}
435+
mock_request = mocker.patch.object(client, "request", return_value=mock_response)
436+
437+
response = client.oauth.revoke(
438+
client_id="test_client_id",
439+
client_secret="test_client_secret",
440+
token="test_token",
441+
)
442+
443+
assert response == {}
444+
mock_request.assert_called_once_with(
445+
path="oauth/revoke",
446+
method="POST",
447+
body={"token": "test_token"},
448+
auth={"client_id": "test_client_id", "client_secret": "test_client_secret"},
449+
)
450+
451+
452+
def test_oauth_token_authorization_code(client, mocker):
453+
mock_response = {
454+
"access_token": "secret_test_token",
455+
"token_type": "bearer",
456+
"bot_id": "bot_123",
457+
"workspace_id": "ws_456",
458+
"workspace_name": "Test Workspace",
459+
"owner": {"type": "user", "user": {"object": "user", "id": "user_789"}},
460+
}
461+
462+
mock_request = mocker.patch.object(client, "request", return_value=mock_response)
463+
464+
response = client.oauth.token(
465+
client_id="test_client_id",
466+
client_secret="test_client_secret",
467+
grant_type="authorization_code",
468+
code="test_code",
469+
redirect_uri="http://localhost:3000/callback",
470+
)
471+
472+
assert response["access_token"] == "secret_test_token"
473+
assert response["bot_id"] == "bot_123"
474+
mock_request.assert_called_once()
475+
call_kwargs = mock_request.call_args[1]
476+
assert call_kwargs["path"] == "oauth/token"
477+
assert call_kwargs["method"] == "POST"
478+
assert call_kwargs["auth"] == {
479+
"client_id": "test_client_id",
480+
"client_secret": "test_client_secret",
481+
}
482+
483+
484+
def test_oauth_token_refresh_token(client, mocker):
485+
mock_response = {
486+
"access_token": "secret_refreshed_token",
487+
"token_type": "bearer",
488+
"bot_id": "bot_123",
489+
}
490+
491+
mock_request = mocker.patch.object(client, "request", return_value=mock_response)
492+
493+
response = client.oauth.token(
494+
client_id="test_client_id",
495+
client_secret="test_client_secret",
496+
grant_type="refresh_token",
497+
refresh_token="test_refresh_token",
498+
)
499+
500+
assert response["access_token"] == "secret_refreshed_token"
501+
mock_request.assert_called_once()
502+
call_kwargs = mock_request.call_args[1]
503+
assert call_kwargs["path"] == "oauth/token"

0 commit comments

Comments
 (0)