fastapi-sso 0.12.2__py3-none-any.whl → 0.13.0__py3-none-any.whl

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.
fastapi_sso/__init__.py CHANGED
@@ -16,3 +16,4 @@ from .sso.microsoft import MicrosoftSSO
16
16
  from .sso.naver import NaverSSO
17
17
  from .sso.notion import NotionSSO
18
18
  from .sso.spotify import SpotifySSO
19
+ from .sso.twitter import TwitterSSO
fastapi_sso/pkce.py ADDED
@@ -0,0 +1,25 @@
1
+ """PKCE-related helper functions"""
2
+
3
+ import base64
4
+ import hashlib
5
+ import os
6
+ from typing import Tuple
7
+
8
+
9
+ def get_code_verifier(length: int = 96) -> str:
10
+ """Get code verifier for PKCE challenge"""
11
+ length = max(43, min(length, 128))
12
+ bytes_length = int(length * 3 / 4)
13
+ return base64.urlsafe_b64encode(os.urandom(bytes_length)).decode("utf-8").replace("=", "")[:length]
14
+
15
+
16
+ def get_pkce_challenge_pair(verifier_length: int = 96) -> Tuple[str, str]:
17
+ """Get tuple of (verifier, challenge) for PKCE challenge."""
18
+ code_verifier = get_code_verifier(verifier_length)
19
+ code_challenge = (
20
+ base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode("utf-8")).digest())
21
+ .decode("utf-8")
22
+ .replace("=", "")
23
+ )
24
+
25
+ return (code_verifier, code_challenge)
fastapi_sso/sso/base.py CHANGED
@@ -17,6 +17,9 @@ from starlette.exceptions import HTTPException
17
17
  from starlette.requests import Request
18
18
  from starlette.responses import RedirectResponse
19
19
 
20
+ from fastapi_sso.pkce import get_pkce_challenge_pair
21
+ from fastapi_sso.state import generate_random_state
22
+
20
23
  if sys.version_info >= (3, 8):
21
24
  from typing import TypedDict
22
25
  else:
@@ -63,6 +66,10 @@ class SSOBase:
63
66
  redirect_uri: Optional[Union[pydantic.AnyHttpUrl, str]] = NotImplemented
64
67
  scope: List[str] = NotImplemented
65
68
  additional_headers: Optional[Dict[str, Any]] = None
69
+ uses_pkce: bool = False
70
+ requires_state: bool = False
71
+
72
+ _pkce_challenge_length: int = 96
66
73
 
67
74
  def __init__(
68
75
  self,
@@ -79,6 +86,7 @@ class SSOBase:
79
86
  self.redirect_uri: Optional[Union[pydantic.AnyHttpUrl, str]] = redirect_uri
80
87
  self.allow_insecure_http: bool = allow_insecure_http
81
88
  self._oauth_client: Optional[WebApplicationClient] = None
89
+ self._generated_state: Optional[str] = None
82
90
 
83
91
  if self.allow_insecure_http:
84
92
  os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"
@@ -96,6 +104,9 @@ class SSOBase:
96
104
  self._refresh_token: Optional[str] = None
97
105
  self._id_token: Optional[str] = None
98
106
  self._state: Optional[str] = None
107
+ self._pkce_code_challenge: Optional[str] = None
108
+ self._pkce_code_verifier: Optional[str] = None
109
+ self._pkce_challenge_method = "S256"
99
110
 
100
111
  @property
101
112
  def state(self) -> Optional[str]:
@@ -236,8 +247,26 @@ class SSOBase:
236
247
  redirect_uri = redirect_uri or self.redirect_uri
237
248
  if redirect_uri is None:
238
249
  raise ValueError("redirect_uri must be provided, either at construction or request time")
250
+ if self.uses_pkce and not all((self._pkce_code_verifier, self._pkce_code_challenge)):
251
+ warnings.warn(
252
+ f"{self.__class__.__name__!r} uses PKCE and no code was generated yet. "
253
+ "Use SSO class as a context manager to get rid of this warning and possible errors."
254
+ )
255
+ if self.requires_state and not state:
256
+ if self._generated_state is None:
257
+ warnings.warn(
258
+ f"{self.__class__.__name__!r} requires state in the request but none was provided nor "
259
+ "generated automatically. Use SSO as a context manager. The login process will most probably fail."
260
+ )
261
+ state = self._generated_state
239
262
  request_uri = self.oauth_client.prepare_request_uri(
240
- await self.authorization_endpoint, redirect_uri=redirect_uri, state=state, scope=self.scope, **params
263
+ await self.authorization_endpoint,
264
+ redirect_uri=redirect_uri,
265
+ state=state,
266
+ scope=self.scope,
267
+ code_challenge=self._pkce_code_challenge,
268
+ code_challenge_method=self._pkce_challenge_method,
269
+ **params,
241
270
  )
242
271
  return request_uri
243
272
 
@@ -259,8 +288,12 @@ class SSOBase:
259
288
  Returns:
260
289
  RedirectResponse: A Starlette response directing to the login page of the OAuth SSO provider.
261
290
  """
291
+ if self.requires_state and not state:
292
+ state = self._generated_state
262
293
  login_uri = await self.get_login_url(redirect_uri=redirect_uri, params=params, state=state)
263
294
  response = RedirectResponse(login_uri, 303)
295
+ if self.uses_pkce:
296
+ response.set_cookie("pkce_code_verifier", str(self._pkce_code_verifier))
264
297
  return response
265
298
 
266
299
  async def verify_and_process(
@@ -291,14 +324,31 @@ class SSOBase:
291
324
  if code is None:
292
325
  raise SSOLoginError(400, "'code' parameter was not found in callback request")
293
326
  self._state = request.query_params.get("state")
327
+ pkce_code_verifier: Optional[str] = None
328
+ if self.uses_pkce:
329
+ pkce_code_verifier = request.cookies.get("pkce_code_verifier")
330
+ if pkce_code_verifier is None:
331
+ warnings.warn(
332
+ "PKCE code verifier was not found in the request Cookie. This will probably lead to a login error."
333
+ )
294
334
  return await self.process_login(
295
- code, request, params=params, additional_headers=headers, redirect_uri=redirect_uri
335
+ code,
336
+ request,
337
+ params=params,
338
+ additional_headers=headers,
339
+ redirect_uri=redirect_uri,
340
+ pkce_code_verifier=pkce_code_verifier,
296
341
  )
297
342
 
298
343
  def __enter__(self) -> "SSOBase":
299
344
  self._oauth_client = None
300
345
  self._refresh_token = None
301
346
  self._id_token = None
347
+ self._state = None
348
+ if self.requires_state:
349
+ self._generated_state = generate_random_state()
350
+ if self.uses_pkce:
351
+ self._pkce_code_verifier, self._pkce_code_challenge = get_pkce_challenge_pair(self._pkce_challenge_length)
302
352
  return self
303
353
 
304
354
  def __exit__(
@@ -321,6 +371,7 @@ class SSOBase:
321
371
  params: Optional[Dict[str, Any]] = None,
322
372
  additional_headers: Optional[Dict[str, Any]] = None,
323
373
  redirect_uri: Optional[str] = None,
374
+ pkce_code_verifier: Optional[str] = None,
324
375
  ) -> Optional[OpenID]:
325
376
  """
326
377
  Processes login from the callback endpoint to verify the user and request user info endpoint.
@@ -332,6 +383,7 @@ class SSOBase:
332
383
  params (Optional[Dict[str, Any]]): Additional query parameters to pass to the provider.
333
384
  additional_headers (Optional[Dict[str, Any]]): Additional headers to be added to all requests.
334
385
  redirect_uri (Optional[str]): Overrides the `redirect_uri` specified on this instance.
386
+ pkce_code_verifier (Optional[str]): A PKCE code verifier sent to the server to verify the login request.
335
387
 
336
388
  Raises:
337
389
  ReusedOauthClientWarning: If the SSO object is reused, which is not safe and caused security issues.
@@ -379,8 +431,12 @@ class SSOBase:
379
431
  headers.update(additional_headers)
380
432
 
381
433
  auth = httpx.BasicAuth(self.client_id, self.client_secret)
434
+
435
+ if pkce_code_verifier:
436
+ params.update({"code_verifier": pkce_code_verifier})
437
+
382
438
  async with httpx.AsyncClient() as session:
383
- response = await session.post(token_url, headers=headers, content=body, auth=auth)
439
+ response = await session.post(token_url, headers=headers, content=body, auth=auth, params=params)
384
440
  content = response.json()
385
441
  self._refresh_token = content.get("refresh_token")
386
442
  self._id_token = content.get("id_token")
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Optional
6
6
  from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
7
7
 
8
8
  if TYPE_CHECKING:
9
- import httpx
9
+ import httpx # pragma: no cover
10
10
 
11
11
 
12
12
  class FacebookSSO(SSOBase):
fastapi_sso/sso/fitbit.py CHANGED
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Optional
6
6
  from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase, SSOLoginError
7
7
 
8
8
  if TYPE_CHECKING:
9
- import httpx
9
+ import httpx # pragma: no cover
10
10
 
11
11
 
12
12
  class FitbitSSO(SSOBase):
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Type, Uni
8
8
  from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
9
9
 
10
10
  if TYPE_CHECKING:
11
- import httpx
11
+ import httpx # pragma: no cover
12
12
 
13
13
  logger = logging.getLogger(__name__)
14
14
 
fastapi_sso/sso/github.py CHANGED
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Optional
5
5
  from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
6
6
 
7
7
  if TYPE_CHECKING:
8
- import httpx
8
+ import httpx # pragma: no cover
9
9
 
10
10
 
11
11
  class GithubSSO(SSOBase):
fastapi_sso/sso/gitlab.py CHANGED
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Optional
5
5
  from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
6
6
 
7
7
  if TYPE_CHECKING:
8
- import httpx
8
+ import httpx # pragma: no cover
9
9
 
10
10
 
11
11
  class GitlabSSO(SSOBase):
fastapi_sso/sso/kakao.py CHANGED
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Optional
5
5
  from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
6
6
 
7
7
  if TYPE_CHECKING:
8
- import httpx
8
+ import httpx # pragma: no cover
9
9
 
10
10
 
11
11
  class KakaoSSO(SSOBase):
fastapi_sso/sso/line.py CHANGED
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Optional
6
6
  from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
7
7
 
8
8
  if TYPE_CHECKING:
9
- import httpx
9
+ import httpx # pragma: no cover
10
10
 
11
11
 
12
12
  class LineSSO(SSOBase):
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Dict, Optional
5
5
  from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
6
6
 
7
7
  if TYPE_CHECKING:
8
- import httpx
8
+ import httpx # pragma: no cover
9
9
 
10
10
 
11
11
  class LinkedInSSO(SSOBase):
@@ -7,7 +7,7 @@ import pydantic
7
7
  from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
8
8
 
9
9
  if TYPE_CHECKING:
10
- import httpx
10
+ import httpx # pragma: no cover
11
11
 
12
12
 
13
13
  class MicrosoftSSO(SSOBase):
fastapi_sso/sso/naver.py CHANGED
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, List, Optional
5
5
  from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
6
6
 
7
7
  if TYPE_CHECKING:
8
- import httpx
8
+ import httpx # pragma: no cover
9
9
 
10
10
 
11
11
  class NaverSSO(SSOBase):
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Optional
6
6
  from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
7
7
 
8
8
  if TYPE_CHECKING:
9
- import httpx
9
+ import httpx # pragma: no cover
10
10
 
11
11
 
12
12
  class SpotifySSO(SSOBase):
@@ -0,0 +1,35 @@
1
+ """Twitter (X) SSO Oauth Helper class"""
2
+
3
+ from typing import TYPE_CHECKING, Optional
4
+
5
+ from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
6
+
7
+ if TYPE_CHECKING:
8
+ import httpx # pragma: no cover
9
+
10
+
11
+ class TwitterSSO(SSOBase):
12
+ """Class providing login via Twitter SSO"""
13
+
14
+ provider = "twitter"
15
+ scope = ["users.read", "tweet.read"]
16
+ uses_pkce = True
17
+ requires_state = True
18
+
19
+ async def get_discovery_document(self) -> DiscoveryDocument:
20
+ return {
21
+ "authorization_endpoint": "https://twitter.com/i/oauth2/authorize",
22
+ "token_endpoint": "https://api.twitter.com/2/oauth2/token",
23
+ "userinfo_endpoint": "https://api.twitter.com/2/users/me",
24
+ }
25
+
26
+ async def openid_from_response(self, response: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID:
27
+ first_name, *last_name_parts = response["data"].get("name", "").split(" ")
28
+ last_name = " ".join(last_name_parts) if last_name_parts else None
29
+ return OpenID(
30
+ id=str(response["data"]["id"]),
31
+ display_name=response["data"]["username"],
32
+ first_name=first_name,
33
+ last_name=last_name,
34
+ provider=self.provider,
35
+ )
fastapi_sso/state.py ADDED
@@ -0,0 +1,10 @@
1
+ """Helper functions to generate state param"""
2
+
3
+ import base64
4
+ import os
5
+
6
+
7
+ def generate_random_state(length: int = 64) -> str:
8
+ """Generate a url-safe string to use as a state"""
9
+ bytes_length = int(length * 3 / 4)
10
+ return base64.urlsafe_b64encode(os.urandom(bytes_length)).decode("utf-8")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fastapi-sso
3
- Version: 0.12.2
3
+ Version: 0.13.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
@@ -0,0 +1,24 @@
1
+ fastapi_sso/__init__.py,sha256=bQH_ECRRvpAQfPfpe5bIiGdfRoPULFgvbSjGS9OPkYQ,690
2
+ fastapi_sso/pkce.py,sha256=_J9wMCaSwPsUk32qoHzorhB9HG79YVifcQcRA4l_N7I,790
3
+ fastapi_sso/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ fastapi_sso/sso/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ fastapi_sso/sso/base.py,sha256=VypbWdKTC4B2cmHpNv7TI8VjbI0Sl1gMwak8cHf3Z0w,17130
6
+ fastapi_sso/sso/facebook.py,sha256=fyO9R7wmhuDp8gZWOp5hogMfTLbff_7ms1XcrLL9udU,1347
7
+ fastapi_sso/sso/fitbit.py,sha256=ofWcrGVeZk6n4b59RI9dLxPz_GzpizJiLNGLvrIphmY,1278
8
+ fastapi_sso/sso/generic.py,sha256=nfH_ju0zRM4HAX-YYHMulSGRb_UEr342YAcK8JtJ3CE,2645
9
+ fastapi_sso/sso/github.py,sha256=GA0jalPGjgBvhJH2SSyIxiizcj6PR8ccOXQYemmaKpQ,1713
10
+ fastapi_sso/sso/gitlab.py,sha256=cHf6fy8jP2zQYt1NUp3ah5qf0134n88FLk2sHZeWZds,1052
11
+ fastapi_sso/sso/google.py,sha256=iyVqh5YG5Xw-Ss4y8xEv1APDvubdI9Vt3ijvPMmStVk,1380
12
+ fastapi_sso/sso/kakao.py,sha256=-St6EEI0r3oFFphFd7UgqppA0z7XtZ22FmBOyhPfJ9M,884
13
+ fastapi_sso/sso/line.py,sha256=1u27gqq8ZNiNn3XRumyTzNTj67vyTlo_HfVBRj4_crI,1210
14
+ fastapi_sso/sso/linkedin.py,sha256=huoWB0qz1lToq0arPTwr13ZlapQUzy8EBklqsqF84-E,1272
15
+ fastapi_sso/sso/microsoft.py,sha256=WX5WQRNt0kYdQO7XeJXS6dE_k-pO1y3Ty4_IuZPc0gI,1938
16
+ fastapi_sso/sso/naver.py,sha256=L9rMTESnZrKZb1Z3odxn2oR2hxjrbnPa8rG8LcypbqU,921
17
+ fastapi_sso/sso/notion.py,sha256=3OU70JpE4mle9DXyZrf4sRpwq1raWcOHjFJrRmsI-EM,1302
18
+ fastapi_sso/sso/spotify.py,sha256=EyCuUK1fiP-I7NSg1ssDoPJilHnIULyXB4AyJNxPiYg,1269
19
+ fastapi_sso/sso/twitter.py,sha256=NsRbKZlau_8gZNKgCywlggKv1jZLRP1oDXTp-TId2PU,1227
20
+ fastapi_sso/state.py,sha256=bwqsl73I5_VSf-TfCTvDfVb7L_3PwUqtP7jDjco-8Nw,298
21
+ fastapi_sso-0.13.0.dist-info/LICENSE.md,sha256=5NVQtYs6liDtYdWM4VObWmTTKaK0k9C9txx5pLPJSyQ,1093
22
+ fastapi_sso-0.13.0.dist-info/METADATA,sha256=XQw80jXm5vDEMzgKxrNhiN1xek3iZXbp4O7ZXQClTEI,4549
23
+ fastapi_sso-0.13.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
24
+ fastapi_sso-0.13.0.dist-info/RECORD,,
@@ -1,21 +0,0 @@
1
- fastapi_sso/__init__.py,sha256=aqYfVqDcq3sk8BuPNbcVnSED6ouoHi-1is9oe_Lgl-M,654
2
- fastapi_sso/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
- fastapi_sso/sso/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- fastapi_sso/sso/base.py,sha256=K2UnC8ay20lFeCn5WUx1z-DYzpqFYqDC8RZjpVdamTI,14607
5
- fastapi_sso/sso/facebook.py,sha256=F1ox1auZ5PlJW2xccAN0oF8HpvTA5tF6cdPdXar_xF0,1327
6
- fastapi_sso/sso/fitbit.py,sha256=OcCL1Go5jZ6XAarucORErX7Nuk9g9qFCQPamdcEbcFE,1258
7
- fastapi_sso/sso/generic.py,sha256=pLVK7WuxiQS8XdblkdC5h6he6BcybTdMCrXvfdJlvA8,2625
8
- fastapi_sso/sso/github.py,sha256=DM4gDK0QTqf8Iftn0La3V7g7bBZ6KuXUB2QyVUELXro,1693
9
- fastapi_sso/sso/gitlab.py,sha256=DzvbTkW0dalDDJVGbbQkr8G5Q3GuMF991-aM_L15v64,1032
10
- fastapi_sso/sso/google.py,sha256=iyVqh5YG5Xw-Ss4y8xEv1APDvubdI9Vt3ijvPMmStVk,1380
11
- fastapi_sso/sso/kakao.py,sha256=WvlPMWdYSDM_XHv8wtbvzIzm3xUzxmaEulcf-UaeQSE,864
12
- fastapi_sso/sso/line.py,sha256=s0nsC11wXvCvddwCWSiv108cvCFUPHkF2FEpdOAVMj4,1190
13
- fastapi_sso/sso/linkedin.py,sha256=UEV69KL_z5L5bBnfyZsDuvIcKFBzplqjheRWioegmFs,1252
14
- fastapi_sso/sso/microsoft.py,sha256=tVxHHGlXTuFCwiLh8pcM7I9aP9ncea5U0ZPp-JEB6hU,1918
15
- fastapi_sso/sso/naver.py,sha256=mwv7ondZHzaUOXleMiBNkKEHnUywctVUjw0wo83l-II,901
16
- fastapi_sso/sso/notion.py,sha256=3OU70JpE4mle9DXyZrf4sRpwq1raWcOHjFJrRmsI-EM,1302
17
- fastapi_sso/sso/spotify.py,sha256=GhB-Fg3C4In_kzkEUCiPHi00oa1a2Z-QuhX3MCve8m4,1249
18
- fastapi_sso-0.12.2.dist-info/LICENSE.md,sha256=5NVQtYs6liDtYdWM4VObWmTTKaK0k9C9txx5pLPJSyQ,1093
19
- fastapi_sso-0.12.2.dist-info/METADATA,sha256=gQjb2LXH3Yr6pCcrCoqhMT0q6SshXdc8M11A6iQAngs,4549
20
- fastapi_sso-0.12.2.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
21
- fastapi_sso-0.12.2.dist-info/RECORD,,