Skip to content

Commit 7c4b89e

Browse files
committed
Add OAuth token, introspect, and revoke endpoints
1 parent 7762013 commit 7c4b89e

File tree

5 files changed

+252
-5
lines changed

5 files changed

+252
-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: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,3 +393,170 @@ 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_send = mocker.patch.object(
403+
client.client,
404+
"send",
405+
return_value=mocker.Mock(
406+
json=lambda: mock_response, raise_for_status=lambda: None
407+
),
408+
)
409+
410+
response = client.oauth.introspect(
411+
client_id="test_client_id",
412+
client_secret="test_client_secret",
413+
token="test_token",
414+
)
415+
416+
assert "active" in response
417+
assert isinstance(response["active"], bool)
418+
419+
mock_send.assert_called_once()
420+
request = mock_send.call_args[0][0]
421+
assert "Authorization" in request.headers
422+
assert request.headers["Authorization"].startswith("Basic ")
423+
assert (
424+
request.headers["Authorization"]
425+
== "Basic dGVzdF9jbGllbnRfaWQ6dGVzdF9jbGllbnRfc2VjcmV0"
426+
)
427+
428+
429+
def test_oauth_token_with_basic_auth(client, mocker):
430+
"""Test OAuth token exchange with Basic auth - exercises auth encoding path"""
431+
mock_response = {
432+
"access_token": "secret_test_token",
433+
"token_type": "bearer",
434+
"bot_id": "bot_123",
435+
}
436+
437+
mock_send = mocker.patch.object(
438+
client.client,
439+
"send",
440+
return_value=mocker.Mock(
441+
json=lambda: mock_response, raise_for_status=lambda: None
442+
),
443+
)
444+
445+
response = client.oauth.token(
446+
client_id="test_client_id",
447+
client_secret="test_client_secret",
448+
grant_type="authorization_code",
449+
code="test_code",
450+
redirect_uri="http://localhost:3000/callback",
451+
)
452+
453+
assert response["access_token"] == "secret_test_token"
454+
455+
mock_send.assert_called_once()
456+
request = mock_send.call_args[0][0]
457+
assert "Authorization" in request.headers
458+
assert request.headers["Authorization"].startswith("Basic ")
459+
import base64
460+
461+
expected = base64.b64encode(b"test_client_id:test_client_secret").decode()
462+
assert request.headers["Authorization"] == f"Basic {expected}"
463+
464+
465+
def test_oauth_revoke_with_basic_auth(client, mocker):
466+
"""Test OAuth revoke with Basic auth - exercises auth encoding path"""
467+
mock_response = {}
468+
469+
mock_send = mocker.patch.object(
470+
client.client,
471+
"send",
472+
return_value=mocker.Mock(
473+
json=lambda: mock_response, raise_for_status=lambda: None
474+
),
475+
)
476+
477+
response = client.oauth.revoke(
478+
client_id="test_client_id",
479+
client_secret="test_client_secret",
480+
token="test_token",
481+
)
482+
483+
assert response == {}
484+
485+
mock_send.assert_called_once()
486+
request = mock_send.call_args[0][0]
487+
assert "Authorization" in request.headers
488+
assert request.headers["Authorization"].startswith("Basic ")
489+
490+
491+
def test_oauth_revoke(client, mocker):
492+
"""Test OAuth token revocation with mock (can't use cassette - token becomes invalid)"""
493+
mock_response = {}
494+
mock_request = mocker.patch.object(client, "request", return_value=mock_response)
495+
496+
response = client.oauth.revoke(
497+
client_id="test_client_id",
498+
client_secret="test_client_secret",
499+
token="test_token",
500+
)
501+
502+
assert response == {}
503+
mock_request.assert_called_once_with(
504+
path="oauth/revoke",
505+
method="POST",
506+
body={"token": "test_token"},
507+
auth={"client_id": "test_client_id", "client_secret": "test_client_secret"},
508+
)
509+
510+
511+
def test_oauth_token_authorization_code(client, mocker):
512+
mock_response = {
513+
"access_token": "secret_test_token",
514+
"token_type": "bearer",
515+
"bot_id": "bot_123",
516+
"workspace_id": "ws_456",
517+
"workspace_name": "Test Workspace",
518+
"owner": {"type": "user", "user": {"object": "user", "id": "user_789"}},
519+
}
520+
521+
mock_request = mocker.patch.object(client, "request", return_value=mock_response)
522+
523+
response = client.oauth.token(
524+
client_id="test_client_id",
525+
client_secret="test_client_secret",
526+
grant_type="authorization_code",
527+
code="test_code",
528+
redirect_uri="http://localhost:3000/callback",
529+
)
530+
531+
assert response["access_token"] == "secret_test_token"
532+
assert response["bot_id"] == "bot_123"
533+
mock_request.assert_called_once()
534+
call_kwargs = mock_request.call_args[1]
535+
assert call_kwargs["path"] == "oauth/token"
536+
assert call_kwargs["method"] == "POST"
537+
assert call_kwargs["auth"] == {
538+
"client_id": "test_client_id",
539+
"client_secret": "test_client_secret",
540+
}
541+
542+
543+
def test_oauth_token_refresh_token(client, mocker):
544+
mock_response = {
545+
"access_token": "secret_refreshed_token",
546+
"token_type": "bearer",
547+
"bot_id": "bot_123",
548+
}
549+
550+
mock_request = mocker.patch.object(client, "request", return_value=mock_response)
551+
552+
response = client.oauth.token(
553+
client_id="test_client_id",
554+
client_secret="test_client_secret",
555+
grant_type="refresh_token",
556+
refresh_token="test_refresh_token",
557+
)
558+
559+
assert response["access_token"] == "secret_refreshed_token"
560+
mock_request.assert_called_once()
561+
call_kwargs = mock_request.call_args[1]
562+
assert call_kwargs["path"] == "oauth/token"

0 commit comments

Comments
 (0)