Skip to content

Commit f790da3

Browse files
feat: add Discord and Bitbucket providers (#207)
* add discord and bitbucket provider fix Update fastapi_sso/sso/discord.py Apply fix Co-authored-by: Tomas Votava <info@tomasvotava.eu> chore: resolve conflicts * chore: remove unrelated changes * chore: implement missing tests --------- Co-authored-by: Afi <aconique@gmail.com>
1 parent b240d0d commit f790da3

9 files changed

+254
-3
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ repos:
33
hooks:
44
- id: ruff
55
name: ruff
6-
entry: poe ruff
6+
entry: poe ruff --fix
77
language: system
88
types: [python]
99
pass_filenames: false

examples/bitbucket.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""BitBucket Login Example
2+
"""
3+
4+
import os
5+
import uvicorn
6+
from fastapi import FastAPI, Request
7+
from fastapi_sso.sso.bitbucket import BitbucketSSO
8+
9+
CLIENT_ID = os.environ["CLIENT_ID"]
10+
CLIENT_SECRET = os.environ["CLIENT_SECRET"]
11+
12+
app = FastAPI()
13+
14+
sso = BitbucketSSO(
15+
client_id=CLIENT_ID,
16+
client_secret=CLIENT_SECRET,
17+
redirect_uri="http://localhost:5000/auth/callback",
18+
allow_insecure_http=True,
19+
)
20+
21+
22+
@app.get("/auth/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("/auth/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.bitbucket:app", host="127.0.0.1", port=5000)

examples/discord.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Discord Login Example
2+
"""
3+
4+
import os
5+
import uvicorn
6+
from fastapi import FastAPI, Request
7+
from fastapi_sso.sso.discord import DiscordSSO
8+
9+
CLIENT_ID = os.environ["CLIENT_ID"]
10+
CLIENT_SECRET = os.environ["CLIENT_SECRET"]
11+
12+
app = FastAPI()
13+
14+
sso = DiscordSSO(
15+
client_id=CLIENT_ID,
16+
client_secret=CLIENT_SECRET,
17+
redirect_uri="http://localhost:5000/auth/callback",
18+
allow_insecure_http=True,
19+
)
20+
21+
22+
@app.get("/auth/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("/auth/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.discord:app", host="127.0.0.1", port=5000)

fastapi_sso/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
"""
55

66
from .sso.base import OpenID, SSOBase, SSOLoginError
7+
from .sso.bitbucket import BitbucketSSO
8+
from .sso.discord import DiscordSSO
79
from .sso.facebook import FacebookSSO
810
from .sso.fitbit import FitbitSSO
911
from .sso.generic import create_provider
@@ -37,4 +39,6 @@
3739
"NotionSSO",
3840
"SpotifySSO",
3941
"TwitterSSO",
42+
"BitbucketSSO",
43+
"DiscordSSO",
4044
]

fastapi_sso/sso/bitbucket.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""BitBucket SSO Oauth Helper class"""
2+
3+
from typing import TYPE_CHECKING, ClassVar, List, Optional, Union
4+
5+
import pydantic
6+
7+
from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
8+
9+
if TYPE_CHECKING:
10+
import httpx # pragma: no cover
11+
12+
13+
class BitbucketSSO(SSOBase):
14+
"""Class providing login using BitBucket OAuth"""
15+
16+
provider = "bitbucket"
17+
scope: ClassVar = ["account", "email"]
18+
version = "2.0"
19+
20+
def __init__(
21+
self,
22+
client_id: str,
23+
client_secret: str,
24+
redirect_uri: Optional[Union[pydantic.AnyHttpUrl, str]] = None,
25+
allow_insecure_http: bool = False,
26+
scope: Optional[List[str]] = None,
27+
):
28+
super().__init__(
29+
client_id=client_id,
30+
client_secret=client_secret,
31+
redirect_uri=redirect_uri,
32+
allow_insecure_http=allow_insecure_http,
33+
scope=scope,
34+
)
35+
36+
async def get_useremail(self, session: Optional["httpx.AsyncClient"] = None) -> dict:
37+
"""Get user email"""
38+
if session is None:
39+
raise ValueError("Session is required to make HTTP requests")
40+
41+
response = await session.get(f"https://api.bitbucket.org/{self.version}/user/emails")
42+
return response.json()
43+
44+
async def get_discovery_document(self) -> DiscoveryDocument:
45+
return {
46+
"authorization_endpoint": "https://bitbucket.org/site/oauth2/authorize",
47+
"token_endpoint": "https://bitbucket.org/site/oauth2/access_token",
48+
"userinfo_endpoint": f"https://api.bitbucket.org/{self.version}/user",
49+
}
50+
51+
async def openid_from_response(self, response: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID:
52+
email = await self.get_useremail(session=session)
53+
return OpenID(
54+
email=email["values"][0]["email"],
55+
display_name=response.get("display_name"),
56+
provider=self.provider,
57+
id=str(response.get("uuid")).strip("{}"),
58+
first_name=response.get("nickname"),
59+
picture=response.get("links", {}).get("avatar", {}).get("href"),
60+
)

fastapi_sso/sso/discord.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Discord SSO Oauth Helper class"""
2+
3+
from typing import TYPE_CHECKING, ClassVar, List, Optional, Union
4+
5+
import pydantic
6+
7+
from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
8+
9+
if TYPE_CHECKING:
10+
import httpx # pragma: no cover
11+
12+
13+
class DiscordSSO(SSOBase):
14+
"""Class providing login using Discord OAuth"""
15+
16+
provider = "discord"
17+
scope: ClassVar = ["identify", "email", "openid"]
18+
19+
def __init__(
20+
self,
21+
client_id: str,
22+
client_secret: str,
23+
redirect_uri: Optional[Union[pydantic.AnyHttpUrl, str]] = None,
24+
allow_insecure_http: bool = False,
25+
scope: Optional[List[str]] = None,
26+
):
27+
super().__init__(
28+
client_id=client_id,
29+
client_secret=client_secret,
30+
redirect_uri=redirect_uri,
31+
allow_insecure_http=allow_insecure_http,
32+
scope=scope,
33+
)
34+
35+
async def get_discovery_document(self) -> DiscoveryDocument:
36+
return {
37+
"authorization_endpoint": "https://discord.com/oauth2/authorize",
38+
"token_endpoint": "https://discord.com/api/oauth2/token",
39+
"userinfo_endpoint": "https://discord.com/api/users/@me",
40+
}
41+
42+
async def openid_from_response(self, response: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID:
43+
user_id = response.get("id")
44+
avatar = response.get("avatar")
45+
picture = None
46+
if user_id and avatar:
47+
picture = f"https://cdn.discordapp.com/avatars/{user_id}/{avatar}.png"
48+
49+
return OpenID(
50+
email=response.get("email"),
51+
display_name=response.get("global_name"),
52+
provider=self.provider,
53+
id=user_id,
54+
first_name=response.get("username"),
55+
picture=picture,
56+
)

tests/test_openid_responses.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import pytest
44

55
from fastapi_sso.sso.base import OpenID, SSOBase
6+
from fastapi_sso.sso.discord import DiscordSSO
67
from fastapi_sso.sso.facebook import FacebookSSO
78
from fastapi_sso.sso.fitbit import FitbitSSO
89
from fastapi_sso.sso.github import GithubSSO
@@ -219,6 +220,24 @@
219220
picture="https://avatars.yandex.net/get-yapic/123456/islands-200",
220221
),
221222
),
223+
(
224+
DiscordSSO,
225+
{
226+
"id": "test",
227+
"avatar": "avatar",
228+
"email": "test@example.com",
229+
"global_name": "Test User",
230+
"username": "testuser",
231+
},
232+
OpenID(
233+
email="test@example.com",
234+
first_name="testuser",
235+
id="test",
236+
picture="https://cdn.discordapp.com/avatars/test/avatar.png",
237+
provider="discord",
238+
display_name="Test User",
239+
),
240+
),
222241
)
223242

224243

tests/test_providers.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from utils import AnythingDict, Request, Response, make_fake_async_client
99

1010
from fastapi_sso.sso.base import OpenID, SecurityWarning, SSOBase
11+
from fastapi_sso.sso.bitbucket import BitbucketSSO
12+
from fastapi_sso.sso.discord import DiscordSSO
1113
from fastapi_sso.sso.facebook import FacebookSSO
1214
from fastapi_sso.sso.fitbit import FitbitSSO
1315
from fastapi_sso.sso.generic import create_provider
@@ -20,10 +22,10 @@
2022
from fastapi_sso.sso.microsoft import MicrosoftSSO
2123
from fastapi_sso.sso.naver import NaverSSO
2224
from fastapi_sso.sso.notion import NotionSSO
25+
from fastapi_sso.sso.seznam import SeznamSSO
2326
from fastapi_sso.sso.spotify import SpotifySSO
2427
from fastapi_sso.sso.twitter import TwitterSSO
2528
from fastapi_sso.sso.yandex import YandexSSO
26-
from fastapi_sso.sso.seznam import SeznamSSO
2729

2830
GenericProvider = create_provider(
2931
name="generic",
@@ -52,6 +54,8 @@
5254
TwitterSSO,
5355
YandexSSO,
5456
SeznamSSO,
57+
BitbucketSSO,
58+
DiscordSSO,
5559
)
5660

5761
# Run all tests for each of the listed providers

tests/test_providers_individual.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
from unittest.mock import MagicMock
2+
13
import pytest
24

3-
from fastapi_sso import NotionSSO, OpenID, SSOLoginError
5+
from fastapi_sso import BitbucketSSO, NotionSSO, OpenID, SSOLoginError
46

57

68
async def test_notion_openid_response():
@@ -23,3 +25,33 @@ async def test_notion_openid_response():
2325
await sso.openid_from_response(invalid_response)
2426
openid = OpenID(id="test", email="test@example.com", display_name="Test User", picture="avatar", provider="notion")
2527
assert await sso.openid_from_response(valid_response) == openid
28+
29+
30+
async def test_bitbucket_openid_response():
31+
sso = BitbucketSSO("client_id", "client_secret")
32+
valid_response = {
33+
"uuid": "00000000-0000-0000-0000-000000000000",
34+
"nickname": "testuser",
35+
"links": {"avatar": {"href": "https://example.com/myavatar.png"}},
36+
"display_name": "Test User",
37+
}
38+
39+
class FakeSesssion:
40+
async def get(self, url: str) -> MagicMock:
41+
response = MagicMock()
42+
response.json.return_value = {"values": [{"email": "test@example.com"}]}
43+
return response
44+
45+
openid = OpenID(
46+
id=valid_response["uuid"],
47+
display_name=valid_response["display_name"],
48+
provider="bitbucket",
49+
email="test@example.com",
50+
first_name="testuser",
51+
picture=valid_response["links"]["avatar"]["href"],
52+
)
53+
54+
with pytest.raises(ValueError, match="Session is required to make HTTP requests"):
55+
await sso.openid_from_response(valid_response)
56+
57+
assert openid == await sso.openid_from_response(valid_response, FakeSesssion())

0 commit comments

Comments
 (0)