Skip to content

Commit e2f8eb1

Browse files
authored
Add support for Notion (#115)
* fix: apply additional headers to user endpoint * feat: add support for Notion
1 parent 16fbcc0 commit e2f8eb1

8 files changed

Lines changed: 104 additions & 1 deletion

File tree

.pylintrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,7 @@ indent-after-paren=4
338338
indent-string=' '
339339

340340
# Maximum number of characters on a single line.
341-
max-line-length=119
341+
max-line-length=120
342342

343343
# Maximum number of lines in a module.
344344
max-module-lines=1000

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ I tend to process Pull Requests faster when properly caffeinated 😉.
5050
- Fitbit
5151
- Github (credits to [Brandl](https://github.com/Brandl) for hint using `accept` header)
5252
- generic (see [docs](https://tomasvotava.github.io/fastapi-sso/reference/sso.generic/))
53+
- Notion
5354

5455
### Contributed
5556

examples/notion.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Github Login Example
2+
"""
3+
4+
import os
5+
import uvicorn
6+
from fastapi import FastAPI, Request
7+
from fastapi_sso.sso.notion import NotionSSO
8+
9+
CLIENT_ID = os.environ["CLIENT_ID"]
10+
CLIENT_SECRET = os.environ["CLIENT_SECRET"]
11+
12+
app = FastAPI()
13+
14+
sso = NotionSSO(
15+
client_id=CLIENT_ID,
16+
client_secret=CLIENT_SECRET,
17+
redirect_uri="http://localhost:3000/oauth2/callback",
18+
allow_insecure_http=True,
19+
)
20+
21+
22+
@app.get("/oauth2/login")
23+
async def auth_init():
24+
"""Initialize auth and redirect"""
25+
with sso:
26+
return await sso.get_login_redirect()
27+
28+
29+
@app.get("/oauth2/callback")
30+
async def auth_callback(request: Request):
31+
"""Verify login"""
32+
with sso:
33+
user = await sso.verify_and_process(request)
34+
return user
35+
36+
37+
if __name__ == "__main__":
38+
uvicorn.run(app="examples.notion:app", host="127.0.0.1", port=3000)

fastapi_sso/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@
1212
from .sso.kakao import KakaoSSO
1313
from .sso.microsoft import MicrosoftSSO
1414
from .sso.naver import NaverSSO
15+
from .sso.notion import NotionSSO
1516
from .sso.spotify import SpotifySSO

fastapi_sso/sso/base.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,7 @@ async def process_login(
336336
params = params or {}
337337
additional_headers = additional_headers or {}
338338
additional_headers.update(self.additional_headers or {})
339+
339340
url = request.url
340341

341342
if not self.allow_insecure_http and url.scheme != "https":
@@ -366,6 +367,7 @@ async def process_login(
366367
self.oauth_client.parse_request_body_response(json.dumps(content))
367368

368369
uri, headers, _ = self.oauth_client.add_token(await self.userinfo_endpoint)
370+
headers.update(additional_headers)
369371
session.headers.update(headers)
370372
response = await session.get(uri)
371373
content = response.json()

fastapi_sso/sso/notion.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Notion SSO Oauth Helper class"""
2+
3+
from typing import TYPE_CHECKING, Optional
4+
5+
from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase, SSOLoginError
6+
7+
if TYPE_CHECKING:
8+
import httpx # pragma: no cover
9+
10+
11+
class NotionSSO(SSOBase):
12+
"""Class providing login using Notion OAuth"""
13+
14+
provider = "notion"
15+
scope = ["openid"]
16+
additional_headers = {"Notion-Version": "2022-06-28"}
17+
18+
async def get_discovery_document(self) -> DiscoveryDocument:
19+
return {
20+
"authorization_endpoint": "https://api.notion.com/v1/oauth/authorize?owner=user",
21+
"token_endpoint": "https://api.notion.com/v1/oauth/token",
22+
"userinfo_endpoint": "https://api.notion.com/v1/users/me",
23+
}
24+
25+
async def openid_from_response(self, response: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID:
26+
owner = response["bot"]["owner"]
27+
if owner["type"] != "user":
28+
raise SSOLoginError(401, f"Notion login failed, owner is not a user but {response['bot']['owner']['type']}")
29+
return OpenID(
30+
id=owner["user"]["id"],
31+
email=owner["user"]["person"]["email"],
32+
picture=owner["user"]["avatar_url"],
33+
display_name=owner["user"]["name"],
34+
provider=self.provider,
35+
)

tests/test_providers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from fastapi_sso.sso.microsoft import MicrosoftSSO
2020
from fastapi_sso.sso.naver import NaverSSO
2121
from fastapi_sso.sso.spotify import SpotifySSO
22+
from fastapi_sso.sso.notion import NotionSSO
2223

2324
GenericProvider = create_provider(
2425
name="generic",
@@ -41,6 +42,7 @@
4142
NaverSSO,
4243
SpotifySSO,
4344
GenericProvider,
45+
NotionSSO,
4446
)
4547

4648
# Run all tests for each of the listed providers

tests/test_providers_individual.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import pytest
2+
from fastapi_sso import NotionSSO, OpenID, SSOLoginError
3+
4+
5+
async def test_notion_openid_response():
6+
sso = NotionSSO("client_id", "client_secret")
7+
valid_response = {
8+
"bot": {
9+
"owner": {
10+
"type": "user",
11+
"user": {
12+
"id": "test",
13+
"person": {"email": "test@example.com"},
14+
"avatar_url": "avatar",
15+
"name": "Test User",
16+
},
17+
}
18+
}
19+
}
20+
invalid_response = {"bot": {"owner": {"type": "workspace", "workspace": {}}}}
21+
with pytest.raises(SSOLoginError):
22+
await sso.openid_from_response(invalid_response)
23+
openid = OpenID(id="test", email="test@example.com", display_name="Test User", picture="avatar", provider="notion")
24+
assert await sso.openid_from_response(valid_response) == openid

0 commit comments

Comments
 (0)