fastapi-sso 0.14.2__tar.gz → 0.16.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.
- {fastapi_sso-0.14.2 → fastapi_sso-0.16.0}/PKG-INFO +65 -8
- {fastapi_sso-0.14.2 → fastapi_sso-0.16.0}/README.md +62 -7
- {fastapi_sso-0.14.2 → fastapi_sso-0.16.0}/fastapi_sso/__init__.py +22 -1
- {fastapi_sso-0.14.2 → fastapi_sso-0.16.0}/fastapi_sso/pkce.py +2 -2
- {fastapi_sso-0.14.2 → fastapi_sso-0.16.0}/fastapi_sso/sso/base.py +111 -49
- {fastapi_sso-0.14.2 → fastapi_sso-0.16.0}/fastapi_sso/sso/facebook.py +9 -9
- {fastapi_sso-0.14.2 → fastapi_sso-0.16.0}/fastapi_sso/sso/fitbit.py +6 -7
- {fastapi_sso-0.14.2 → fastapi_sso-0.16.0}/fastapi_sso/sso/generic.py +3 -4
- {fastapi_sso-0.14.2 → fastapi_sso-0.16.0}/fastapi_sso/sso/github.py +7 -6
- {fastapi_sso-0.14.2 → fastapi_sso-0.16.0}/fastapi_sso/sso/gitlab.py +5 -6
- {fastapi_sso-0.14.2 → fastapi_sso-0.16.0}/fastapi_sso/sso/google.py +7 -8
- {fastapi_sso-0.14.2 → fastapi_sso-0.16.0}/fastapi_sso/sso/kakao.py +4 -4
- {fastapi_sso-0.14.2 → fastapi_sso-0.16.0}/fastapi_sso/sso/line.py +6 -7
- {fastapi_sso-0.14.2 → fastapi_sso-0.16.0}/fastapi_sso/sso/linkedin.py +5 -5
- {fastapi_sso-0.14.2 → fastapi_sso-0.16.0}/fastapi_sso/sso/microsoft.py +4 -4
- {fastapi_sso-0.14.2 → fastapi_sso-0.16.0}/fastapi_sso/sso/naver.py +5 -5
- {fastapi_sso-0.14.2 → fastapi_sso-0.16.0}/fastapi_sso/sso/notion.py +5 -5
- fastapi_sso-0.16.0/fastapi_sso/sso/seznam.py +39 -0
- {fastapi_sso-0.14.2 → fastapi_sso-0.16.0}/fastapi_sso/sso/spotify.py +8 -12
- {fastapi_sso-0.14.2 → fastapi_sso-0.16.0}/fastapi_sso/sso/twitter.py +4 -4
- {fastapi_sso-0.14.2 → fastapi_sso-0.16.0}/fastapi_sso/sso/yandex.py +3 -6
- {fastapi_sso-0.14.2 → fastapi_sso-0.16.0}/fastapi_sso/state.py +2 -2
- {fastapi_sso-0.14.2 → fastapi_sso-0.16.0}/pyproject.toml +45 -12
- {fastapi_sso-0.14.2 → fastapi_sso-0.16.0}/LICENSE.md +0 -0
- {fastapi_sso-0.14.2 → fastapi_sso-0.16.0}/fastapi_sso/py.typed +0 -0
- {fastapi_sso-0.14.2 → fastapi_sso-0.16.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.
|
|
3
|
+
Version: 0.16.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
|
|
@@ -15,10 +15,12 @@ Classifier: Programming Language :: Python :: 3.9
|
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.10
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.11
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
19
|
Requires-Dist: fastapi (>=0.80)
|
|
19
20
|
Requires-Dist: httpx (>=0.23.0)
|
|
20
21
|
Requires-Dist: oauthlib (>=3.1.0)
|
|
21
22
|
Requires-Dist: pydantic[email] (>=1.8.0)
|
|
23
|
+
Requires-Dist: typing-extensions (>=4.12.2,<5.0.0) ; python_version < "3.10"
|
|
22
24
|
Project-URL: Documentation, https://tomasvotava.github.io/fastapi-sso/
|
|
23
25
|
Project-URL: Repository, https://github.com/tomasvotava/fastapi-sso
|
|
24
26
|
Description-Content-Type: text/markdown
|
|
@@ -28,7 +30,7 @@ Description-Content-Type: text/markdown
|
|
|
28
30
|

|
|
29
31
|
[](https://codecov.io/gh/tomasvotava/fastapi-sso)
|
|
30
32
|

|
|
31
|
-

|
|
32
34
|

|
|
33
35
|

|
|
34
36
|

|
|
@@ -46,14 +48,68 @@ backend very easily.
|
|
|
46
48
|
|
|
47
49
|
**Source Code**: [https://github.com/tomasvotava/fastapi-sso](https://github.com/tomasvotava/fastapi-sso/)
|
|
48
50
|
|
|
49
|
-
##
|
|
51
|
+
## Demo site
|
|
50
52
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
An awesome demo site was created and is maintained by even awesomer
|
|
54
|
+
[Chris Karvouniaris (@chrisK824)](https://github.com/chrisK824). Chris has also posted multiple
|
|
55
|
+
Medium articles about FastAPI and FastAPI SSO.
|
|
54
56
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
Be sure to see his tutorials, follow him and show him some appreciation!
|
|
58
|
+
|
|
59
|
+
Please see his [announcement](https://github.com/tomasvotava/fastapi-sso/discussions/150) with all the links.
|
|
60
|
+
|
|
61
|
+
Quick links for the eager ones:
|
|
62
|
+
|
|
63
|
+
- [Demo site](https://fastapi-sso-example.vercel.app/)
|
|
64
|
+
- [Medium articles](https://medium.com/@christos.karvouniaris247)
|
|
65
|
+
|
|
66
|
+
## Security Notice
|
|
67
|
+
|
|
68
|
+
### Version `0.16.0` Update: Race Condition Bug Fix & Context Manager Change
|
|
69
|
+
|
|
70
|
+
A race condition bug in the login flow that could, in rare cases, allow one user
|
|
71
|
+
to assume the identity of another due to concurrent login requests was recently discovered
|
|
72
|
+
by [@parikls](https://github.com/parikls).
|
|
73
|
+
This issue was reported in [#186](https://github.com/tomasvotava/fastapi-sso/issues/186) and has been resolved
|
|
74
|
+
in version `0.16.0`.
|
|
75
|
+
|
|
76
|
+
**Details of the Fix:**
|
|
77
|
+
|
|
78
|
+
The bug was mitigated by introducing an async lock mechanism that ensures only one user can attempt the login
|
|
79
|
+
process at any given time. This prevents race conditions that could lead to unintended user identity crossover.
|
|
80
|
+
|
|
81
|
+
**Important Change:**
|
|
82
|
+
|
|
83
|
+
To fully support this fix, **users must now use the SSO instance within an `async with`
|
|
84
|
+
context manager**. This adjustment is necessary for proper handling of asynchronous operations.
|
|
85
|
+
|
|
86
|
+
The synchronous `with` context manager is now deprecated and will produce a warning.
|
|
87
|
+
It will be removed in future versions to ensure best practices for async handling.
|
|
88
|
+
|
|
89
|
+
**Impact:**
|
|
90
|
+
|
|
91
|
+
This bug could potentially affect deployments with high concurrency or scenarios where multiple users initiate
|
|
92
|
+
login requests simultaneously. To prevent potential issues and deprecation warnings, **update to
|
|
93
|
+
version `0.16.0` or later and modify your code to use the async with context**.
|
|
94
|
+
|
|
95
|
+
Code Example Update:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
# Before (deprecated)
|
|
99
|
+
with sso:
|
|
100
|
+
openid = await sso.verify_and_process(request)
|
|
101
|
+
|
|
102
|
+
# After (recommended)
|
|
103
|
+
async with sso:
|
|
104
|
+
openid = await sso.verify_and_process(request)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Thanks to both [@parikls](https://github.com/parikls) and the community for helping me identify and improve the
|
|
108
|
+
security of `fastapi-sso`. If you encounter any issues or potential vulnerabilities, please report them
|
|
109
|
+
immediately so they can be addressed.
|
|
110
|
+
|
|
111
|
+
For more details, refer to Issue [#186](https://github.com/tomasvotava/fastapi-sso/issues/186)
|
|
112
|
+
and PR [#189](https://github.com/tomasvotava/fastapi-sso/pull/189).
|
|
57
113
|
|
|
58
114
|
## Support this project
|
|
59
115
|
|
|
@@ -86,6 +142,7 @@ I tend to process Pull Requests faster when properly caffeinated 😉.
|
|
|
86
142
|
- Line (by Jimmy Yeh) - [jimmyyyeh](https://github.com/jimmyyyeh)
|
|
87
143
|
- LinkedIn (by Alessandro Pischedda) - [Cereal84](https://github.com/Cereal84)
|
|
88
144
|
- Yandex (by Akim Faskhutdinov) – [akimrx](https://github.com/akimrx)
|
|
145
|
+
- Seznam (by Tomas Koutek) - [TomasKoutek](https://github.com/TomasKoutek)
|
|
89
146
|
|
|
90
147
|
See [Contributing](#contributing) for a guide on how to contribute your own login provider.
|
|
91
148
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|

|
|
4
4
|
[](https://codecov.io/gh/tomasvotava/fastapi-sso)
|
|
5
5
|

|
|
6
|
-

|
|
7
7
|

|
|
8
8
|

|
|
9
9
|

|
|
@@ -21,14 +21,68 @@ backend very easily.
|
|
|
21
21
|
|
|
22
22
|
**Source Code**: [https://github.com/tomasvotava/fastapi-sso](https://github.com/tomasvotava/fastapi-sso/)
|
|
23
23
|
|
|
24
|
-
##
|
|
24
|
+
## Demo site
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
29
|
|
|
30
|
-
|
|
31
|
-
|
|
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
|
+
|
|
39
|
+
## Security Notice
|
|
40
|
+
|
|
41
|
+
### Version `0.16.0` Update: Race Condition Bug Fix & Context Manager Change
|
|
42
|
+
|
|
43
|
+
A race condition bug in the login flow that could, in rare cases, allow one user
|
|
44
|
+
to assume the identity of another due to concurrent login requests was recently discovered
|
|
45
|
+
by [@parikls](https://github.com/parikls).
|
|
46
|
+
This issue was reported in [#186](https://github.com/tomasvotava/fastapi-sso/issues/186) and has been resolved
|
|
47
|
+
in version `0.16.0`.
|
|
48
|
+
|
|
49
|
+
**Details of the Fix:**
|
|
50
|
+
|
|
51
|
+
The bug was mitigated by introducing an async lock mechanism that ensures only one user can attempt the login
|
|
52
|
+
process at any given time. This prevents race conditions that could lead to unintended user identity crossover.
|
|
53
|
+
|
|
54
|
+
**Important Change:**
|
|
55
|
+
|
|
56
|
+
To fully support this fix, **users must now use the SSO instance within an `async with`
|
|
57
|
+
context manager**. This adjustment is necessary for proper handling of asynchronous operations.
|
|
58
|
+
|
|
59
|
+
The synchronous `with` context manager is now deprecated and will produce a warning.
|
|
60
|
+
It will be removed in future versions to ensure best practices for async handling.
|
|
61
|
+
|
|
62
|
+
**Impact:**
|
|
63
|
+
|
|
64
|
+
This bug could potentially affect deployments with high concurrency or scenarios where multiple users initiate
|
|
65
|
+
login requests simultaneously. To prevent potential issues and deprecation warnings, **update to
|
|
66
|
+
version `0.16.0` or later and modify your code to use the async with context**.
|
|
67
|
+
|
|
68
|
+
Code Example Update:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
# Before (deprecated)
|
|
72
|
+
with sso:
|
|
73
|
+
openid = await sso.verify_and_process(request)
|
|
74
|
+
|
|
75
|
+
# After (recommended)
|
|
76
|
+
async with sso:
|
|
77
|
+
openid = await sso.verify_and_process(request)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Thanks to both [@parikls](https://github.com/parikls) and the community for helping me identify and improve the
|
|
81
|
+
security of `fastapi-sso`. If you encounter any issues or potential vulnerabilities, please report them
|
|
82
|
+
immediately so they can be addressed.
|
|
83
|
+
|
|
84
|
+
For more details, refer to Issue [#186](https://github.com/tomasvotava/fastapi-sso/issues/186)
|
|
85
|
+
and PR [#189](https://github.com/tomasvotava/fastapi-sso/pull/189).
|
|
32
86
|
|
|
33
87
|
## Support this project
|
|
34
88
|
|
|
@@ -61,6 +115,7 @@ I tend to process Pull Requests faster when properly caffeinated 😉.
|
|
|
61
115
|
- Line (by Jimmy Yeh) - [jimmyyyeh](https://github.com/jimmyyyeh)
|
|
62
116
|
- LinkedIn (by Alessandro Pischedda) - [Cereal84](https://github.com/Cereal84)
|
|
63
117
|
- Yandex (by Akim Faskhutdinov) – [akimrx](https://github.com/akimrx)
|
|
118
|
+
- Seznam (by Tomas Koutek) - [TomasKoutek](https://github.com/TomasKoutek)
|
|
64
119
|
|
|
65
120
|
See [Contributing](#contributing) for a guide on how to contribute your own login provider.
|
|
66
121
|
|
|
@@ -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,13 @@
|
|
|
1
|
-
"""SSO login base dependency
|
|
2
|
-
"""
|
|
3
|
-
|
|
4
|
-
# pylint: disable=too-few-public-methods
|
|
1
|
+
"""SSO login base dependency."""
|
|
5
2
|
|
|
3
|
+
import asyncio
|
|
6
4
|
import json
|
|
5
|
+
import logging
|
|
7
6
|
import os
|
|
8
7
|
import sys
|
|
9
8
|
import warnings
|
|
10
9
|
from types import TracebackType
|
|
11
|
-
from typing import Any, Dict, List, Literal, Optional, Type, Union, overload
|
|
10
|
+
from typing import Any, ClassVar, Dict, List, Literal, Optional, Type, TypedDict, TypeVar, Union, overload
|
|
12
11
|
|
|
13
12
|
import httpx
|
|
14
13
|
import pydantic
|
|
@@ -20,31 +19,44 @@ from starlette.responses import RedirectResponse
|
|
|
20
19
|
from fastapi_sso.pkce import get_pkce_challenge_pair
|
|
21
20
|
from fastapi_sso.state import generate_random_state
|
|
22
21
|
|
|
23
|
-
if sys.version_info
|
|
24
|
-
from typing import
|
|
22
|
+
if sys.version_info < (3, 10):
|
|
23
|
+
from typing import Callable # pragma: no cover
|
|
24
|
+
|
|
25
|
+
from typing_extensions import ParamSpec # pragma: no cover
|
|
25
26
|
else:
|
|
26
|
-
from
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
from typing import ParamSpec
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
T = TypeVar("T")
|
|
33
|
+
P = ParamSpec("P")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DiscoveryDocument(TypedDict):
|
|
37
|
+
"""Discovery document."""
|
|
27
38
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
39
|
+
authorization_endpoint: str
|
|
40
|
+
token_endpoint: str
|
|
41
|
+
userinfo_endpoint: str
|
|
31
42
|
|
|
32
43
|
|
|
33
44
|
class UnsetStateWarning(UserWarning):
|
|
34
|
-
"""Warning about unset state parameter"""
|
|
45
|
+
"""Warning about unset state parameter."""
|
|
35
46
|
|
|
36
47
|
|
|
37
48
|
class ReusedOauthClientWarning(UserWarning):
|
|
38
|
-
"""Warning about reused oauth client instance"""
|
|
49
|
+
"""Warning about reused oauth client instance."""
|
|
39
50
|
|
|
40
51
|
|
|
41
52
|
class SSOLoginError(HTTPException):
|
|
42
|
-
"""Raised when any login-related error ocurrs
|
|
43
|
-
|
|
53
|
+
"""Raised when any login-related error ocurrs.
|
|
54
|
+
|
|
55
|
+
Such as when user is not verified or if there was an attempt for fake login.
|
|
44
56
|
"""
|
|
45
57
|
|
|
46
58
|
|
|
47
|
-
class OpenID(pydantic.BaseModel):
|
|
59
|
+
class OpenID(pydantic.BaseModel):
|
|
48
60
|
"""Class (schema) to represent information got from sso provider in a common form."""
|
|
49
61
|
|
|
50
62
|
id: Optional[str] = None
|
|
@@ -56,16 +68,35 @@ class OpenID(pydantic.BaseModel): # pylint: disable=no-member
|
|
|
56
68
|
provider: Optional[str] = None
|
|
57
69
|
|
|
58
70
|
|
|
59
|
-
|
|
71
|
+
class SecurityWarning(UserWarning):
|
|
72
|
+
"""Raised when insecure usage is detected"""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def requires_async_context(func: Callable[P, T]) -> Callable[P, T]:
|
|
76
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
77
|
+
if not args or not isinstance(args[0], SSOBase):
|
|
78
|
+
return func(*args, **kwargs)
|
|
79
|
+
if not args[0]._in_stack:
|
|
80
|
+
warnings.warn(
|
|
81
|
+
"Please make sure you are using SSO provider in an async context (using 'async with provider:'). "
|
|
82
|
+
"See https://github.com/tomasvotava/fastapi-sso/issues/186 for more information.",
|
|
83
|
+
category=SecurityWarning,
|
|
84
|
+
stacklevel=1,
|
|
85
|
+
)
|
|
86
|
+
return func(*args, **kwargs)
|
|
87
|
+
|
|
88
|
+
return wrapper
|
|
89
|
+
|
|
90
|
+
|
|
60
91
|
class SSOBase:
|
|
61
|
-
"""Base class
|
|
92
|
+
"""Base class for all SSO providers."""
|
|
62
93
|
|
|
63
94
|
provider: str = NotImplemented
|
|
64
95
|
client_id: str = NotImplemented
|
|
65
96
|
client_secret: str = NotImplemented
|
|
66
97
|
redirect_uri: Optional[Union[pydantic.AnyHttpUrl, str]] = NotImplemented
|
|
67
|
-
scope: List[str] =
|
|
68
|
-
additional_headers: Optional[Dict[str, Any]] = None
|
|
98
|
+
scope: ClassVar[List[str]] = []
|
|
99
|
+
additional_headers: ClassVar[Optional[Dict[str, Any]]] = None
|
|
69
100
|
uses_pkce: bool = False
|
|
70
101
|
requires_state: bool = False
|
|
71
102
|
|
|
@@ -80,15 +111,18 @@ class SSOBase:
|
|
|
80
111
|
use_state: bool = False,
|
|
81
112
|
scope: Optional[List[str]] = None,
|
|
82
113
|
):
|
|
83
|
-
|
|
114
|
+
"""Base class (mixin) for all SSO providers."""
|
|
84
115
|
self.client_id: str = client_id
|
|
85
116
|
self.client_secret: str = client_secret
|
|
86
117
|
self.redirect_uri: Optional[Union[pydantic.AnyHttpUrl, str]] = redirect_uri
|
|
87
118
|
self.allow_insecure_http: bool = allow_insecure_http
|
|
119
|
+
self._login_lock = asyncio.Lock()
|
|
120
|
+
self._in_stack = False
|
|
88
121
|
self._oauth_client: Optional[WebApplicationClient] = None
|
|
89
122
|
self._generated_state: Optional[str] = None
|
|
90
123
|
|
|
91
124
|
if self.allow_insecure_http:
|
|
125
|
+
logger.debug("Initializing %s with allow_insecure_http=True", self.__class__.__name__)
|
|
92
126
|
os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"
|
|
93
127
|
|
|
94
128
|
# TODO: Remove use_state argument and attribute
|
|
@@ -100,7 +134,7 @@ class SSOBase:
|
|
|
100
134
|
),
|
|
101
135
|
DeprecationWarning,
|
|
102
136
|
)
|
|
103
|
-
self.
|
|
137
|
+
self._scope = scope or self.scope
|
|
104
138
|
self._refresh_token: Optional[str] = None
|
|
105
139
|
self._id_token: Optional[str] = None
|
|
106
140
|
self._state: Optional[str] = None
|
|
@@ -110,8 +144,7 @@ class SSOBase:
|
|
|
110
144
|
|
|
111
145
|
@property
|
|
112
146
|
def state(self) -> Optional[str]:
|
|
113
|
-
"""
|
|
114
|
-
Retrieves the state as it was returned from the server.
|
|
147
|
+
"""Retrieves the state as it was returned from the server.
|
|
115
148
|
|
|
116
149
|
Warning:
|
|
117
150
|
This will emit a warning if the state is unset, implying either that
|
|
@@ -130,9 +163,9 @@ class SSOBase:
|
|
|
130
163
|
return self._state
|
|
131
164
|
|
|
132
165
|
@property
|
|
166
|
+
@requires_async_context
|
|
133
167
|
def oauth_client(self) -> WebApplicationClient:
|
|
134
|
-
"""
|
|
135
|
-
Retrieves the OAuth Client to aid in generating requests and parsing responses.
|
|
168
|
+
"""Retrieves the OAuth Client to aid in generating requests and parsing responses.
|
|
136
169
|
|
|
137
170
|
Raises:
|
|
138
171
|
NotImplementedError: If the provider is not supported or `client_id` is not set.
|
|
@@ -147,9 +180,9 @@ class SSOBase:
|
|
|
147
180
|
return self._oauth_client
|
|
148
181
|
|
|
149
182
|
@property
|
|
183
|
+
@requires_async_context
|
|
150
184
|
def access_token(self) -> Optional[str]:
|
|
151
|
-
"""
|
|
152
|
-
Retrieves the access token from token endpoint.
|
|
185
|
+
"""Retrieves the access token from token endpoint.
|
|
153
186
|
|
|
154
187
|
Returns:
|
|
155
188
|
Optional[str]: The access token if available.
|
|
@@ -157,9 +190,9 @@ class SSOBase:
|
|
|
157
190
|
return self.oauth_client.access_token
|
|
158
191
|
|
|
159
192
|
@property
|
|
193
|
+
@requires_async_context
|
|
160
194
|
def refresh_token(self) -> Optional[str]:
|
|
161
|
-
"""
|
|
162
|
-
Retrieves the refresh token if returned from provider.
|
|
195
|
+
"""Retrieves the refresh token if returned from provider.
|
|
163
196
|
|
|
164
197
|
Returns:
|
|
165
198
|
Optional[str]: The refresh token if available.
|
|
@@ -167,9 +200,9 @@ class SSOBase:
|
|
|
167
200
|
return self._refresh_token or self.oauth_client.refresh_token
|
|
168
201
|
|
|
169
202
|
@property
|
|
203
|
+
@requires_async_context
|
|
170
204
|
def id_token(self) -> Optional[str]:
|
|
171
|
-
"""
|
|
172
|
-
Retrieves the id token if returned from provider.
|
|
205
|
+
"""Retrieves the id token if returned from provider.
|
|
173
206
|
|
|
174
207
|
Returns:
|
|
175
208
|
Optional[str]: The id token if available.
|
|
@@ -177,8 +210,7 @@ class SSOBase:
|
|
|
177
210
|
return self._id_token
|
|
178
211
|
|
|
179
212
|
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.
|
|
213
|
+
"""Converts a response from the provider's user info endpoint to an OpenID object.
|
|
182
214
|
|
|
183
215
|
Args:
|
|
184
216
|
response (dict): The response from the user info endpoint.
|
|
@@ -193,8 +225,7 @@ class SSOBase:
|
|
|
193
225
|
raise NotImplementedError(f"Provider {self.provider} not supported")
|
|
194
226
|
|
|
195
227
|
async def get_discovery_document(self) -> DiscoveryDocument:
|
|
196
|
-
"""
|
|
197
|
-
Retrieves the discovery document containing useful URLs.
|
|
228
|
+
"""Retrieves the discovery document containing useful URLs.
|
|
198
229
|
|
|
199
230
|
Raises:
|
|
200
231
|
NotImplementedError: If the provider is not supported.
|
|
@@ -206,19 +237,19 @@ class SSOBase:
|
|
|
206
237
|
|
|
207
238
|
@property
|
|
208
239
|
async def authorization_endpoint(self) -> Optional[str]:
|
|
209
|
-
"""Return `authorization_endpoint` from discovery document"""
|
|
240
|
+
"""Return `authorization_endpoint` from discovery document."""
|
|
210
241
|
discovery = await self.get_discovery_document()
|
|
211
242
|
return discovery.get("authorization_endpoint")
|
|
212
243
|
|
|
213
244
|
@property
|
|
214
245
|
async def token_endpoint(self) -> Optional[str]:
|
|
215
|
-
"""Return `token_endpoint` from discovery document"""
|
|
246
|
+
"""Return `token_endpoint` from discovery document."""
|
|
216
247
|
discovery = await self.get_discovery_document()
|
|
217
248
|
return discovery.get("token_endpoint")
|
|
218
249
|
|
|
219
250
|
@property
|
|
220
251
|
async def userinfo_endpoint(self) -> Optional[str]:
|
|
221
|
-
"""Return `userinfo_endpoint` from discovery document"""
|
|
252
|
+
"""Return `userinfo_endpoint` from discovery document."""
|
|
222
253
|
discovery = await self.get_discovery_document()
|
|
223
254
|
return discovery.get("userinfo_endpoint")
|
|
224
255
|
|
|
@@ -229,8 +260,7 @@ class SSOBase:
|
|
|
229
260
|
params: Optional[Dict[str, Any]] = None,
|
|
230
261
|
state: Optional[str] = None,
|
|
231
262
|
) -> str:
|
|
232
|
-
"""
|
|
233
|
-
Generates and returns the prepared login URL.
|
|
263
|
+
"""Generates and returns the prepared login URL.
|
|
234
264
|
|
|
235
265
|
Args:
|
|
236
266
|
redirect_uri (Optional[str]): Overrides the `redirect_uri` specified on this instance.
|
|
@@ -263,7 +293,7 @@ class SSOBase:
|
|
|
263
293
|
await self.authorization_endpoint,
|
|
264
294
|
redirect_uri=redirect_uri,
|
|
265
295
|
state=state,
|
|
266
|
-
scope=self.
|
|
296
|
+
scope=self._scope,
|
|
267
297
|
code_challenge=self._pkce_code_challenge,
|
|
268
298
|
code_challenge_method=self._pkce_challenge_method,
|
|
269
299
|
**params,
|
|
@@ -277,8 +307,7 @@ class SSOBase:
|
|
|
277
307
|
params: Optional[Dict[str, Any]] = None,
|
|
278
308
|
state: Optional[str] = None,
|
|
279
309
|
) -> RedirectResponse:
|
|
280
|
-
"""
|
|
281
|
-
Constructs and returns a redirect response to the login page of OAuth SSO provider.
|
|
310
|
+
"""Constructs and returns a redirect response to the login page of OAuth SSO provider.
|
|
282
311
|
|
|
283
312
|
Args:
|
|
284
313
|
redirect_uri (Optional[str]): Overrides the `redirect_uri` specified on this instance.
|
|
@@ -318,6 +347,7 @@ class SSOBase:
|
|
|
318
347
|
convert_response: Literal[False],
|
|
319
348
|
) -> Optional[Dict[str, Any]]: ...
|
|
320
349
|
|
|
350
|
+
@requires_async_context
|
|
321
351
|
async def verify_and_process(
|
|
322
352
|
self,
|
|
323
353
|
request: Request,
|
|
@@ -327,8 +357,7 @@ class SSOBase:
|
|
|
327
357
|
redirect_uri: Optional[str] = None,
|
|
328
358
|
convert_response: Union[Literal[True], Literal[False]] = True,
|
|
329
359
|
) -> 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.
|
|
360
|
+
"""Processes the login given a FastAPI (Starlette) Request object. This should be used for the /callback path.
|
|
332
361
|
|
|
333
362
|
Args:
|
|
334
363
|
request (Request): FastAPI or Starlette request object.
|
|
@@ -347,6 +376,12 @@ class SSOBase:
|
|
|
347
376
|
headers = headers or {}
|
|
348
377
|
code = request.query_params.get("code")
|
|
349
378
|
if code is None:
|
|
379
|
+
logger.debug(
|
|
380
|
+
"Callback request:\n\tURI: %s\n\tHeaders: %s\n\tQuery params: %s",
|
|
381
|
+
request.url,
|
|
382
|
+
request.headers,
|
|
383
|
+
request.query_params,
|
|
384
|
+
)
|
|
350
385
|
raise SSOLoginError(400, "'code' parameter was not found in callback request")
|
|
351
386
|
self._state = request.query_params.get("state")
|
|
352
387
|
pkce_code_verifier: Optional[str] = None
|
|
@@ -367,6 +402,12 @@ class SSOBase:
|
|
|
367
402
|
)
|
|
368
403
|
|
|
369
404
|
def __enter__(self) -> "SSOBase":
|
|
405
|
+
warnings.warn(
|
|
406
|
+
"SSO Providers are supposed to be used in async context, please change 'with provider' to "
|
|
407
|
+
"'async with provider'. See https://github.com/tomasvotava/fastapi-sso/issues/186 for more information.",
|
|
408
|
+
DeprecationWarning,
|
|
409
|
+
stacklevel=1,
|
|
410
|
+
)
|
|
370
411
|
self._oauth_client = None
|
|
371
412
|
self._refresh_token = None
|
|
372
413
|
self._id_token = None
|
|
@@ -377,6 +418,28 @@ class SSOBase:
|
|
|
377
418
|
self._pkce_code_verifier, self._pkce_code_challenge = get_pkce_challenge_pair(self._pkce_challenge_length)
|
|
378
419
|
return self
|
|
379
420
|
|
|
421
|
+
async def __aenter__(self) -> "SSOBase":
|
|
422
|
+
await self._login_lock.acquire()
|
|
423
|
+
self._in_stack = True
|
|
424
|
+
self._oauth_client = None
|
|
425
|
+
self._refresh_token = None
|
|
426
|
+
self._id_token = None
|
|
427
|
+
self._state = None
|
|
428
|
+
if self.requires_state:
|
|
429
|
+
self._generated_state = generate_random_state()
|
|
430
|
+
if self.uses_pkce:
|
|
431
|
+
self._pkce_code_verifier, self._pkce_code_challenge = get_pkce_challenge_pair(self._pkce_challenge_length)
|
|
432
|
+
return self
|
|
433
|
+
|
|
434
|
+
async def __aexit__(
|
|
435
|
+
self,
|
|
436
|
+
_exc_type: Optional[Type[BaseException]],
|
|
437
|
+
_exc_val: Optional[BaseException],
|
|
438
|
+
_exc_tb: Optional[TracebackType],
|
|
439
|
+
) -> None:
|
|
440
|
+
self._in_stack = False
|
|
441
|
+
self._login_lock.release()
|
|
442
|
+
|
|
380
443
|
def __exit__(
|
|
381
444
|
self,
|
|
382
445
|
_exc_type: Optional[Type[BaseException]],
|
|
@@ -415,6 +478,7 @@ class SSOBase:
|
|
|
415
478
|
convert_response: Literal[False],
|
|
416
479
|
) -> Optional[Dict[str, Any]]: ...
|
|
417
480
|
|
|
481
|
+
@requires_async_context
|
|
418
482
|
async def process_login(
|
|
419
483
|
self,
|
|
420
484
|
code: str,
|
|
@@ -426,8 +490,7 @@ class SSOBase:
|
|
|
426
490
|
pkce_code_verifier: Optional[str] = None,
|
|
427
491
|
convert_response: Union[Literal[True], Literal[False]] = True,
|
|
428
492
|
) -> Union[Optional[OpenID], Optional[Dict[str, Any]]]:
|
|
429
|
-
"""
|
|
430
|
-
Processes login from the callback endpoint to verify the user and request user info endpoint.
|
|
493
|
+
"""Processes login from the callback endpoint to verify the user and request user info endpoint.
|
|
431
494
|
It's a lower-level method, typically, you should use `verify_and_process` instead.
|
|
432
495
|
|
|
433
496
|
Args:
|
|
@@ -446,7 +509,6 @@ class SSOBase:
|
|
|
446
509
|
Optional[OpenID]: User information in OpenID format if the login was successful (convert_response == True).
|
|
447
510
|
Optional[Dict[str, Any]]: Original userinfo API endpoint response.
|
|
448
511
|
"""
|
|
449
|
-
# pylint: disable=too-many-locals
|
|
450
512
|
if self._oauth_client is not None: # pragma: no cover
|
|
451
513
|
self._oauth_client = None
|
|
452
514
|
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/
|
|
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
|
-
|
|
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 {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Seznam SSO Login Helper."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, ClassVar, 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
|
+
# https://vyvojari.seznam.cz/oauth/doc
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SeznamSSO(SSOBase):
|
|
15
|
+
"""Class providing login via Seznam OAuth."""
|
|
16
|
+
|
|
17
|
+
provider = "seznam"
|
|
18
|
+
base_url = "https://login.szn.cz/api/v1"
|
|
19
|
+
scope: ClassVar = ["identity", "avatar"] # + ["contact-phone", "adulthood", "birthday", "gender"]
|
|
20
|
+
|
|
21
|
+
async def get_discovery_document(self) -> DiscoveryDocument:
|
|
22
|
+
"""Get document containing handy urls."""
|
|
23
|
+
return {
|
|
24
|
+
"authorization_endpoint": f"{self.base_url}/oauth/auth",
|
|
25
|
+
"token_endpoint": f"{self.base_url}/oauth/token",
|
|
26
|
+
"userinfo_endpoint": f"{self.base_url}/user",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async def openid_from_response(self, response: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID:
|
|
30
|
+
"""Return OpenID from user information provided by Seznam."""
|
|
31
|
+
return OpenID(
|
|
32
|
+
email=response.get("email"),
|
|
33
|
+
first_name=response.get("firstname"),
|
|
34
|
+
last_name=response.get("lastname"),
|
|
35
|
+
display_name=response.get("accountDisplayName"),
|
|
36
|
+
provider=self.provider,
|
|
37
|
+
id=response.get("oauth_user_id"),
|
|
38
|
+
picture=response.get("avatar_url"),
|
|
39
|
+
)
|
|
@@ -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.
|
|
3
|
+
version = "0.16.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"
|
|
@@ -29,18 +29,52 @@ addopts = [
|
|
|
29
29
|
"--cov-report=xml:coverage.xml",
|
|
30
30
|
"--cov-report=json:coverage.json",
|
|
31
31
|
"--cov-report=term-missing",
|
|
32
|
-
"-n",
|
|
33
|
-
"auto",
|
|
34
32
|
]
|
|
35
33
|
|
|
36
34
|
|
|
37
35
|
[tool.black]
|
|
38
36
|
line-length = 120
|
|
39
37
|
|
|
38
|
+
[tool.ruff]
|
|
39
|
+
target-version = "py38"
|
|
40
|
+
line-length = 120
|
|
41
|
+
|
|
42
|
+
[tool.ruff.lint]
|
|
43
|
+
select = [
|
|
44
|
+
#"D",
|
|
45
|
+
"E",
|
|
46
|
+
"F",
|
|
47
|
+
"B",
|
|
48
|
+
"I",
|
|
49
|
+
"N",
|
|
50
|
+
"UP",
|
|
51
|
+
"S",
|
|
52
|
+
"A",
|
|
53
|
+
"DTZ",
|
|
54
|
+
"PT",
|
|
55
|
+
"SIM",
|
|
56
|
+
"PTH",
|
|
57
|
+
"PD",
|
|
58
|
+
"RUF",
|
|
59
|
+
"T20",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
ignore = [
|
|
63
|
+
"B028", # allow warning without specifying `stacklevel`
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
[tool.ruff.lint.pydocstyle]
|
|
67
|
+
convention = "google"
|
|
68
|
+
|
|
69
|
+
[tool.ruff.lint.isort]
|
|
70
|
+
known-first-party = ["fastapi_sso"]
|
|
71
|
+
|
|
72
|
+
[tool.ruff.lint.per-file-ignores]
|
|
73
|
+
"tests*/**/*.py" = ["S101"] # Allow asserts in tests
|
|
74
|
+
"**/__init__.py" = ["D104"] # Allow missing docstrings in __init__ files
|
|
40
75
|
|
|
41
76
|
[tool.poe.tasks]
|
|
42
|
-
|
|
43
|
-
todos = "pylint --disable all --enable fixme --rcfile .pylintrc fastapi_sso"
|
|
77
|
+
ruff = "ruff check fastapi_sso"
|
|
44
78
|
black = "black fastapi_sso"
|
|
45
79
|
isort = "isort --settings-path .isort.cfg fastapi_sso"
|
|
46
80
|
mypy = "mypy --config-file mypy.ini fastapi_sso"
|
|
@@ -48,7 +82,7 @@ black-check = "black --check fastapi_sso"
|
|
|
48
82
|
isort-check = "isort --settings-path .isort.cfg --check-only fastapi_sso"
|
|
49
83
|
|
|
50
84
|
format = ["black", "isort"]
|
|
51
|
-
lint = ["
|
|
85
|
+
lint = ["ruff", "mypy", "black-check", "isort-check"]
|
|
52
86
|
pre-commit = "pre-commit"
|
|
53
87
|
|
|
54
88
|
test = "pytest"
|
|
@@ -61,17 +95,15 @@ black = ">=23.7.0"
|
|
|
61
95
|
isort = "^5"
|
|
62
96
|
markdown-include = "^0.8.1"
|
|
63
97
|
mkdocs-material = { extras = ["imaging"], version = "^9.3.2" }
|
|
64
|
-
mkdocstrings = { extras = ["python"], version = ">=0.23,<0.
|
|
98
|
+
mkdocstrings = { extras = ["python"], version = ">=0.23,<0.27" }
|
|
65
99
|
mypy = "^1"
|
|
66
|
-
poethepoet = ">=0.21.1,<0.
|
|
100
|
+
poethepoet = ">=0.21.1,<0.30.0"
|
|
67
101
|
pre-commit = "^3"
|
|
68
|
-
pylint = ">=2,<4"
|
|
69
102
|
pytest = ">=7,<9"
|
|
70
|
-
pytest-asyncio = "
|
|
103
|
+
pytest-asyncio = "^0.24"
|
|
71
104
|
pytest-cov = ">=4,<6"
|
|
72
|
-
pytest-xdist = "^3"
|
|
73
|
-
tox = "^4"
|
|
74
105
|
uvicorn = ">=0.23.1"
|
|
106
|
+
ruff = ">=0.4.2,<0.8.0"
|
|
75
107
|
|
|
76
108
|
[tool.poetry.dependencies]
|
|
77
109
|
fastapi = ">=0.80"
|
|
@@ -79,6 +111,7 @@ httpx = ">=0.23.0"
|
|
|
79
111
|
oauthlib = ">=3.1.0"
|
|
80
112
|
pydantic = { extras = ["email"], version = ">=1.8.0" }
|
|
81
113
|
python = ">=3.8,<4.0"
|
|
114
|
+
typing-extensions = { version = "^4.12.2", python = "<3.10" }
|
|
82
115
|
|
|
83
116
|
[build-system]
|
|
84
117
|
requires = ["poetry-core>=1.0.0"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|