fastapi-sso 0.14.2__tar.gz → 0.15.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 (25) hide show
  1. {fastapi_sso-0.14.2 → fastapi_sso-0.15.0}/PKG-INFO +17 -2
  2. {fastapi_sso-0.14.2 → fastapi_sso-0.15.0}/README.md +16 -1
  3. {fastapi_sso-0.14.2 → fastapi_sso-0.15.0}/fastapi_sso/__init__.py +22 -1
  4. {fastapi_sso-0.14.2 → fastapi_sso-0.15.0}/fastapi_sso/pkce.py +2 -2
  5. {fastapi_sso-0.14.2 → fastapi_sso-0.15.0}/fastapi_sso/sso/base.py +44 -51
  6. {fastapi_sso-0.14.2 → fastapi_sso-0.15.0}/fastapi_sso/sso/facebook.py +9 -9
  7. {fastapi_sso-0.14.2 → fastapi_sso-0.15.0}/fastapi_sso/sso/fitbit.py +6 -7
  8. {fastapi_sso-0.14.2 → fastapi_sso-0.15.0}/fastapi_sso/sso/generic.py +3 -4
  9. {fastapi_sso-0.14.2 → fastapi_sso-0.15.0}/fastapi_sso/sso/github.py +7 -6
  10. {fastapi_sso-0.14.2 → fastapi_sso-0.15.0}/fastapi_sso/sso/gitlab.py +5 -6
  11. {fastapi_sso-0.14.2 → fastapi_sso-0.15.0}/fastapi_sso/sso/google.py +7 -8
  12. {fastapi_sso-0.14.2 → fastapi_sso-0.15.0}/fastapi_sso/sso/kakao.py +4 -4
  13. {fastapi_sso-0.14.2 → fastapi_sso-0.15.0}/fastapi_sso/sso/line.py +6 -7
  14. {fastapi_sso-0.14.2 → fastapi_sso-0.15.0}/fastapi_sso/sso/linkedin.py +5 -5
  15. {fastapi_sso-0.14.2 → fastapi_sso-0.15.0}/fastapi_sso/sso/microsoft.py +4 -4
  16. {fastapi_sso-0.14.2 → fastapi_sso-0.15.0}/fastapi_sso/sso/naver.py +5 -5
  17. {fastapi_sso-0.14.2 → fastapi_sso-0.15.0}/fastapi_sso/sso/notion.py +5 -5
  18. {fastapi_sso-0.14.2 → fastapi_sso-0.15.0}/fastapi_sso/sso/spotify.py +8 -12
  19. {fastapi_sso-0.14.2 → fastapi_sso-0.15.0}/fastapi_sso/sso/twitter.py +4 -4
  20. {fastapi_sso-0.14.2 → fastapi_sso-0.15.0}/fastapi_sso/sso/yandex.py +3 -6
  21. {fastapi_sso-0.14.2 → fastapi_sso-0.15.0}/fastapi_sso/state.py +2 -2
  22. {fastapi_sso-0.14.2 → fastapi_sso-0.15.0}/pyproject.toml +43 -7
  23. {fastapi_sso-0.14.2 → fastapi_sso-0.15.0}/LICENSE.md +0 -0
  24. {fastapi_sso-0.14.2 → fastapi_sso-0.15.0}/fastapi_sso/py.typed +0 -0
  25. {fastapi_sso-0.14.2 → fastapi_sso-0.15.0}/fastapi_sso/sso/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fastapi-sso
3
- Version: 0.14.2
3
+ Version: 0.15.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
5
  Home-page: https://tomasvotava.github.io/fastapi-sso/
6
6
  License: MIT
@@ -28,7 +28,7 @@ Description-Content-Type: text/markdown
28
28
  ![Supported Python Versions](https://img.shields.io/pypi/pyversions/fastapi-sso)
29
29
  [![Test coverage](https://codecov.io/gh/tomasvotava/fastapi-sso/graph/badge.svg?token=SIFCTVSSOS)](https://codecov.io/gh/tomasvotava/fastapi-sso)
30
30
  ![Tests Workflow Status](https://img.shields.io/github/actions/workflow/status/tomasvotava/fastapi-sso/test.yml?label=tests)
31
- ![Pylint Workflow Status](https://img.shields.io/github/actions/workflow/status/tomasvotava/fastapi-sso/lint.yml?label=pylint)
31
+ ![Lint Workflow Status](https://img.shields.io/github/actions/workflow/status/tomasvotava/fastapi-sso/lint.yml?label=ruff)
32
32
  ![Mypy Workflow Status](https://img.shields.io/github/actions/workflow/status/tomasvotava/fastapi-sso/lint.yml?label=mypy)
33
33
  ![Black Workflow Status](https://img.shields.io/github/actions/workflow/status/tomasvotava/fastapi-sso/lint.yml?label=black)
34
34
  ![CodeQL Workflow Status](https://img.shields.io/github/actions/workflow/status/tomasvotava/fastapi-sso/codeql-analysis.yml?label=CodeQL)
@@ -46,6 +46,21 @@ backend very easily.
46
46
 
47
47
  **Source Code**: [https://github.com/tomasvotava/fastapi-sso](https://github.com/tomasvotava/fastapi-sso/)
48
48
 
49
+ ## Demo site
50
+
51
+ An awesome demo site was created and is maintained by even awesomer
52
+ [Chris Karvouniaris (@chrisK824)](https://github.com/chrisK824). Chris has also posted multiple
53
+ Medium articles about FastAPI and FastAPI SSO.
54
+
55
+ Be sure to see his tutorials, follow him and show him some appreciation!
56
+
57
+ Please see his [announcement](https://github.com/tomasvotava/fastapi-sso/discussions/150) with all the links.
58
+
59
+ Quick links for the eager ones:
60
+
61
+ - [Demo site](https://fastapi-sso-example.vercel.app/)
62
+ - [Medium articles](https://medium.com/@christos.karvouniaris247)
63
+
49
64
  ## Security warning
50
65
 
51
66
  Please note that versions preceding `0.7.0` had a security vulnerability.
@@ -3,7 +3,7 @@
3
3
  ![Supported Python Versions](https://img.shields.io/pypi/pyversions/fastapi-sso)
4
4
  [![Test coverage](https://codecov.io/gh/tomasvotava/fastapi-sso/graph/badge.svg?token=SIFCTVSSOS)](https://codecov.io/gh/tomasvotava/fastapi-sso)
5
5
  ![Tests Workflow Status](https://img.shields.io/github/actions/workflow/status/tomasvotava/fastapi-sso/test.yml?label=tests)
6
- ![Pylint Workflow Status](https://img.shields.io/github/actions/workflow/status/tomasvotava/fastapi-sso/lint.yml?label=pylint)
6
+ ![Lint Workflow Status](https://img.shields.io/github/actions/workflow/status/tomasvotava/fastapi-sso/lint.yml?label=ruff)
7
7
  ![Mypy Workflow Status](https://img.shields.io/github/actions/workflow/status/tomasvotava/fastapi-sso/lint.yml?label=mypy)
8
8
  ![Black Workflow Status](https://img.shields.io/github/actions/workflow/status/tomasvotava/fastapi-sso/lint.yml?label=black)
9
9
  ![CodeQL Workflow Status](https://img.shields.io/github/actions/workflow/status/tomasvotava/fastapi-sso/codeql-analysis.yml?label=CodeQL)
@@ -21,6 +21,21 @@ backend very easily.
21
21
 
22
22
  **Source Code**: [https://github.com/tomasvotava/fastapi-sso](https://github.com/tomasvotava/fastapi-sso/)
23
23
 
24
+ ## Demo site
25
+
26
+ An awesome demo site was created and is maintained by even awesomer
27
+ [Chris Karvouniaris (@chrisK824)](https://github.com/chrisK824). Chris has also posted multiple
28
+ Medium articles about FastAPI and FastAPI SSO.
29
+
30
+ Be sure to see his tutorials, follow him and show him some appreciation!
31
+
32
+ Please see his [announcement](https://github.com/tomasvotava/fastapi-sso/discussions/150) with all the links.
33
+
34
+ Quick links for the eager ones:
35
+
36
+ - [Demo site](https://fastapi-sso-example.vercel.app/)
37
+ - [Medium articles](https://medium.com/@christos.karvouniaris247)
38
+
24
39
  ## Security warning
25
40
 
26
41
  Please note that versions preceding `0.7.0` had a security vulnerability.
@@ -1,4 +1,5 @@
1
- """FastAPI plugin to enable SSO to most common providers
1
+ """FastAPI plugin to enable SSO to most common providers.
2
+
2
3
  (such as Facebook login, Google login and login via Microsoft Office 365 account)
3
4
  """
4
5
 
@@ -17,3 +18,23 @@ from .sso.naver import NaverSSO
17
18
  from .sso.notion import NotionSSO
18
19
  from .sso.spotify import SpotifySSO
19
20
  from .sso.twitter import TwitterSSO
21
+
22
+ __all__ = [
23
+ "OpenID",
24
+ "SSOBase",
25
+ "SSOLoginError",
26
+ "FacebookSSO",
27
+ "FitbitSSO",
28
+ "create_provider",
29
+ "GithubSSO",
30
+ "GitlabSSO",
31
+ "GoogleSSO",
32
+ "KakaoSSO",
33
+ "LineSSO",
34
+ "LinkedInSSO",
35
+ "MicrosoftSSO",
36
+ "NaverSSO",
37
+ "NotionSSO",
38
+ "SpotifySSO",
39
+ "TwitterSSO",
40
+ ]
@@ -1,4 +1,4 @@
1
- """PKCE-related helper functions"""
1
+ """PKCE-related helper functions."""
2
2
 
3
3
  import base64
4
4
  import hashlib
@@ -7,7 +7,7 @@ from typing import Tuple
7
7
 
8
8
 
9
9
  def get_code_verifier(length: int = 96) -> str:
10
- """Get code verifier for PKCE challenge"""
10
+ """Get code verifier for PKCE challenge."""
11
11
  length = max(43, min(length, 128))
12
12
  bytes_length = int(length * 3 / 4)
13
13
  return base64.urlsafe_b64encode(os.urandom(bytes_length)).decode("utf-8").replace("=", "")[:length]
@@ -1,14 +1,11 @@
1
- """SSO login base dependency
2
- """
3
-
4
- # pylint: disable=too-few-public-methods
1
+ """SSO login base dependency."""
5
2
 
6
3
  import json
4
+ import logging
7
5
  import os
8
- import sys
9
6
  import warnings
10
7
  from types import TracebackType
11
- from typing import Any, Dict, List, Literal, Optional, Type, Union, overload
8
+ from typing import Any, ClassVar, Dict, List, Literal, Optional, Type, TypedDict, Union, overload
12
9
 
13
10
  import httpx
14
11
  import pydantic
@@ -20,31 +17,33 @@ from starlette.responses import RedirectResponse
20
17
  from fastapi_sso.pkce import get_pkce_challenge_pair
21
18
  from fastapi_sso.state import generate_random_state
22
19
 
23
- if sys.version_info >= (3, 8):
24
- from typing import TypedDict
25
- else:
26
- from typing_extensions import TypedDict # pragma: no cover
20
+ logger = logging.getLogger(__name__)
21
+
27
22
 
28
- DiscoveryDocument = TypedDict(
29
- "DiscoveryDocument", {"authorization_endpoint": str, "token_endpoint": str, "userinfo_endpoint": str}
30
- )
23
+ class DiscoveryDocument(TypedDict):
24
+ """Discovery document."""
25
+
26
+ authorization_endpoint: str
27
+ token_endpoint: str
28
+ userinfo_endpoint: str
31
29
 
32
30
 
33
31
  class UnsetStateWarning(UserWarning):
34
- """Warning about unset state parameter"""
32
+ """Warning about unset state parameter."""
35
33
 
36
34
 
37
35
  class ReusedOauthClientWarning(UserWarning):
38
- """Warning about reused oauth client instance"""
36
+ """Warning about reused oauth client instance."""
39
37
 
40
38
 
41
39
  class SSOLoginError(HTTPException):
42
- """Raised when any login-related error ocurrs
43
- (such as when user is not verified or if there was an attempt for fake login)
40
+ """Raised when any login-related error ocurrs.
41
+
42
+ Such as when user is not verified or if there was an attempt for fake login.
44
43
  """
45
44
 
46
45
 
47
- class OpenID(pydantic.BaseModel): # pylint: disable=no-member
46
+ class OpenID(pydantic.BaseModel):
48
47
  """Class (schema) to represent information got from sso provider in a common form."""
49
48
 
50
49
  id: Optional[str] = None
@@ -56,16 +55,15 @@ class OpenID(pydantic.BaseModel): # pylint: disable=no-member
56
55
  provider: Optional[str] = None
57
56
 
58
57
 
59
- # pylint: disable=too-many-instance-attributes
60
58
  class SSOBase:
61
- """Base class (mixin) for all SSO providers"""
59
+ """Base class for all SSO providers."""
62
60
 
63
61
  provider: str = NotImplemented
64
62
  client_id: str = NotImplemented
65
63
  client_secret: str = NotImplemented
66
64
  redirect_uri: Optional[Union[pydantic.AnyHttpUrl, str]] = NotImplemented
67
- scope: List[str] = NotImplemented
68
- additional_headers: Optional[Dict[str, Any]] = None
65
+ scope: ClassVar[List[str]] = []
66
+ additional_headers: ClassVar[Optional[Dict[str, Any]]] = None
69
67
  uses_pkce: bool = False
70
68
  requires_state: bool = False
71
69
 
@@ -80,7 +78,7 @@ class SSOBase:
80
78
  use_state: bool = False,
81
79
  scope: Optional[List[str]] = None,
82
80
  ):
83
- # pylint: disable=too-many-arguments
81
+ """Base class (mixin) for all SSO providers."""
84
82
  self.client_id: str = client_id
85
83
  self.client_secret: str = client_secret
86
84
  self.redirect_uri: Optional[Union[pydantic.AnyHttpUrl, str]] = redirect_uri
@@ -89,6 +87,7 @@ class SSOBase:
89
87
  self._generated_state: Optional[str] = None
90
88
 
91
89
  if self.allow_insecure_http:
90
+ logger.debug("Initializing %s with allow_insecure_http=True", self.__class__.__name__)
92
91
  os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"
93
92
 
94
93
  # TODO: Remove use_state argument and attribute
@@ -100,7 +99,7 @@ class SSOBase:
100
99
  ),
101
100
  DeprecationWarning,
102
101
  )
103
- self.scope = scope or self.scope
102
+ self._scope = scope or self.scope
104
103
  self._refresh_token: Optional[str] = None
105
104
  self._id_token: Optional[str] = None
106
105
  self._state: Optional[str] = None
@@ -110,8 +109,7 @@ class SSOBase:
110
109
 
111
110
  @property
112
111
  def state(self) -> Optional[str]:
113
- """
114
- Retrieves the state as it was returned from the server.
112
+ """Retrieves the state as it was returned from the server.
115
113
 
116
114
  Warning:
117
115
  This will emit a warning if the state is unset, implying either that
@@ -131,8 +129,7 @@ class SSOBase:
131
129
 
132
130
  @property
133
131
  def oauth_client(self) -> WebApplicationClient:
134
- """
135
- Retrieves the OAuth Client to aid in generating requests and parsing responses.
132
+ """Retrieves the OAuth Client to aid in generating requests and parsing responses.
136
133
 
137
134
  Raises:
138
135
  NotImplementedError: If the provider is not supported or `client_id` is not set.
@@ -148,8 +145,7 @@ class SSOBase:
148
145
 
149
146
  @property
150
147
  def access_token(self) -> Optional[str]:
151
- """
152
- Retrieves the access token from token endpoint.
148
+ """Retrieves the access token from token endpoint.
153
149
 
154
150
  Returns:
155
151
  Optional[str]: The access token if available.
@@ -158,8 +154,7 @@ class SSOBase:
158
154
 
159
155
  @property
160
156
  def refresh_token(self) -> Optional[str]:
161
- """
162
- Retrieves the refresh token if returned from provider.
157
+ """Retrieves the refresh token if returned from provider.
163
158
 
164
159
  Returns:
165
160
  Optional[str]: The refresh token if available.
@@ -168,8 +163,7 @@ class SSOBase:
168
163
 
169
164
  @property
170
165
  def id_token(self) -> Optional[str]:
171
- """
172
- Retrieves the id token if returned from provider.
166
+ """Retrieves the id token if returned from provider.
173
167
 
174
168
  Returns:
175
169
  Optional[str]: The id token if available.
@@ -177,8 +171,7 @@ class SSOBase:
177
171
  return self._id_token
178
172
 
179
173
  async def openid_from_response(self, response: dict, session: Optional[httpx.AsyncClient] = None) -> OpenID:
180
- """
181
- Converts a response from the provider's user info endpoint to an OpenID object.
174
+ """Converts a response from the provider's user info endpoint to an OpenID object.
182
175
 
183
176
  Args:
184
177
  response (dict): The response from the user info endpoint.
@@ -193,8 +186,7 @@ class SSOBase:
193
186
  raise NotImplementedError(f"Provider {self.provider} not supported")
194
187
 
195
188
  async def get_discovery_document(self) -> DiscoveryDocument:
196
- """
197
- Retrieves the discovery document containing useful URLs.
189
+ """Retrieves the discovery document containing useful URLs.
198
190
 
199
191
  Raises:
200
192
  NotImplementedError: If the provider is not supported.
@@ -206,19 +198,19 @@ class SSOBase:
206
198
 
207
199
  @property
208
200
  async def authorization_endpoint(self) -> Optional[str]:
209
- """Return `authorization_endpoint` from discovery document"""
201
+ """Return `authorization_endpoint` from discovery document."""
210
202
  discovery = await self.get_discovery_document()
211
203
  return discovery.get("authorization_endpoint")
212
204
 
213
205
  @property
214
206
  async def token_endpoint(self) -> Optional[str]:
215
- """Return `token_endpoint` from discovery document"""
207
+ """Return `token_endpoint` from discovery document."""
216
208
  discovery = await self.get_discovery_document()
217
209
  return discovery.get("token_endpoint")
218
210
 
219
211
  @property
220
212
  async def userinfo_endpoint(self) -> Optional[str]:
221
- """Return `userinfo_endpoint` from discovery document"""
213
+ """Return `userinfo_endpoint` from discovery document."""
222
214
  discovery = await self.get_discovery_document()
223
215
  return discovery.get("userinfo_endpoint")
224
216
 
@@ -229,8 +221,7 @@ class SSOBase:
229
221
  params: Optional[Dict[str, Any]] = None,
230
222
  state: Optional[str] = None,
231
223
  ) -> str:
232
- """
233
- Generates and returns the prepared login URL.
224
+ """Generates and returns the prepared login URL.
234
225
 
235
226
  Args:
236
227
  redirect_uri (Optional[str]): Overrides the `redirect_uri` specified on this instance.
@@ -263,7 +254,7 @@ class SSOBase:
263
254
  await self.authorization_endpoint,
264
255
  redirect_uri=redirect_uri,
265
256
  state=state,
266
- scope=self.scope,
257
+ scope=self._scope,
267
258
  code_challenge=self._pkce_code_challenge,
268
259
  code_challenge_method=self._pkce_challenge_method,
269
260
  **params,
@@ -277,8 +268,7 @@ class SSOBase:
277
268
  params: Optional[Dict[str, Any]] = None,
278
269
  state: Optional[str] = None,
279
270
  ) -> RedirectResponse:
280
- """
281
- Constructs and returns a redirect response to the login page of OAuth SSO provider.
271
+ """Constructs and returns a redirect response to the login page of OAuth SSO provider.
282
272
 
283
273
  Args:
284
274
  redirect_uri (Optional[str]): Overrides the `redirect_uri` specified on this instance.
@@ -327,8 +317,7 @@ class SSOBase:
327
317
  redirect_uri: Optional[str] = None,
328
318
  convert_response: Union[Literal[True], Literal[False]] = True,
329
319
  ) -> Union[Optional[OpenID], Optional[Dict[str, Any]]]:
330
- """
331
- Processes the login given a FastAPI (Starlette) Request object. This should be used for the /callback path.
320
+ """Processes the login given a FastAPI (Starlette) Request object. This should be used for the /callback path.
332
321
 
333
322
  Args:
334
323
  request (Request): FastAPI or Starlette request object.
@@ -347,6 +336,12 @@ class SSOBase:
347
336
  headers = headers or {}
348
337
  code = request.query_params.get("code")
349
338
  if code is None:
339
+ logger.debug(
340
+ "Callback request:\n\tURI: %s\n\tHeaders: %s\n\tQuery params: %s",
341
+ request.url,
342
+ request.headers,
343
+ request.query_params,
344
+ )
350
345
  raise SSOLoginError(400, "'code' parameter was not found in callback request")
351
346
  self._state = request.query_params.get("state")
352
347
  pkce_code_verifier: Optional[str] = None
@@ -426,8 +421,7 @@ class SSOBase:
426
421
  pkce_code_verifier: Optional[str] = None,
427
422
  convert_response: Union[Literal[True], Literal[False]] = True,
428
423
  ) -> Union[Optional[OpenID], Optional[Dict[str, Any]]]:
429
- """
430
- Processes login from the callback endpoint to verify the user and request user info endpoint.
424
+ """Processes login from the callback endpoint to verify the user and request user info endpoint.
431
425
  It's a lower-level method, typically, you should use `verify_and_process` instead.
432
426
 
433
427
  Args:
@@ -446,7 +440,6 @@ class SSOBase:
446
440
  Optional[OpenID]: User information in OpenID format if the login was successful (convert_response == True).
447
441
  Optional[Dict[str, Any]]: Original userinfo API endpoint response.
448
442
  """
449
- # pylint: disable=too-many-locals
450
443
  if self._oauth_client is not None: # pragma: no cover
451
444
  self._oauth_client = None
452
445
  self._refresh_token = None
@@ -1,7 +1,6 @@
1
- """Facebook SSO Login Helper
2
- """
1
+ """Facebook SSO Login Helper."""
3
2
 
4
- from typing import TYPE_CHECKING, Optional
3
+ from typing import TYPE_CHECKING, ClassVar, Optional
5
4
 
6
5
  from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
7
6
 
@@ -10,14 +9,14 @@ if TYPE_CHECKING:
10
9
 
11
10
 
12
11
  class FacebookSSO(SSOBase):
13
- """Class providing login via Facebook OAuth"""
12
+ """Class providing login via Facebook OAuth."""
14
13
 
15
14
  provider = "facebook"
16
- base_url = "https://graph.facebook.com/v9.0"
17
- scope = ["email"]
15
+ base_url = "https://graph.facebook.com/v19.0"
16
+ scope: ClassVar = ["email"]
18
17
 
19
18
  async def get_discovery_document(self) -> DiscoveryDocument:
20
- """Get document containing handy urls"""
19
+ """Get document containing handy urls."""
21
20
  return {
22
21
  "authorization_endpoint": "https://www.facebook.com/v9.0/dialog/oauth",
23
22
  "token_endpoint": f"{self.base_url}/oauth/access_token",
@@ -25,9 +24,10 @@ class FacebookSSO(SSOBase):
25
24
  }
26
25
 
27
26
  async def openid_from_response(self, response: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID:
28
- """Return OpenID from user information provided by Facebook"""
27
+ """Return OpenID from user information provided by Facebook."""
28
+
29
29
  return OpenID(
30
- email=response.get("email", ""),
30
+ email=response.get("email"),
31
31
  first_name=response.get("first_name"),
32
32
  last_name=response.get("last_name"),
33
33
  display_name=response.get("name"),
@@ -1,7 +1,6 @@
1
- """Fitbit OAuth Login Helper
2
- """
1
+ """Fitbit OAuth Login Helper."""
3
2
 
4
- from typing import TYPE_CHECKING, Optional
3
+ from typing import TYPE_CHECKING, ClassVar, Optional
5
4
 
6
5
  from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase, SSOLoginError
7
6
 
@@ -10,13 +9,13 @@ if TYPE_CHECKING:
10
9
 
11
10
 
12
11
  class FitbitSSO(SSOBase):
13
- """Class providing login via Fitbit OAuth"""
12
+ """Class providing login via Fitbit OAuth."""
14
13
 
15
14
  provider = "fitbit"
16
- scope = ["profile"]
15
+ scope: ClassVar = ["profile"]
17
16
 
18
17
  async def openid_from_response(self, response: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID:
19
- """Return OpenID from user information provided by Google"""
18
+ """Return OpenID from user information provided by Google."""
20
19
  info = response.get("user")
21
20
  if not info:
22
21
  raise SSOLoginError(401, "Failed to process login via Fitbit")
@@ -29,7 +28,7 @@ class FitbitSSO(SSOBase):
29
28
  )
30
29
 
31
30
  async def get_discovery_document(self) -> DiscoveryDocument:
32
- """Get document containing handy urls"""
31
+ """Get document containing handy urls."""
33
32
  return {
34
33
  "authorization_endpoint": "https://www.fitbit.com/oauth2/authorize?response_type=code",
35
34
  "token_endpoint": "https://api.fitbit.com/oauth2/token",
@@ -1,5 +1,5 @@
1
1
  """A generic OAuth client that can be used to quickly create support for any OAuth provider
2
- with close to no code
2
+ with close to no code.
3
3
  """
4
4
 
5
5
  import logging
@@ -24,7 +24,6 @@ def create_provider(
24
24
  Returns a class.
25
25
 
26
26
  Args:
27
-
28
27
  name: Name of the provider
29
28
  default_scope: default list of scopes (can be overriden in constructor)
30
29
  discovery_document: a dictionary containing discovery document or a callable returning it
@@ -53,13 +52,13 @@ def create_provider(
53
52
  """
54
53
 
55
54
  class GenericSSOProvider(SSOBase):
56
- """SSO Provider Template"""
55
+ """SSO Provider Template."""
57
56
 
58
57
  provider = name
59
58
  scope = default_scope or ["openid"]
60
59
 
61
60
  async def get_discovery_document(self) -> DiscoveryDocument:
62
- """Get document containing handy urls"""
61
+ """Get document containing handy urls."""
63
62
  if callable(discovery_document):
64
63
  return discovery_document(self)
65
64
  return discovery_document
@@ -1,6 +1,6 @@
1
- """Github SSO Oauth Helper class"""
1
+ """Github SSO Oauth Helper class."""
2
2
 
3
- from typing import TYPE_CHECKING, Optional
3
+ from typing import TYPE_CHECKING, ClassVar, Optional
4
4
 
5
5
  from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
6
6
 
@@ -9,11 +9,11 @@ if TYPE_CHECKING:
9
9
 
10
10
 
11
11
  class GithubSSO(SSOBase):
12
- """Class providing login via Github SSO"""
12
+ """Class providing login via Github SSO."""
13
13
 
14
14
  provider = "github"
15
- scope = ["user:email"]
16
- additional_headers = {"accept": "application/json"}
15
+ scope: ClassVar = ["user:email"]
16
+ additional_headers: ClassVar = {"accept": "application/json"}
17
17
  emails_endpoint = "https://api.github.com/user/emails"
18
18
 
19
19
  async def get_discovery_document(self) -> DiscoveryDocument:
@@ -25,7 +25,8 @@ class GithubSSO(SSOBase):
25
25
 
26
26
  async def _get_primary_email(self, session: Optional["httpx.AsyncClient"] = None) -> Optional[str]:
27
27
  """Attempt to get primary email from Github for a current user.
28
- The session received must be authenticated."""
28
+ The session received must be authenticated.
29
+ """
29
30
  if not session:
30
31
  return None
31
32
  response = await session.get(self.emails_endpoint)
@@ -1,6 +1,6 @@
1
- """Gitlab SSO Oauth Helper class"""
1
+ """Gitlab SSO Oauth Helper class."""
2
2
 
3
- from typing import TYPE_CHECKING, List, Optional, Tuple, Union
3
+ from typing import TYPE_CHECKING, ClassVar, List, Optional, Tuple, Union
4
4
  from urllib.parse import urljoin
5
5
 
6
6
  import pydantic
@@ -12,11 +12,11 @@ if TYPE_CHECKING:
12
12
 
13
13
 
14
14
  class GitlabSSO(SSOBase):
15
- """Class providing login via Gitlab SSO"""
15
+ """Class providing login via Gitlab SSO."""
16
16
 
17
17
  provider = "gitlab"
18
- scope = ["read_user", "openid", "profile"]
19
- additional_headers = {"accept": "application/json"}
18
+ scope: ClassVar = ["read_user", "openid", "profile"]
19
+ additional_headers: ClassVar = {"accept": "application/json"}
20
20
  base_endpoint_url = "https://gitlab.com"
21
21
 
22
22
  def __init__(
@@ -41,7 +41,6 @@ class GitlabSSO(SSOBase):
41
41
 
42
42
  async def get_discovery_document(self) -> DiscoveryDocument:
43
43
  """Override the discovery document method to return Yandex OAuth endpoints."""
44
-
45
44
  return {
46
45
  "authorization_endpoint": urljoin(self.base_endpoint_url, "/oauth/authorize"),
47
46
  "token_endpoint": urljoin(self.base_endpoint_url, "/oauth/token"),
@@ -1,7 +1,6 @@
1
- """Google SSO Login Helper
2
- """
1
+ """Google SSO Login Helper."""
3
2
 
4
- from typing import Optional
3
+ from typing import ClassVar, Optional
5
4
 
6
5
  import httpx
7
6
 
@@ -9,17 +8,17 @@ from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase, SSOLoginErr
9
8
 
10
9
 
11
10
  class GoogleSSO(SSOBase):
12
- """Class providing login via Google OAuth"""
11
+ """Class providing login via Google OAuth."""
13
12
 
14
13
  discovery_url = "https://accounts.google.com/.well-known/openid-configuration"
15
14
  provider = "google"
16
- scope = ["openid", "email", "profile"]
15
+ scope: ClassVar = ["openid", "email", "profile"]
17
16
 
18
17
  async def openid_from_response(self, response: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID:
19
- """Return OpenID from user information provided by Google"""
18
+ """Return OpenID from user information provided by Google."""
20
19
  if response.get("email_verified"):
21
20
  return OpenID(
22
- email=response.get("email", ""),
21
+ email=response.get("email"),
23
22
  provider=self.provider,
24
23
  id=response.get("sub"),
25
24
  first_name=response.get("given_name"),
@@ -30,7 +29,7 @@ class GoogleSSO(SSOBase):
30
29
  raise SSOLoginError(401, f"User {response.get('email')} is not verified with Google")
31
30
 
32
31
  async def get_discovery_document(self) -> DiscoveryDocument:
33
- """Get document containing handy urls"""
32
+ """Get document containing handy urls."""
34
33
  async with httpx.AsyncClient() as session:
35
34
  response = await session.get(self.discovery_url)
36
35
  content = response.json()
@@ -1,6 +1,6 @@
1
- """Kakao SSO Oauth Helper class"""
1
+ """Kakao SSO Oauth Helper class."""
2
2
 
3
- from typing import TYPE_CHECKING, Optional
3
+ from typing import TYPE_CHECKING, ClassVar, Optional
4
4
 
5
5
  from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
6
6
 
@@ -9,10 +9,10 @@ if TYPE_CHECKING:
9
9
 
10
10
 
11
11
  class KakaoSSO(SSOBase):
12
- """Class providing login using Kakao OAuth"""
12
+ """Class providing login using Kakao OAuth."""
13
13
 
14
14
  provider = "kakao"
15
- scope = ["openid"]
15
+ scop: ClassVar = ["openid"]
16
16
  version = "v2"
17
17
 
18
18
  async def get_discovery_document(self) -> DiscoveryDocument:
@@ -1,7 +1,6 @@
1
- """Line SSO Login Helper
2
- """
1
+ """Line SSO Login Helper."""
3
2
 
4
- from typing import TYPE_CHECKING, Optional
3
+ from typing import TYPE_CHECKING, ClassVar, Optional
5
4
 
6
5
  from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
7
6
 
@@ -10,14 +9,14 @@ if TYPE_CHECKING:
10
9
 
11
10
 
12
11
  class LineSSO(SSOBase):
13
- """Class providing login via Line OAuth"""
12
+ """Class providing login via Line OAuth."""
14
13
 
15
14
  provider = "line"
16
15
  base_url = "https://api.line.me/oauth2/v2.1"
17
- scope = ["email", "profile", "openid"]
16
+ scope: ClassVar = ["email", "profile", "openid"]
18
17
 
19
18
  async def get_discovery_document(self) -> DiscoveryDocument:
20
- """Get document containing handy urls"""
19
+ """Get document containing handy urls."""
21
20
  return {
22
21
  "authorization_endpoint": "https://access.line.me/oauth2/v2.1/authorize",
23
22
  "token_endpoint": f"{self.base_url}/token",
@@ -25,7 +24,7 @@ class LineSSO(SSOBase):
25
24
  }
26
25
 
27
26
  async def openid_from_response(self, response: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID:
28
- """Return OpenID from user information provided by Line"""
27
+ """Return OpenID from user information provided by Line."""
29
28
  return OpenID(
30
29
  email=response.get("email"),
31
30
  first_name=None,
@@ -1,6 +1,6 @@
1
- """LinkedIn SSO Oauth Helper class"""
1
+ """LinkedIn SSO Oauth Helper class."""
2
2
 
3
- from typing import TYPE_CHECKING, Dict, Optional
3
+ from typing import TYPE_CHECKING, ClassVar, Dict, Optional
4
4
 
5
5
  from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
6
6
 
@@ -9,11 +9,11 @@ if TYPE_CHECKING:
9
9
 
10
10
 
11
11
  class LinkedInSSO(SSOBase):
12
- """Class providing login via LinkedIn SSO"""
12
+ """Class providing login via LinkedIn SSO."""
13
13
 
14
14
  provider = "linkedin"
15
- scope = ["openid", "profile", "email"]
16
- additional_headers = {"accept": "application/json"}
15
+ scope: ClassVar = ["openid", "profile", "email"]
16
+ additional_headers: ClassVar = {"accept": "application/json"}
17
17
 
18
18
  @property
19
19
  def _extra_query_params(self) -> Dict:
@@ -1,6 +1,6 @@
1
- """Microsoft SSO Oauth Helper class"""
1
+ """Microsoft SSO Oauth Helper class."""
2
2
 
3
- from typing import TYPE_CHECKING, List, Optional, Union
3
+ from typing import TYPE_CHECKING, ClassVar, List, Optional, Union
4
4
 
5
5
  import pydantic
6
6
 
@@ -11,10 +11,10 @@ if TYPE_CHECKING:
11
11
 
12
12
 
13
13
  class MicrosoftSSO(SSOBase):
14
- """Class providing login using Microsoft OAuth"""
14
+ """Class providing login using Microsoft OAuth."""
15
15
 
16
16
  provider = "microsoft"
17
- scope = ["openid", "User.Read", "email"]
17
+ scope: ClassVar = ["openid", "User.Read", "email"]
18
18
  version = "v1.0"
19
19
  tenant: str = "common"
20
20
 
@@ -1,6 +1,6 @@
1
- """Naver SSO Oauth Helper class"""
1
+ """Naver SSO Oauth Helper class."""
2
2
 
3
- from typing import TYPE_CHECKING, List, Optional
3
+ from typing import TYPE_CHECKING, ClassVar, List, Optional
4
4
 
5
5
  from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
6
6
 
@@ -9,11 +9,11 @@ if TYPE_CHECKING:
9
9
 
10
10
 
11
11
  class NaverSSO(SSOBase):
12
- """Class providing login using Naver OAuth"""
12
+ """Class providing login using Naver OAuth."""
13
13
 
14
14
  provider = "naver"
15
- scope: List[str] = []
16
- additional_headers = {"accept": "application/json"}
15
+ scope: ClassVar[List[str]] = []
16
+ additional_headers: ClassVar = {"accept": "application/json"}
17
17
 
18
18
  async def get_discovery_document(self) -> DiscoveryDocument:
19
19
  return {
@@ -1,6 +1,6 @@
1
- """Notion SSO Oauth Helper class"""
1
+ """Notion SSO Oauth Helper class."""
2
2
 
3
- from typing import TYPE_CHECKING, Optional
3
+ from typing import TYPE_CHECKING, ClassVar, Optional
4
4
 
5
5
  from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase, SSOLoginError
6
6
 
@@ -9,11 +9,11 @@ if TYPE_CHECKING:
9
9
 
10
10
 
11
11
  class NotionSSO(SSOBase):
12
- """Class providing login using Notion OAuth"""
12
+ """Class providing login using Notion OAuth."""
13
13
 
14
14
  provider = "notion"
15
- scope = ["openid"]
16
- additional_headers = {"Notion-Version": "2022-06-28"}
15
+ scope: ClassVar = ["openid"]
16
+ additional_headers: ClassVar = {"Notion-Version": "2022-06-28"}
17
17
 
18
18
  async def get_discovery_document(self) -> DiscoveryDocument:
19
19
  return {
@@ -1,7 +1,6 @@
1
- """Spotify SSO Login Helper
2
- """
1
+ """Spotify SSO Login Helper."""
3
2
 
4
- from typing import TYPE_CHECKING, Optional
3
+ from typing import TYPE_CHECKING, ClassVar, Optional
5
4
 
6
5
  from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
7
6
 
@@ -10,13 +9,13 @@ if TYPE_CHECKING:
10
9
 
11
10
 
12
11
  class SpotifySSO(SSOBase):
13
- """Class providing login via Spotify OAuth"""
12
+ """Class providing login via Spotify OAuth."""
14
13
 
15
14
  provider = "spotify"
16
- scope = ["user-read-private", "user-read-email"]
15
+ scope: ClassVar = ["user-read-private", "user-read-email"]
17
16
 
18
17
  async def get_discovery_document(self) -> DiscoveryDocument:
19
- """Get document containing handy urls"""
18
+ """Get document containing handy urls."""
20
19
  return {
21
20
  "authorization_endpoint": "https://accounts.spotify.com/authorize",
22
21
  "token_endpoint": "https://accounts.spotify.com/api/token",
@@ -24,13 +23,10 @@ class SpotifySSO(SSOBase):
24
23
  }
25
24
 
26
25
  async def openid_from_response(self, response: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID:
27
- """Return OpenID from user information provided by Spotify"""
28
- if response.get("images", []):
29
- picture = response["images"][0]["url"]
30
- else:
31
- picture = None
26
+ """Return OpenID from user information provided by Spotify."""
27
+ picture = response["images"][0]["url"] if response.get("images", []) else None
32
28
  return OpenID(
33
- email=response.get("email", ""),
29
+ email=response.get("email"),
34
30
  display_name=response.get("display_name"),
35
31
  provider=self.provider,
36
32
  id=response.get("id"),
@@ -1,6 +1,6 @@
1
- """Twitter (X) SSO Oauth Helper class"""
1
+ """Twitter (X) SSO Oauth Helper class."""
2
2
 
3
- from typing import TYPE_CHECKING, Optional
3
+ from typing import TYPE_CHECKING, ClassVar, Optional
4
4
 
5
5
  from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
6
6
 
@@ -9,10 +9,10 @@ if TYPE_CHECKING:
9
9
 
10
10
 
11
11
  class TwitterSSO(SSOBase):
12
- """Class providing login via Twitter SSO"""
12
+ """Class providing login via Twitter SSO."""
13
13
 
14
14
  provider = "twitter"
15
- scope = ["users.read", "tweet.read"]
15
+ scope: ClassVar = ["users.read", "tweet.read"]
16
16
  uses_pkce = True
17
17
  requires_state = True
18
18
 
@@ -1,7 +1,6 @@
1
- """Yandex SSO Login Helper
2
- """
1
+ """Yandex SSO Login Helper."""
3
2
 
4
- from typing import TYPE_CHECKING, Optional
3
+ from typing import TYPE_CHECKING, ClassVar, Optional
5
4
 
6
5
  from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
7
6
 
@@ -13,12 +12,11 @@ class YandexSSO(SSOBase):
13
12
  """Class providing login using Yandex OAuth."""
14
13
 
15
14
  provider = "yandex"
16
- scope = ["login:email", "login:info", "login:avatar"]
15
+ scope: ClassVar = ["login:email", "login:info", "login:avatar"]
17
16
  avatar_url = "https://avatars.yandex.net/get-yapic"
18
17
 
19
18
  async def get_discovery_document(self) -> DiscoveryDocument:
20
19
  """Override the discovery document method to return Yandex OAuth endpoints."""
21
-
22
20
  return {
23
21
  "authorization_endpoint": "https://oauth.yandex.ru/authorize",
24
22
  "token_endpoint": "https://oauth.yandex.ru/token",
@@ -27,7 +25,6 @@ class YandexSSO(SSOBase):
27
25
 
28
26
  async def openid_from_response(self, response: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID:
29
27
  """Converts Yandex user info response to OpenID object."""
30
-
31
28
  picture = None
32
29
 
33
30
  if (avatar_id := response.get("default_avatar_id")) is not None:
@@ -1,10 +1,10 @@
1
- """Helper functions to generate state param"""
1
+ """Helper functions to generate state param."""
2
2
 
3
3
  import base64
4
4
  import os
5
5
 
6
6
 
7
7
  def generate_random_state(length: int = 64) -> str:
8
- """Generate a url-safe string to use as a state"""
8
+ """Generate a url-safe string to use as a state."""
9
9
  bytes_length = int(length * 3 / 4)
10
10
  return base64.urlsafe_b64encode(os.urandom(bytes_length)).decode("utf-8")
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "fastapi-sso"
3
- version = "0.14.2"
3
+ version = "0.15.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"
@@ -37,10 +37,46 @@ addopts = [
37
37
  [tool.black]
38
38
  line-length = 120
39
39
 
40
+ [tool.ruff]
41
+ target-version = "py38"
42
+ line-length = 120
43
+
44
+ [tool.ruff.lint]
45
+ select = [
46
+ #"D",
47
+ "E",
48
+ "F",
49
+ "B",
50
+ "I",
51
+ "N",
52
+ "UP",
53
+ "S",
54
+ "A",
55
+ "DTZ",
56
+ "PT",
57
+ "SIM",
58
+ "PTH",
59
+ "PD",
60
+ "RUF",
61
+ "T20",
62
+ ]
63
+
64
+ ignore = [
65
+ "B028", # allow warning without specifying `stacklevel`
66
+ ]
67
+
68
+ [tool.ruff.lint.pydocstyle]
69
+ convention = "google"
70
+
71
+ [tool.ruff.lint.isort]
72
+ known-first-party = ["fastapi_sso"]
73
+
74
+ [tool.ruff.lint.per-file-ignores]
75
+ "tests*/**/*.py" = ["S101"] # Allow asserts in tests
76
+ "**/__init__.py" = ["D104"] # Allow missing docstrings in __init__ files
40
77
 
41
78
  [tool.poe.tasks]
42
- pylint = "pylint --disable fixme --rcfile .pylintrc fastapi_sso"
43
- todos = "pylint --disable all --enable fixme --rcfile .pylintrc fastapi_sso"
79
+ ruff = "ruff check fastapi_sso"
44
80
  black = "black fastapi_sso"
45
81
  isort = "isort --settings-path .isort.cfg fastapi_sso"
46
82
  mypy = "mypy --config-file mypy.ini fastapi_sso"
@@ -48,7 +84,7 @@ black-check = "black --check fastapi_sso"
48
84
  isort-check = "isort --settings-path .isort.cfg --check-only fastapi_sso"
49
85
 
50
86
  format = ["black", "isort"]
51
- lint = ["pylint", "mypy", "black-check", "isort-check"]
87
+ lint = ["ruff", "mypy", "black-check", "isort-check"]
52
88
  pre-commit = "pre-commit"
53
89
 
54
90
  test = "pytest"
@@ -61,17 +97,17 @@ black = ">=23.7.0"
61
97
  isort = "^5"
62
98
  markdown-include = "^0.8.1"
63
99
  mkdocs-material = { extras = ["imaging"], version = "^9.3.2" }
64
- mkdocstrings = { extras = ["python"], version = ">=0.23,<0.25" }
100
+ mkdocstrings = { extras = ["python"], version = ">=0.23,<0.26" }
65
101
  mypy = "^1"
66
- poethepoet = ">=0.21.1,<0.26.0"
102
+ poethepoet = ">=0.21.1,<0.27.0"
67
103
  pre-commit = "^3"
68
- pylint = ">=2,<4"
69
104
  pytest = ">=7,<9"
70
105
  pytest-asyncio = ">=0.21.1,<0.24.0"
71
106
  pytest-cov = ">=4,<6"
72
107
  pytest-xdist = "^3"
73
108
  tox = "^4"
74
109
  uvicorn = ">=0.23.1"
110
+ ruff = "^0.4.2"
75
111
 
76
112
  [tool.poetry.dependencies]
77
113
  fastapi = ">=0.80"
File without changes