fastapi-sso 0.16.0__tar.gz → 0.18.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {fastapi_sso-0.16.0 → fastapi_sso-0.18.0}/PKG-INFO +7 -5
  2. {fastapi_sso-0.16.0 → fastapi_sso-0.18.0}/README.md +2 -0
  3. {fastapi_sso-0.16.0 → fastapi_sso-0.18.0}/fastapi_sso/__init__.py +8 -4
  4. {fastapi_sso-0.16.0 → fastapi_sso-0.18.0}/fastapi_sso/pkce.py +1 -2
  5. {fastapi_sso-0.16.0 → fastapi_sso-0.18.0}/fastapi_sso/sso/base.py +55 -33
  6. fastapi_sso-0.18.0/fastapi_sso/sso/bitbucket.py +60 -0
  7. fastapi_sso-0.18.0/fastapi_sso/sso/discord.py +56 -0
  8. {fastapi_sso-0.16.0 → fastapi_sso-0.18.0}/fastapi_sso/sso/generic.py +4 -4
  9. {fastapi_sso-0.16.0 → fastapi_sso-0.18.0}/fastapi_sso/sso/gitlab.py +3 -3
  10. {fastapi_sso-0.16.0 → fastapi_sso-0.18.0}/fastapi_sso/sso/linkedin.py +6 -2
  11. {fastapi_sso-0.16.0 → fastapi_sso-0.18.0}/fastapi_sso/sso/microsoft.py +2 -2
  12. {fastapi_sso-0.16.0 → fastapi_sso-0.18.0}/fastapi_sso/sso/naver.py +2 -2
  13. {fastapi_sso-0.16.0 → fastapi_sso-0.18.0}/pyproject.toml +6 -5
  14. {fastapi_sso-0.16.0 → fastapi_sso-0.18.0}/LICENSE.md +0 -0
  15. {fastapi_sso-0.16.0 → fastapi_sso-0.18.0}/fastapi_sso/py.typed +0 -0
  16. {fastapi_sso-0.16.0 → fastapi_sso-0.18.0}/fastapi_sso/sso/__init__.py +0 -0
  17. {fastapi_sso-0.16.0 → fastapi_sso-0.18.0}/fastapi_sso/sso/facebook.py +0 -0
  18. {fastapi_sso-0.16.0 → fastapi_sso-0.18.0}/fastapi_sso/sso/fitbit.py +0 -0
  19. {fastapi_sso-0.16.0 → fastapi_sso-0.18.0}/fastapi_sso/sso/github.py +0 -0
  20. {fastapi_sso-0.16.0 → fastapi_sso-0.18.0}/fastapi_sso/sso/google.py +0 -0
  21. {fastapi_sso-0.16.0 → fastapi_sso-0.18.0}/fastapi_sso/sso/kakao.py +0 -0
  22. {fastapi_sso-0.16.0 → fastapi_sso-0.18.0}/fastapi_sso/sso/line.py +0 -0
  23. {fastapi_sso-0.16.0 → fastapi_sso-0.18.0}/fastapi_sso/sso/notion.py +0 -0
  24. {fastapi_sso-0.16.0 → fastapi_sso-0.18.0}/fastapi_sso/sso/seznam.py +0 -0
  25. {fastapi_sso-0.16.0 → fastapi_sso-0.18.0}/fastapi_sso/sso/spotify.py +0 -0
  26. {fastapi_sso-0.16.0 → fastapi_sso-0.18.0}/fastapi_sso/sso/twitter.py +0 -0
  27. {fastapi_sso-0.16.0 → fastapi_sso-0.18.0}/fastapi_sso/sso/yandex.py +0 -0
  28. {fastapi_sso-0.16.0 → fastapi_sso-0.18.0}/fastapi_sso/state.py +0 -0
@@ -1,16 +1,14 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.3
2
2
  Name: fastapi-sso
3
- Version: 0.16.0
3
+ Version: 0.18.0
4
4
  Summary: FastAPI plugin to enable SSO to most common providers (such as Facebook login, Google login and login via Microsoft Office 365 Account)
5
- Home-page: https://tomasvotava.github.io/fastapi-sso/
6
5
  License: MIT
7
6
  Keywords: fastapi,sso,oauth,google,facebook,spotify,linkedin
8
7
  Author: Tomas Votava
9
8
  Author-email: info@tomasvotava.eu
10
- Requires-Python: >=3.8,<4.0
9
+ Requires-Python: >=3.9,<4.0
11
10
  Classifier: License :: OSI Approved :: MIT License
12
11
  Classifier: Programming Language :: Python :: 3
13
- Classifier: Programming Language :: Python :: 3.8
14
12
  Classifier: Programming Language :: Python :: 3.9
15
13
  Classifier: Programming Language :: Python :: 3.10
16
14
  Classifier: Programming Language :: Python :: 3.11
@@ -20,8 +18,10 @@ Requires-Dist: fastapi (>=0.80)
20
18
  Requires-Dist: httpx (>=0.23.0)
21
19
  Requires-Dist: oauthlib (>=3.1.0)
22
20
  Requires-Dist: pydantic[email] (>=1.8.0)
21
+ Requires-Dist: pyjwt (>=2.10.1,<3.0.0)
23
22
  Requires-Dist: typing-extensions (>=4.12.2,<5.0.0) ; python_version < "3.10"
24
23
  Project-URL: Documentation, https://tomasvotava.github.io/fastapi-sso/
24
+ Project-URL: Homepage, https://tomasvotava.github.io/fastapi-sso/
25
25
  Project-URL: Repository, https://github.com/tomasvotava/fastapi-sso
26
26
  Description-Content-Type: text/markdown
27
27
 
@@ -143,6 +143,8 @@ I tend to process Pull Requests faster when properly caffeinated 😉.
143
143
  - LinkedIn (by Alessandro Pischedda) - [Cereal84](https://github.com/Cereal84)
144
144
  - Yandex (by Akim Faskhutdinov) – [akimrx](https://github.com/akimrx)
145
145
  - Seznam (by Tomas Koutek) - [TomasKoutek](https://github.com/TomasKoutek)
146
+ - Discord (by Kaelian Baudelet) - [afi-dev](https://github.com/afi-dev)
147
+ - Bitbucket (by Kaelian Baudelet) - [afi-dev](https://github.com/afi-dev)
146
148
 
147
149
  See [Contributing](#contributing) for a guide on how to contribute your own login provider.
148
150
 
@@ -116,6 +116,8 @@ I tend to process Pull Requests faster when properly caffeinated 😉.
116
116
  - LinkedIn (by Alessandro Pischedda) - [Cereal84](https://github.com/Cereal84)
117
117
  - Yandex (by Akim Faskhutdinov) – [akimrx](https://github.com/akimrx)
118
118
  - Seznam (by Tomas Koutek) - [TomasKoutek](https://github.com/TomasKoutek)
119
+ - Discord (by Kaelian Baudelet) - [afi-dev](https://github.com/afi-dev)
120
+ - Bitbucket (by Kaelian Baudelet) - [afi-dev](https://github.com/afi-dev)
119
121
 
120
122
  See [Contributing](#contributing) for a guide on how to contribute your own login provider.
121
123
 
@@ -4,6 +4,8 @@
4
4
  """
5
5
 
6
6
  from .sso.base import OpenID, SSOBase, SSOLoginError
7
+ from .sso.bitbucket import BitbucketSSO
8
+ from .sso.discord import DiscordSSO
7
9
  from .sso.facebook import FacebookSSO
8
10
  from .sso.fitbit import FitbitSSO
9
11
  from .sso.generic import create_provider
@@ -20,12 +22,10 @@ from .sso.spotify import SpotifySSO
20
22
  from .sso.twitter import TwitterSSO
21
23
 
22
24
  __all__ = [
23
- "OpenID",
24
- "SSOBase",
25
- "SSOLoginError",
25
+ "BitbucketSSO",
26
+ "DiscordSSO",
26
27
  "FacebookSSO",
27
28
  "FitbitSSO",
28
- "create_provider",
29
29
  "GithubSSO",
30
30
  "GitlabSSO",
31
31
  "GoogleSSO",
@@ -35,6 +35,10 @@ __all__ = [
35
35
  "MicrosoftSSO",
36
36
  "NaverSSO",
37
37
  "NotionSSO",
38
+ "OpenID",
39
+ "SSOBase",
40
+ "SSOLoginError",
38
41
  "SpotifySSO",
39
42
  "TwitterSSO",
43
+ "create_provider",
40
44
  ]
@@ -3,7 +3,6 @@
3
3
  import base64
4
4
  import hashlib
5
5
  import os
6
- from typing import Tuple
7
6
 
8
7
 
9
8
  def get_code_verifier(length: int = 96) -> str:
@@ -13,7 +12,7 @@ def get_code_verifier(length: int = 96) -> str:
13
12
  return base64.urlsafe_b64encode(os.urandom(bytes_length)).decode("utf-8").replace("=", "")[:length]
14
13
 
15
14
 
16
- def get_pkce_challenge_pair(verifier_length: int = 96) -> Tuple[str, str]:
15
+ def get_pkce_challenge_pair(verifier_length: int = 96) -> tuple[str, str]:
17
16
  """Get tuple of (verifier, challenge) for PKCE challenge."""
18
17
  code_verifier = get_code_verifier(verifier_length)
19
18
  code_challenge = (
@@ -7,9 +7,10 @@ import os
7
7
  import sys
8
8
  import warnings
9
9
  from types import TracebackType
10
- from typing import Any, ClassVar, Dict, List, Literal, Optional, Type, TypedDict, TypeVar, Union, overload
10
+ from typing import Any, ClassVar, Literal, Optional, TypedDict, TypeVar, Union, overload
11
11
 
12
12
  import httpx
13
+ import jwt
13
14
  import pydantic
14
15
  from oauthlib.oauth2 import WebApplicationClient
15
16
  from starlette.exceptions import HTTPException
@@ -33,6 +34,10 @@ T = TypeVar("T")
33
34
  P = ParamSpec("P")
34
35
 
35
36
 
37
+ def _decode_id_token(id_token: str, verify: bool = False) -> dict:
38
+ return jwt.decode(id_token, options={"verify_signature": verify})
39
+
40
+
36
41
  class DiscoveryDocument(TypedDict):
37
42
  """Discovery document."""
38
43
 
@@ -95,10 +100,11 @@ class SSOBase:
95
100
  client_id: str = NotImplemented
96
101
  client_secret: str = NotImplemented
97
102
  redirect_uri: Optional[Union[pydantic.AnyHttpUrl, str]] = NotImplemented
98
- scope: ClassVar[List[str]] = []
99
- additional_headers: ClassVar[Optional[Dict[str, Any]]] = None
103
+ scope: ClassVar[list[str]] = []
104
+ additional_headers: ClassVar[Optional[dict[str, Any]]] = None
100
105
  uses_pkce: bool = False
101
106
  requires_state: bool = False
107
+ use_id_token_for_user_info: ClassVar[bool] = False
102
108
 
103
109
  _pkce_challenge_length: int = 96
104
110
 
@@ -109,7 +115,7 @@ class SSOBase:
109
115
  redirect_uri: Optional[Union[pydantic.AnyHttpUrl, str]] = None,
110
116
  allow_insecure_http: bool = False,
111
117
  use_state: bool = False,
112
- scope: Optional[List[str]] = None,
118
+ scope: Optional[list[str]] = None,
113
119
  ):
114
120
  """Base class (mixin) for all SSO providers."""
115
121
  self.client_id: str = client_id
@@ -224,6 +230,18 @@ class SSOBase:
224
230
  """
225
231
  raise NotImplementedError(f"Provider {self.provider} not supported")
226
232
 
233
+ async def openid_from_token(self, id_token: dict, session: Optional[httpx.AsyncClient] = None) -> OpenID:
234
+ """Converts an ID token from the provider's token endpoint to an OpenID object.
235
+
236
+ Args:
237
+ id_token (dict): The id token data retrieved from the token endpoint.
238
+ session: (Optional[httpx.AsyncClient]): The HTTPX AsyncClient session.
239
+
240
+ Returns:
241
+ OpenID: The user information in a standardized format.
242
+ """
243
+ raise NotImplementedError(f"Provider {self.provider} not supported")
244
+
227
245
  async def get_discovery_document(self) -> DiscoveryDocument:
228
246
  """Retrieves the discovery document containing useful URLs.
229
247
 
@@ -257,14 +275,14 @@ class SSOBase:
257
275
  self,
258
276
  *,
259
277
  redirect_uri: Optional[Union[pydantic.AnyHttpUrl, str]] = None,
260
- params: Optional[Dict[str, Any]] = None,
278
+ params: Optional[dict[str, Any]] = None,
261
279
  state: Optional[str] = None,
262
280
  ) -> str:
263
281
  """Generates and returns the prepared login URL.
264
282
 
265
283
  Args:
266
284
  redirect_uri (Optional[str]): Overrides the `redirect_uri` specified on this instance.
267
- params (Optional[Dict[str, Any]]): Additional query parameters to add to the login request.
285
+ params (Optional[dict[str, Any]]): Additional query parameters to add to the login request.
268
286
  state (Optional[str]): The state parameter for the OAuth 2.0 authorization request.
269
287
 
270
288
  Raises:
@@ -304,14 +322,14 @@ class SSOBase:
304
322
  self,
305
323
  *,
306
324
  redirect_uri: Optional[str] = None,
307
- params: Optional[Dict[str, Any]] = None,
325
+ params: Optional[dict[str, Any]] = None,
308
326
  state: Optional[str] = None,
309
327
  ) -> RedirectResponse:
310
328
  """Constructs and returns a redirect response to the login page of OAuth SSO provider.
311
329
 
312
330
  Args:
313
331
  redirect_uri (Optional[str]): Overrides the `redirect_uri` specified on this instance.
314
- params (Optional[Dict[str, Any]]): Additional query parameters to add to the login request.
332
+ params (Optional[dict[str, Any]]): Additional query parameters to add to the login request.
315
333
  state (Optional[str]): The state parameter for the OAuth 2.0 authorization request.
316
334
 
317
335
  Returns:
@@ -330,8 +348,8 @@ class SSOBase:
330
348
  self,
331
349
  request: Request,
332
350
  *,
333
- params: Optional[Dict[str, Any]] = None,
334
- headers: Optional[Dict[str, Any]] = None,
351
+ params: Optional[dict[str, Any]] = None,
352
+ headers: Optional[dict[str, Any]] = None,
335
353
  redirect_uri: Optional[str] = None,
336
354
  convert_response: Literal[True] = True,
337
355
  ) -> Optional[OpenID]: ...
@@ -341,28 +359,28 @@ class SSOBase:
341
359
  self,
342
360
  request: Request,
343
361
  *,
344
- params: Optional[Dict[str, Any]] = None,
345
- headers: Optional[Dict[str, Any]] = None,
362
+ params: Optional[dict[str, Any]] = None,
363
+ headers: Optional[dict[str, Any]] = None,
346
364
  redirect_uri: Optional[str] = None,
347
365
  convert_response: Literal[False],
348
- ) -> Optional[Dict[str, Any]]: ...
366
+ ) -> Optional[dict[str, Any]]: ...
349
367
 
350
368
  @requires_async_context
351
369
  async def verify_and_process(
352
370
  self,
353
371
  request: Request,
354
372
  *,
355
- params: Optional[Dict[str, Any]] = None,
356
- headers: Optional[Dict[str, Any]] = None,
373
+ params: Optional[dict[str, Any]] = None,
374
+ headers: Optional[dict[str, Any]] = None,
357
375
  redirect_uri: Optional[str] = None,
358
376
  convert_response: Union[Literal[True], Literal[False]] = True,
359
- ) -> Union[Optional[OpenID], Optional[Dict[str, Any]]]:
377
+ ) -> Union[Optional[OpenID], Optional[dict[str, Any]]]:
360
378
  """Processes the login given a FastAPI (Starlette) Request object. This should be used for the /callback path.
361
379
 
362
380
  Args:
363
381
  request (Request): FastAPI or Starlette request object.
364
- params (Optional[Dict[str, Any]]): Additional query parameters to pass to the provider.
365
- headers (Optional[Dict[str, Any]]): Additional headers to pass to the provider.
382
+ params (Optional[dict[str, Any]]): Additional query parameters to pass to the provider.
383
+ headers (Optional[dict[str, Any]]): Additional headers to pass to the provider.
366
384
  redirect_uri (Optional[str]): Overrides the `redirect_uri` specified on this instance.
367
385
  convert_response (bool): If True, userinfo response is converted to OpenID object.
368
386
 
@@ -371,7 +389,7 @@ class SSOBase:
371
389
 
372
390
  Returns:
373
391
  Optional[OpenID]: User information as OpenID instance (if convert_response == True)
374
- Optional[Dict[str, Any]]: The original JSON response from the API.
392
+ Optional[dict[str, Any]]: The original JSON response from the API.
375
393
  """
376
394
  headers = headers or {}
377
395
  code = request.query_params.get("code")
@@ -433,7 +451,7 @@ class SSOBase:
433
451
 
434
452
  async def __aexit__(
435
453
  self,
436
- _exc_type: Optional[Type[BaseException]],
454
+ _exc_type: Optional[type[BaseException]],
437
455
  _exc_val: Optional[BaseException],
438
456
  _exc_tb: Optional[TracebackType],
439
457
  ) -> None:
@@ -442,14 +460,14 @@ class SSOBase:
442
460
 
443
461
  def __exit__(
444
462
  self,
445
- _exc_type: Optional[Type[BaseException]],
463
+ _exc_type: Optional[type[BaseException]],
446
464
  _exc_val: Optional[BaseException],
447
465
  _exc_tb: Optional[TracebackType],
448
466
  ) -> None:
449
467
  return None
450
468
 
451
469
  @property
452
- def _extra_query_params(self) -> Dict:
470
+ def _extra_query_params(self) -> dict:
453
471
  return {}
454
472
 
455
473
  @overload
@@ -458,8 +476,8 @@ class SSOBase:
458
476
  code: str,
459
477
  request: Request,
460
478
  *,
461
- params: Optional[Dict[str, Any]] = None,
462
- additional_headers: Optional[Dict[str, Any]] = None,
479
+ params: Optional[dict[str, Any]] = None,
480
+ additional_headers: Optional[dict[str, Any]] = None,
463
481
  redirect_uri: Optional[str] = None,
464
482
  pkce_code_verifier: Optional[str] = None,
465
483
  convert_response: Literal[True] = True,
@@ -471,12 +489,12 @@ class SSOBase:
471
489
  code: str,
472
490
  request: Request,
473
491
  *,
474
- params: Optional[Dict[str, Any]] = None,
475
- additional_headers: Optional[Dict[str, Any]] = None,
492
+ params: Optional[dict[str, Any]] = None,
493
+ additional_headers: Optional[dict[str, Any]] = None,
476
494
  redirect_uri: Optional[str] = None,
477
495
  pkce_code_verifier: Optional[str] = None,
478
496
  convert_response: Literal[False],
479
- ) -> Optional[Dict[str, Any]]: ...
497
+ ) -> Optional[dict[str, Any]]: ...
480
498
 
481
499
  @requires_async_context
482
500
  async def process_login(
@@ -484,20 +502,20 @@ class SSOBase:
484
502
  code: str,
485
503
  request: Request,
486
504
  *,
487
- params: Optional[Dict[str, Any]] = None,
488
- additional_headers: Optional[Dict[str, Any]] = None,
505
+ params: Optional[dict[str, Any]] = None,
506
+ additional_headers: Optional[dict[str, Any]] = None,
489
507
  redirect_uri: Optional[str] = None,
490
508
  pkce_code_verifier: Optional[str] = None,
491
509
  convert_response: Union[Literal[True], Literal[False]] = True,
492
- ) -> Union[Optional[OpenID], Optional[Dict[str, Any]]]:
510
+ ) -> Union[Optional[OpenID], Optional[dict[str, Any]]]:
493
511
  """Processes login from the callback endpoint to verify the user and request user info endpoint.
494
512
  It's a lower-level method, typically, you should use `verify_and_process` instead.
495
513
 
496
514
  Args:
497
515
  code (str): The authorization code.
498
516
  request (Request): FastAPI or Starlette request object.
499
- params (Optional[Dict[str, Any]]): Additional query parameters to pass to the provider.
500
- additional_headers (Optional[Dict[str, Any]]): Additional headers to be added to all requests.
517
+ params (Optional[dict[str, Any]]): Additional query parameters to pass to the provider.
518
+ additional_headers (Optional[dict[str, Any]]): Additional headers to be added to all requests.
501
519
  redirect_uri (Optional[str]): Overrides the `redirect_uri` specified on this instance.
502
520
  pkce_code_verifier (Optional[str]): A PKCE code verifier sent to the server to verify the login request.
503
521
  convert_response (bool): If True, userinfo response is converted to OpenID object.
@@ -507,7 +525,7 @@ class SSOBase:
507
525
 
508
526
  Returns:
509
527
  Optional[OpenID]: User information in OpenID format if the login was successful (convert_response == True).
510
- Optional[Dict[str, Any]]: Original userinfo API endpoint response.
528
+ Optional[dict[str, Any]]: Original userinfo API endpoint response.
511
529
  """
512
530
  if self._oauth_client is not None: # pragma: no cover
513
531
  self._oauth_client = None
@@ -565,5 +583,9 @@ class SSOBase:
565
583
  response = await session.get(uri)
566
584
  content = response.json()
567
585
  if convert_response:
586
+ if self.use_id_token_for_user_info:
587
+ if not self._id_token:
588
+ raise SSOLoginError(401, f"Provider {self.provider!r} did not return id token.")
589
+ return await self.openid_from_token(_decode_id_token(self._id_token), session)
568
590
  return await self.openid_from_response(content, session)
569
591
  return content
@@ -0,0 +1,60 @@
1
+ """BitBucket SSO Oauth Helper class"""
2
+
3
+ from typing import TYPE_CHECKING, ClassVar, 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
+ )
@@ -0,0 +1,56 @@
1
+ """Discord SSO Oauth Helper class"""
2
+
3
+ from typing import TYPE_CHECKING, ClassVar, 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
+ )
@@ -3,7 +3,7 @@ with close to no code.
3
3
  """
4
4
 
5
5
  import logging
6
- from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Type, Union
6
+ from typing import TYPE_CHECKING, Any, Callable, Optional, Union
7
7
 
8
8
  from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
9
9
 
@@ -16,10 +16,10 @@ logger = logging.getLogger(__name__)
16
16
  def create_provider(
17
17
  *,
18
18
  name: str = "generic",
19
- default_scope: Optional[List[str]] = None,
19
+ default_scope: Optional[list[str]] = None,
20
20
  discovery_document: Union[DiscoveryDocument, Callable[[SSOBase], DiscoveryDocument]],
21
- response_convertor: Optional[Callable[[Dict[str, Any], Optional["httpx.AsyncClient"]], OpenID]] = None
22
- ) -> Type[SSOBase]:
21
+ response_convertor: Optional[Callable[[dict[str, Any], Optional["httpx.AsyncClient"]], OpenID]] = None
22
+ ) -> type[SSOBase]:
23
23
  """A factory to create a generic OAuth client usable with almost any OAuth provider.
24
24
  Returns a class.
25
25
 
@@ -1,6 +1,6 @@
1
1
  """Gitlab SSO Oauth Helper class."""
2
2
 
3
- from typing import TYPE_CHECKING, ClassVar, List, Optional, Tuple, Union
3
+ from typing import TYPE_CHECKING, ClassVar, Optional, Union
4
4
  from urllib.parse import urljoin
5
5
 
6
6
  import pydantic
@@ -26,7 +26,7 @@ class GitlabSSO(SSOBase):
26
26
  redirect_uri: Optional[Union[pydantic.AnyHttpUrl, str]] = None,
27
27
  allow_insecure_http: bool = False,
28
28
  use_state: bool = False, # TODO: Remove use_state argument
29
- scope: Optional[List[str]] = None,
29
+ scope: Optional[list[str]] = None,
30
30
  base_endpoint_url: Optional[str] = None,
31
31
  ) -> None:
32
32
  super().__init__(
@@ -47,7 +47,7 @@ class GitlabSSO(SSOBase):
47
47
  "userinfo_endpoint": urljoin(self.base_endpoint_url, "/api/v4/user"),
48
48
  }
49
49
 
50
- def _parse_name(self, full_name: Optional[str]) -> Tuple[Union[str, None], Union[str, None]]:
50
+ def _parse_name(self, full_name: Optional[str]) -> tuple[Union[str, None], Union[str, None]]:
51
51
  """Parses the full name from Gitlab into the first and last name."""
52
52
  if not full_name or not isinstance(full_name, str):
53
53
  return None, None
@@ -1,6 +1,6 @@
1
1
  """LinkedIn SSO Oauth Helper class."""
2
2
 
3
- from typing import TYPE_CHECKING, ClassVar, Dict, Optional
3
+ from typing import TYPE_CHECKING, ClassVar, Optional
4
4
 
5
5
  from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
6
6
 
@@ -14,9 +14,10 @@ class LinkedInSSO(SSOBase):
14
14
  provider = "linkedin"
15
15
  scope: ClassVar = ["openid", "profile", "email"]
16
16
  additional_headers: ClassVar = {"accept": "application/json"}
17
+ use_id_token_for_user_info: ClassVar = True
17
18
 
18
19
  @property
19
- def _extra_query_params(self) -> Dict:
20
+ def _extra_query_params(self) -> dict:
20
21
  return {"client_secret": self.client_secret}
21
22
 
22
23
  async def get_discovery_document(self) -> DiscoveryDocument:
@@ -26,6 +27,9 @@ class LinkedInSSO(SSOBase):
26
27
  "userinfo_endpoint": "https://api.linkedin.com/v2/userinfo",
27
28
  }
28
29
 
30
+ async def openid_from_token(self, id_token: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID:
31
+ return await self.openid_from_response(id_token, session)
32
+
29
33
  async def openid_from_response(self, response: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID:
30
34
  return OpenID(
31
35
  email=response.get("email"),
@@ -1,6 +1,6 @@
1
1
  """Microsoft SSO Oauth Helper class."""
2
2
 
3
- from typing import TYPE_CHECKING, ClassVar, List, Optional, Union
3
+ from typing import TYPE_CHECKING, ClassVar, Optional, Union
4
4
 
5
5
  import pydantic
6
6
 
@@ -25,7 +25,7 @@ class MicrosoftSSO(SSOBase):
25
25
  redirect_uri: Optional[Union[pydantic.AnyHttpUrl, str]] = None,
26
26
  allow_insecure_http: bool = False,
27
27
  use_state: bool = False, # TODO: Remove use_state argument
28
- scope: Optional[List[str]] = None,
28
+ scope: Optional[list[str]] = None,
29
29
  tenant: Optional[str] = None,
30
30
  ):
31
31
  super().__init__(
@@ -1,6 +1,6 @@
1
1
  """Naver SSO Oauth Helper class."""
2
2
 
3
- from typing import TYPE_CHECKING, ClassVar, List, Optional
3
+ from typing import TYPE_CHECKING, ClassVar, Optional
4
4
 
5
5
  from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
6
6
 
@@ -12,7 +12,7 @@ class NaverSSO(SSOBase):
12
12
  """Class providing login using Naver OAuth."""
13
13
 
14
14
  provider = "naver"
15
- scope: ClassVar[List[str]] = []
15
+ scope: ClassVar[list[str]] = []
16
16
  additional_headers: ClassVar = {"accept": "application/json"}
17
17
 
18
18
  async def get_discovery_document(self) -> DiscoveryDocument:
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "fastapi-sso"
3
- version = "0.16.0"
3
+ version = "0.18.0"
4
4
  description = "FastAPI plugin to enable SSO to most common providers (such as Facebook login, Google login and login via Microsoft Office 365 Account)"
5
5
  authors = ["Tomas Votava <info@tomasvotava.eu>"]
6
6
  readme = "README.md"
@@ -36,7 +36,7 @@ addopts = [
36
36
  line-length = 120
37
37
 
38
38
  [tool.ruff]
39
- target-version = "py38"
39
+ target-version = "py39"
40
40
  line-length = 120
41
41
 
42
42
  [tool.ruff.lint]
@@ -97,21 +97,22 @@ markdown-include = "^0.8.1"
97
97
  mkdocs-material = { extras = ["imaging"], version = "^9.3.2" }
98
98
  mkdocstrings = { extras = ["python"], version = ">=0.23,<0.27" }
99
99
  mypy = "^1"
100
- poethepoet = ">=0.21.1,<0.30.0"
100
+ poethepoet = ">=0.21.1,<0.31.0"
101
101
  pre-commit = "^3"
102
102
  pytest = ">=7,<9"
103
103
  pytest-asyncio = "^0.24"
104
104
  pytest-cov = ">=4,<6"
105
105
  uvicorn = ">=0.23.1"
106
- ruff = ">=0.4.2,<0.8.0"
106
+ ruff = ">=0.4.2,<0.12.0"
107
107
 
108
108
  [tool.poetry.dependencies]
109
109
  fastapi = ">=0.80"
110
110
  httpx = ">=0.23.0"
111
111
  oauthlib = ">=3.1.0"
112
112
  pydantic = { extras = ["email"], version = ">=1.8.0" }
113
- python = ">=3.8,<4.0"
113
+ python = ">=3.9,<4.0"
114
114
  typing-extensions = { version = "^4.12.2", python = "<3.10" }
115
+ pyjwt = "^2.10.1"
115
116
 
116
117
  [build-system]
117
118
  requires = ["poetry-core>=1.0.0"]
File without changes