usso 0.28.26__tar.gz → 0.28.28__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.
- {usso-0.28.26/src/usso.egg-info → usso-0.28.28}/PKG-INFO +2 -2
- {usso-0.28.26 → usso-0.28.28}/pyproject.toml +10 -4
- {usso-0.28.26 → usso-0.28.28}/src/usso/api_key.py +2 -2
- {usso-0.28.26 → usso-0.28.28}/src/usso/authorization.py +32 -62
- {usso-0.28.26 → usso-0.28.28}/src/usso/client.py +25 -2
- {usso-0.28.26 → usso-0.28.28}/src/usso/config.py +10 -7
- {usso-0.28.26 → usso-0.28.28}/src/usso/exceptions.py +24 -10
- {usso-0.28.26 → usso-0.28.28}/src/usso/integrations/django/middleware.py +1 -1
- {usso-0.28.26 → usso-0.28.28}/src/usso/integrations/fastapi/dependency.py +7 -4
- {usso-0.28.26 → usso-0.28.28}/src/usso/integrations/fastapi/handler.py +9 -2
- {usso-0.28.26 → usso-0.28.28}/src/usso/session/async_session.py +7 -4
- {usso-0.28.26 → usso-0.28.28}/src/usso/session/base_session.py +3 -3
- {usso-0.28.26 → usso-0.28.28}/src/usso/session/session.py +7 -5
- {usso-0.28.26 → usso-0.28.28}/src/usso/user.py +6 -6
- {usso-0.28.26 → usso-0.28.28/src/usso.egg-info}/PKG-INFO +2 -2
- {usso-0.28.26 → usso-0.28.28}/src/usso.egg-info/requires.txt +1 -1
- {usso-0.28.26 → usso-0.28.28}/tests/test_authorization.py +32 -34
- {usso-0.28.26 → usso-0.28.28}/tests/test_fastapi.py +13 -10
- {usso-0.28.26 → usso-0.28.28}/LICENSE.txt +0 -0
- {usso-0.28.26 → usso-0.28.28}/MANIFEST.in +0 -0
- {usso-0.28.26 → usso-0.28.28}/README.md +0 -0
- {usso-0.28.26 → usso-0.28.28}/pytest.ini +0 -0
- {usso-0.28.26 → usso-0.28.28}/setup.cfg +0 -0
- {usso-0.28.26 → usso-0.28.28}/src/usso/__init__.py +0 -0
- {usso-0.28.26 → usso-0.28.28}/src/usso/integrations/django/__init__.py +0 -0
- {usso-0.28.26 → usso-0.28.28}/src/usso/integrations/fastapi/__init__.py +0 -0
- {usso-0.28.26 → usso-0.28.28}/src/usso/session/__init__.py +0 -0
- {usso-0.28.26 → usso-0.28.28}/src/usso/utils/__init__.py +0 -0
- {usso-0.28.26 → usso-0.28.28}/src/usso/utils/string_utils.py +0 -0
- {usso-0.28.26 → usso-0.28.28}/src/usso.egg-info/SOURCES.txt +0 -0
- {usso-0.28.26 → usso-0.28.28}/src/usso.egg-info/dependency_links.txt +0 -0
- {usso-0.28.26 → usso-0.28.28}/src/usso.egg-info/entry_points.txt +0 -0
- {usso-0.28.26 → usso-0.28.28}/src/usso.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: usso
|
3
|
-
Version: 0.28.
|
3
|
+
Version: 0.28.28
|
4
4
|
Summary: A plug-and-play client for integrating universal single sign-on (SSO) with Python frameworks, enabling secure and seamless authentication across microservices.
|
5
5
|
Author-email: Mahdi Kiani <mahdikiany@gmail.com>
|
6
6
|
Maintainer-email: Mahdi Kiani <mahdikiany@gmail.com>
|
@@ -28,7 +28,7 @@ Requires-Dist: cachetools
|
|
28
28
|
Requires-Dist: singleton_package
|
29
29
|
Requires-Dist: json-advanced
|
30
30
|
Requires-Dist: httpx
|
31
|
-
Requires-Dist: usso-jwt>=0.2.
|
31
|
+
Requires-Dist: usso-jwt>=0.2.6
|
32
32
|
Provides-Extra: fastapi
|
33
33
|
Requires-Dist: fastapi>=0.65.0; extra == "fastapi"
|
34
34
|
Requires-Dist: uvicorn[standard]>=0.13.0; extra == "fastapi"
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
4
4
|
|
5
5
|
[project]
|
6
6
|
name = "usso"
|
7
|
-
version = "0.28.
|
7
|
+
version = "0.28.28"
|
8
8
|
description = "A plug-and-play client for integrating universal single sign-on (SSO) with Python frameworks, enabling secure and seamless authentication across microservices."
|
9
9
|
readme = "README.md"
|
10
10
|
requires-python = ">=3.9"
|
@@ -30,7 +30,7 @@ dependencies = [
|
|
30
30
|
"singleton_package",
|
31
31
|
"json-advanced",
|
32
32
|
"httpx",
|
33
|
-
"usso-jwt>=0.2.
|
33
|
+
"usso-jwt>=0.2.6",
|
34
34
|
]
|
35
35
|
|
36
36
|
[project.optional-dependencies]
|
@@ -45,7 +45,7 @@ all = [
|
|
45
45
|
"check-manifest",
|
46
46
|
"pytest",
|
47
47
|
"pytest_asyncio",
|
48
|
-
"coverage"
|
48
|
+
"coverage",
|
49
49
|
]
|
50
50
|
|
51
51
|
[project.urls]
|
@@ -69,7 +69,13 @@ unsafe-fixes = true
|
|
69
69
|
preview = true
|
70
70
|
|
71
71
|
[tool.ruff.lint]
|
72
|
-
select = ["E", "F", "W", "I", "UP", "B"]
|
72
|
+
select = ["E", "F", "W", "I", "UP", "B", "T", "C", "A", "ANN"]
|
73
73
|
|
74
74
|
[tool.ruff.format]
|
75
75
|
quote-style = "double"
|
76
|
+
|
77
|
+
[tool.mypy]
|
78
|
+
python_version = "3.13"
|
79
|
+
ignore_missing_imports = true
|
80
|
+
check_untyped_defs = false
|
81
|
+
disallow_untyped_defs = false
|
@@ -10,7 +10,7 @@ from .user import UserData
|
|
10
10
|
logger = logging.getLogger("usso")
|
11
11
|
|
12
12
|
|
13
|
-
def _handle_exception(error_type: str, **kwargs):
|
13
|
+
def _handle_exception(error_type: str, **kwargs: dict) -> None:
|
14
14
|
"""Handle API key related exceptions."""
|
15
15
|
if kwargs.get("raise_exception", True):
|
16
16
|
raise USSOException(
|
@@ -20,7 +20,7 @@ def _handle_exception(error_type: str, **kwargs):
|
|
20
20
|
|
21
21
|
|
22
22
|
@cachetools.func.ttl_cache(maxsize=128, ttl=60)
|
23
|
-
def fetch_api_key_data(api_key_verify_url: str, api_key: str):
|
23
|
+
def fetch_api_key_data(api_key_verify_url: str, api_key: str) -> UserData:
|
24
24
|
"""Fetch user data using an API key.
|
25
25
|
|
26
26
|
Args:
|
@@ -26,6 +26,9 @@ def parse_scope(scope: str) -> tuple[str, list[str], dict[str, str]]:
|
|
26
26
|
"*:*" ->
|
27
27
|
("*", ["*"], {})
|
28
28
|
|
29
|
+
"media//files" ->
|
30
|
+
("", ["media", "*", "files"], {})
|
31
|
+
|
29
32
|
Returns:
|
30
33
|
- action: str (could be empty string if no scheme present)
|
31
34
|
- path_parts: list[str]
|
@@ -47,91 +50,58 @@ def parse_scope(scope: str) -> tuple[str, list[str], dict[str, str]]:
|
|
47
50
|
query = scope[question_idx + 1 :]
|
48
51
|
filters = {k: v[0] for k, v in parse_qs(query).items()}
|
49
52
|
resource_path_parts = resource_path.split("/") if resource_path else ["*"]
|
50
|
-
return action, resource_path_parts, filters
|
51
|
-
|
53
|
+
return action, [rp or "*" for rp in resource_path_parts], filters
|
52
54
|
|
53
|
-
def is_path_match(
|
54
|
-
user_path: list[str] | str,
|
55
|
-
requested_path: list[str] | str,
|
56
|
-
strict: bool = False,
|
57
|
-
) -> bool:
|
58
|
-
"""
|
59
|
-
Match resource paths from right to left, supporting wildcards (*).
|
60
55
|
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
Examples = [
|
67
|
-
("files", "files", True),
|
68
|
-
("file-manager/files", "files", True),
|
69
|
-
("media/file-manager/files", "files", True),
|
70
|
-
("media//files", "files", True),
|
71
|
-
("media//files", "file-manager/files", True),
|
72
|
-
("files", "file-manager/files", True),
|
73
|
-
("*/files", "file-manager/files", True),
|
74
|
-
("*//files", "file-manager/files", True),
|
75
|
-
("//files", "file-manager/files", True),
|
76
|
-
("//files", "media/file-manager/files", True),
|
77
|
-
("media//files", "media/file-manager/files", True),
|
78
|
-
("media/files/*", "media/files/transactions", True),
|
79
|
-
("*/*/transactions", "media/files/transactions", True),
|
80
|
-
("media/*/transactions", "media/images/transactions", True),
|
81
|
-
("media//files", "media/files", True), # attention
|
82
|
-
|
83
|
-
("files", "file", False),
|
84
|
-
("files", "files/transactions", False),
|
85
|
-
("files", "media/files/transactions", False),
|
86
|
-
("media/files", "media/files/transactions", False),
|
87
|
-
("finance/*/*", "wallet", False),
|
88
|
-
]
|
89
|
-
"""
|
90
|
-
if isinstance(user_path, str):
|
91
|
-
user_parts = user_path.split("/")
|
92
|
-
elif isinstance(user_path, list):
|
93
|
-
user_parts = user_path
|
56
|
+
def _normalize_path(path: list[str] | str) -> list[str]:
|
57
|
+
if isinstance(path, str):
|
58
|
+
return path.split("/")
|
59
|
+
elif isinstance(path, list):
|
60
|
+
return path
|
94
61
|
else:
|
95
|
-
raise ValueError(f"Invalid path type: {type(
|
62
|
+
raise ValueError(f"Invalid path type: {type(path)}")
|
96
63
|
|
97
|
-
if isinstance(requested_path, str):
|
98
|
-
req_parts = requested_path.split("/")
|
99
|
-
elif isinstance(requested_path, list):
|
100
|
-
req_parts = requested_path
|
101
|
-
else:
|
102
|
-
raise ValueError(f"Invalid path type: {type(requested_path)}")
|
103
64
|
|
65
|
+
def _match_path_parts(
|
66
|
+
user_parts: list[str], req_parts: list[str], strict: bool
|
67
|
+
) -> bool:
|
104
68
|
wildcard_found = False
|
105
69
|
# Match resource name (rightmost)
|
106
70
|
if not fnmatch.fnmatch(req_parts[-1], user_parts[-1]):
|
107
71
|
return False
|
108
|
-
|
109
72
|
if "*" in user_parts[-1]:
|
110
73
|
wildcard_found = True
|
111
|
-
|
112
74
|
# Match rest of the path from right to left
|
113
75
|
user_path_parts = user_parts[:-1]
|
114
76
|
req_path_parts = req_parts[:-1]
|
115
|
-
|
116
77
|
for u, r in zip(
|
117
|
-
reversed(user_path_parts),
|
118
|
-
reversed(req_path_parts),
|
119
|
-
strict=strict,
|
78
|
+
reversed(user_path_parts), reversed(req_path_parts), strict=strict
|
120
79
|
):
|
121
80
|
if r and u and r != "*" and not fnmatch.fnmatch(r, u):
|
122
81
|
return False
|
123
82
|
if "*" in u:
|
124
83
|
wildcard_found = True
|
125
|
-
|
126
84
|
offset = len(user_path_parts) - len(req_path_parts)
|
127
85
|
if offset > 0 and wildcard_found:
|
128
|
-
for u in user_path_parts[
|
86
|
+
for u in user_path_parts[:offset]:
|
129
87
|
if u != "*":
|
130
88
|
return False
|
131
|
-
|
132
89
|
return True
|
133
90
|
|
134
91
|
|
92
|
+
def is_path_match(
|
93
|
+
user_path: list[str] | str,
|
94
|
+
requested_path: list[str] | str,
|
95
|
+
strict: bool = False,
|
96
|
+
) -> bool:
|
97
|
+
"""
|
98
|
+
Match resource paths from right to left, supporting wildcards (*).
|
99
|
+
"""
|
100
|
+
user_parts = _normalize_path(user_path)
|
101
|
+
req_parts = _normalize_path(requested_path)
|
102
|
+
return _match_path_parts(user_parts, req_parts, strict)
|
103
|
+
|
104
|
+
|
135
105
|
def is_filter_match(user_filters: dict, requested_filters: dict) -> bool:
|
136
106
|
"""All user filters must match requested filters."""
|
137
107
|
for k, v in user_filters.items():
|
@@ -222,7 +192,7 @@ def is_authorized(
|
|
222
192
|
if not is_path_match(user_path, requested_path, strict=strict):
|
223
193
|
return False
|
224
194
|
|
225
|
-
if not is_filter_match(user_filters, reuested_filter):
|
195
|
+
if not is_filter_match(user_filters, reuested_filter or {}):
|
226
196
|
return False
|
227
197
|
|
228
198
|
if requested_action:
|
@@ -256,15 +226,15 @@ def check_access(
|
|
256
226
|
if isinstance(filters, dict):
|
257
227
|
filters = [{k: v} for k, v in filters.items()]
|
258
228
|
elif filters is None:
|
259
|
-
filters = [
|
229
|
+
filters = [{}]
|
260
230
|
|
261
231
|
for scope in user_scopes:
|
262
|
-
for
|
232
|
+
for filt in filters:
|
263
233
|
if is_authorized(
|
264
234
|
user_scope=scope,
|
265
235
|
requested_path=resource_path,
|
266
236
|
requested_action=action,
|
267
|
-
reuested_filter=
|
237
|
+
reuested_filter=filt,
|
268
238
|
strict=strict,
|
269
239
|
):
|
270
240
|
return True
|
@@ -1,4 +1,5 @@
|
|
1
1
|
import logging
|
2
|
+
from urllib.parse import urlparse
|
2
3
|
|
3
4
|
import usso_jwt.exceptions
|
4
5
|
import usso_jwt.schemas
|
@@ -22,7 +23,9 @@ class UssoAuth:
|
|
22
23
|
self,
|
23
24
|
*,
|
24
25
|
jwt_config: AvailableJwtConfigs | None = None,
|
25
|
-
|
26
|
+
from_base_usso_url: str | None = None,
|
27
|
+
**kwargs: object,
|
28
|
+
) -> None:
|
26
29
|
"""Initialize the USSO authentication client.
|
27
30
|
|
28
31
|
Args:
|
@@ -31,6 +34,7 @@ class UssoAuth:
|
|
31
34
|
if jwt_config is None:
|
32
35
|
jwt_config = AuthConfig()
|
33
36
|
self.jwt_configs = AuthConfig.validate_jwt_configs(jwt_config)
|
37
|
+
self.from_base_usso_url = from_base_usso_url
|
34
38
|
|
35
39
|
def user_data_from_token(
|
36
40
|
self,
|
@@ -38,7 +42,7 @@ class UssoAuth:
|
|
38
42
|
*,
|
39
43
|
expected_token_type: str | None = "access",
|
40
44
|
raise_exception: bool = True,
|
41
|
-
**kwargs,
|
45
|
+
**kwargs: dict,
|
42
46
|
) -> UserData | None:
|
43
47
|
"""Get user data from a JWT token.
|
44
48
|
|
@@ -68,6 +72,25 @@ class UssoAuth:
|
|
68
72
|
except usso_jwt.exceptions.JWTError as e:
|
69
73
|
exp = e
|
70
74
|
|
75
|
+
if self.from_base_usso_url:
|
76
|
+
try:
|
77
|
+
jwt_obj = usso_jwt.schemas.JWT(
|
78
|
+
token=token, config=jwk_config, payload_class=UserData
|
79
|
+
)
|
80
|
+
iss = jwt_obj.unverified_payload.iss
|
81
|
+
iss_domain = urlparse(iss).netloc
|
82
|
+
jwt_obj.config.jwks_url = (
|
83
|
+
f"{self.from_base_usso_url}/.well-known/jwks.json?"
|
84
|
+
f"domain={iss_domain}"
|
85
|
+
)
|
86
|
+
if jwt_obj.verify(
|
87
|
+
expected_token_type=expected_token_type,
|
88
|
+
**kwargs,
|
89
|
+
):
|
90
|
+
return jwt_obj.payload
|
91
|
+
except usso_jwt.exceptions.JWTError as e:
|
92
|
+
exp = e
|
93
|
+
|
71
94
|
_handle_exception(
|
72
95
|
"Unauthorized",
|
73
96
|
message=str(exp) if exp else None,
|
@@ -1,4 +1,5 @@
|
|
1
1
|
import json
|
2
|
+
import os
|
2
3
|
from typing import Any, Literal, Union
|
3
4
|
|
4
5
|
import usso_jwt.config
|
@@ -7,13 +8,15 @@ from pydantic import BaseModel, model_validator
|
|
7
8
|
from .user import UserData
|
8
9
|
from .utils.string_utils import get_authorization_scheme_param
|
9
10
|
|
11
|
+
BASE_USSO_URL = os.getenv("BASE_USSO_URL") or "https://sso.usso.io"
|
12
|
+
|
10
13
|
|
11
14
|
class HeaderConfig(BaseModel):
|
12
15
|
type: Literal["Authorization", "Cookie", "CustomHeader"] = "Cookie"
|
13
16
|
name: str = "usso_access_token"
|
14
17
|
|
15
18
|
@model_validator(mode="before")
|
16
|
-
def validate_header(cls, data: dict):
|
19
|
+
def validate_header(cls, data: dict) -> dict:
|
17
20
|
if data.get("type") == "Authorization" and not data.get("name"):
|
18
21
|
data["name"] = "Bearer"
|
19
22
|
elif data.get("type") == "Cookie":
|
@@ -22,10 +25,10 @@ class HeaderConfig(BaseModel):
|
|
22
25
|
data["name"] = data.get("name", "x-usso-access-token")
|
23
26
|
return data
|
24
27
|
|
25
|
-
def __hash__(self):
|
28
|
+
def __hash__(self) -> int:
|
26
29
|
return hash(self.model_dump_json())
|
27
30
|
|
28
|
-
def get_key(self, request) -> str | None:
|
31
|
+
def get_key(self, request: object) -> str | None: # type: ignore
|
29
32
|
headers: dict[str, Any] = getattr(request, "headers", {})
|
30
33
|
cookies: dict[str, str] = getattr(
|
31
34
|
request, "cookies", headers.get("Cookie", {})
|
@@ -42,7 +45,7 @@ class HeaderConfig(BaseModel):
|
|
42
45
|
|
43
46
|
|
44
47
|
class APIHeaderConfig(HeaderConfig):
|
45
|
-
verify_endpoint: str = "
|
48
|
+
verify_endpoint: str = f"{BASE_USSO_URL}/api/sso/v1/apikeys/verify"
|
46
49
|
|
47
50
|
|
48
51
|
class AuthConfig(usso_jwt.config.JWTConfig):
|
@@ -54,18 +57,18 @@ class AuthConfig(usso_jwt.config.JWTConfig):
|
|
54
57
|
jwt_header: HeaderConfig | None = HeaderConfig()
|
55
58
|
static_api_keys: list[str] | None = None
|
56
59
|
|
57
|
-
def get_api_key(self, request) -> str | None:
|
60
|
+
def get_api_key(self, request: object) -> str | None:
|
58
61
|
if self.api_key_header:
|
59
62
|
return self.api_key_header.get_key(request)
|
60
63
|
return None
|
61
64
|
|
62
|
-
def get_jwt(self, request) -> str | None:
|
65
|
+
def get_jwt(self, request: object) -> str | None:
|
63
66
|
if self.jwt_header:
|
64
67
|
return self.jwt_header.get_key(request)
|
65
68
|
return None
|
66
69
|
|
67
70
|
def verify_token(
|
68
|
-
self, token: str, *, raise_exception: bool = True, **kwargs
|
71
|
+
self, token: str, *, raise_exception: bool = True, **kwargs: dict
|
69
72
|
) -> bool:
|
70
73
|
from usso_jwt import exceptions as jwt_exceptions
|
71
74
|
from usso_jwt import schemas
|
@@ -14,30 +14,44 @@ error_messages = {
|
|
14
14
|
|
15
15
|
class USSOException(Exception):
|
16
16
|
def __init__(
|
17
|
-
self,
|
18
|
-
|
17
|
+
self,
|
18
|
+
status_code: int,
|
19
|
+
error: str,
|
20
|
+
detail: str | None = None,
|
21
|
+
message: dict | None = None,
|
22
|
+
**kwargs: dict,
|
23
|
+
) -> None:
|
19
24
|
self.status_code = status_code
|
20
25
|
self.error = error
|
21
|
-
|
26
|
+
msg: dict = {}
|
22
27
|
if message is None:
|
23
|
-
|
24
|
-
|
28
|
+
if detail:
|
29
|
+
msg["en"] = detail
|
30
|
+
else:
|
31
|
+
msg["en"] = error_messages.get(error, error)
|
32
|
+
else:
|
33
|
+
msg = message
|
34
|
+
|
35
|
+
self.message = msg
|
36
|
+
self.detail = detail or str(self.message)
|
37
|
+
self.data = kwargs
|
38
|
+
super().__init__(detail)
|
25
39
|
|
26
40
|
|
27
41
|
class PermissionDenied(USSOException):
|
28
42
|
def __init__(
|
29
43
|
self,
|
30
44
|
error: str = "permission_denied",
|
31
|
-
|
32
|
-
|
33
|
-
**kwargs,
|
34
|
-
):
|
45
|
+
detail: str | None = None,
|
46
|
+
message: dict | None = None,
|
47
|
+
**kwargs: dict,
|
48
|
+
) -> None:
|
35
49
|
super().__init__(
|
36
50
|
403, error=error, message=message, detail=detail, **kwargs
|
37
51
|
)
|
38
52
|
|
39
53
|
|
40
|
-
def _handle_exception(error_type: str, **kwargs):
|
54
|
+
def _handle_exception(error_type: str, **kwargs: dict) -> None:
|
41
55
|
"""Handle JWT-related exceptions."""
|
42
56
|
if kwargs.get("raise_exception", True):
|
43
57
|
raise USSOException(
|
@@ -17,7 +17,7 @@ class USSOAuthenticationMiddleware(MiddlewareMixin):
|
|
17
17
|
def jwt_config(self) -> AuthConfig:
|
18
18
|
return settings.USSO_JWT_CONFIG
|
19
19
|
|
20
|
-
def process_request(self, request: HttpRequest):
|
20
|
+
def process_request(self, request: HttpRequest) -> None:
|
21
21
|
"""
|
22
22
|
Middleware to authenticate users by JWT token and create or
|
23
23
|
return a user in the database.
|
@@ -14,15 +14,20 @@ class USSOAuthentication(UssoAuth):
|
|
14
14
|
def __init__(
|
15
15
|
self,
|
16
16
|
jwt_config: AvailableJwtConfigs | None = None,
|
17
|
+
*,
|
17
18
|
raise_exception: bool = True,
|
18
19
|
expected_token_type: str = "access",
|
19
|
-
|
20
|
+
from_base_usso_url: str | None = None,
|
21
|
+
) -> None:
|
20
22
|
if jwt_config is None:
|
21
23
|
jwt_config = AuthConfig()
|
22
24
|
|
23
|
-
super().__init__(
|
25
|
+
super().__init__(
|
26
|
+
jwt_config=jwt_config, from_base_usso_url=from_base_usso_url
|
27
|
+
)
|
24
28
|
self.raise_exception = raise_exception
|
25
29
|
self.expected_token_type = expected_token_type
|
30
|
+
self.from_base_usso_url = from_base_usso_url
|
26
31
|
|
27
32
|
def __call__(self, request: Request) -> UserData:
|
28
33
|
return self.usso_access_security(request)
|
@@ -41,7 +46,6 @@ class USSOAuthentication(UssoAuth):
|
|
41
46
|
return token
|
42
47
|
return None
|
43
48
|
|
44
|
-
# @instance_method
|
45
49
|
def usso_access_security(self, request: Request) -> UserData | None:
|
46
50
|
"""Return the user associated with a token value."""
|
47
51
|
api_key = self.get_request_api_key(request)
|
@@ -62,7 +66,6 @@ class USSOAuthentication(UssoAuth):
|
|
62
66
|
raise_exception=self.raise_exception,
|
63
67
|
)
|
64
68
|
|
65
|
-
# @instance_method
|
66
69
|
def jwt_access_security_ws(self, websocket: WebSocket) -> UserData | None:
|
67
70
|
"""Return the user associated with a token value."""
|
68
71
|
api_key = self.get_request_api_key(websocket)
|
@@ -4,10 +4,17 @@ from fastapi.responses import JSONResponse
|
|
4
4
|
from ...exceptions import USSOException
|
5
5
|
|
6
6
|
|
7
|
-
async def usso_exception_handler(
|
7
|
+
async def usso_exception_handler(
|
8
|
+
request: Request, exc: USSOException
|
9
|
+
) -> JSONResponse:
|
8
10
|
return JSONResponse(
|
9
11
|
status_code=exc.status_code,
|
10
|
-
content={
|
12
|
+
content={
|
13
|
+
"message": exc.message,
|
14
|
+
"error": exc.error,
|
15
|
+
"detail": exc.detail,
|
16
|
+
**exc.data,
|
17
|
+
},
|
11
18
|
)
|
12
19
|
|
13
20
|
|
@@ -17,8 +17,9 @@ class AsyncUssoSession(httpx.AsyncClient, BaseUssoSession):
|
|
17
17
|
usso_api_key: str | None = os.getenv("USSO_ADMIN_API_KEY"),
|
18
18
|
user_id: str | None = None,
|
19
19
|
client: "AsyncUssoSession" = None,
|
20
|
-
|
21
|
-
|
20
|
+
**kwargs: dict,
|
21
|
+
) -> None:
|
22
|
+
httpx.AsyncClient.__init__(self, **kwargs)
|
22
23
|
BaseUssoSession.__init__(
|
23
24
|
self,
|
24
25
|
usso_base_url=usso_base_url,
|
@@ -100,7 +101,7 @@ class AsyncUssoSession(httpx.AsyncClient, BaseUssoSession):
|
|
100
101
|
)
|
101
102
|
return self._handle_refresh_response(response)
|
102
103
|
|
103
|
-
async def get_session(self):
|
104
|
+
async def get_session(self) -> "AsyncUssoSession":
|
104
105
|
if hasattr(self, "api_key") and self.api_key:
|
105
106
|
return self
|
106
107
|
|
@@ -108,6 +109,8 @@ class AsyncUssoSession(httpx.AsyncClient, BaseUssoSession):
|
|
108
109
|
await self._refresh()
|
109
110
|
return self
|
110
111
|
|
111
|
-
async def _request(
|
112
|
+
async def _request(
|
113
|
+
self, method: str, url: str, **kwargs: dict
|
114
|
+
) -> httpx.Response:
|
112
115
|
session = await self.get_session()
|
113
116
|
return await session.request(method, url, **kwargs)
|
@@ -14,7 +14,7 @@ class BaseUssoSession:
|
|
14
14
|
app_secret: str | None = None,
|
15
15
|
usso_url: str = "https://sso.usso.io",
|
16
16
|
client: Optional["BaseUssoSession"] = None,
|
17
|
-
):
|
17
|
+
) -> None:
|
18
18
|
if client:
|
19
19
|
self.copy_attributes_from(client)
|
20
20
|
return
|
@@ -54,7 +54,7 @@ class BaseUssoSession:
|
|
54
54
|
self.access_token = None
|
55
55
|
self.headers = getattr(self, "headers", {})
|
56
56
|
|
57
|
-
def copy_attributes_from(self, client: "BaseUssoSession"):
|
57
|
+
def copy_attributes_from(self, client: "BaseUssoSession") -> None:
|
58
58
|
self.usso_url = client.usso_url
|
59
59
|
self.usso_refresh_url = client.usso_refresh_url
|
60
60
|
self._refresh_token = client._refresh_token
|
@@ -64,7 +64,7 @@ class BaseUssoSession:
|
|
64
64
|
self.headers = client.headers.copy()
|
65
65
|
|
66
66
|
@property
|
67
|
-
def refresh_token(self):
|
67
|
+
def refresh_token(self) -> JWT:
|
68
68
|
if (
|
69
69
|
self._refresh_token
|
70
70
|
and self._refresh_token.verify( # noqa: W503
|
@@ -14,8 +14,8 @@ class UssoSession(httpx.Client, BaseUssoSession):
|
|
14
14
|
refresh_token: str | None = os.getenv("USSO_REFRESH_TOKEN"),
|
15
15
|
usso_url: str | None = os.getenv("USSO_URL"),
|
16
16
|
client: "UssoSession" = None,
|
17
|
-
**kwargs,
|
18
|
-
):
|
17
|
+
**kwargs: dict,
|
18
|
+
) -> None:
|
19
19
|
httpx.Client.__init__(self, **kwargs)
|
20
20
|
|
21
21
|
BaseUssoSession.__init__(
|
@@ -28,7 +28,7 @@ class UssoSession(httpx.Client, BaseUssoSession):
|
|
28
28
|
if not self.api_key:
|
29
29
|
self._refresh()
|
30
30
|
|
31
|
-
def _refresh(self):
|
31
|
+
def _refresh(self) -> dict:
|
32
32
|
assert self.refresh_token, "refresh_token is required"
|
33
33
|
|
34
34
|
response = httpx.post(
|
@@ -43,7 +43,7 @@ class UssoSession(httpx.Client, BaseUssoSession):
|
|
43
43
|
self.headers.update({"Authorization": f"Bearer {self.access_token}"})
|
44
44
|
return response.json()
|
45
45
|
|
46
|
-
def get_session(self):
|
46
|
+
def get_session(self) -> "UssoSession":
|
47
47
|
if self.api_key:
|
48
48
|
return self
|
49
49
|
|
@@ -51,6 +51,8 @@ class UssoSession(httpx.Client, BaseUssoSession):
|
|
51
51
|
self._refresh()
|
52
52
|
return self
|
53
53
|
|
54
|
-
def _request(
|
54
|
+
def _request(
|
55
|
+
self, method: str, url: str, **kwargs: dict
|
56
|
+
) -> httpx.Response:
|
55
57
|
self.get_session()
|
56
58
|
return super().request(self, method, url, **kwargs)
|
@@ -55,8 +55,8 @@ class UserData(BaseModel):
|
|
55
55
|
acr: str | None = None,
|
56
56
|
amr: list[str] | None = None,
|
57
57
|
signing_level: str | None = None,
|
58
|
-
**kwargs,
|
59
|
-
):
|
58
|
+
**kwargs: dict,
|
59
|
+
) -> None:
|
60
60
|
super().__init__(
|
61
61
|
jti=jti,
|
62
62
|
token_type=token_type,
|
@@ -109,9 +109,9 @@ class UserData(BaseModel):
|
|
109
109
|
self,
|
110
110
|
*,
|
111
111
|
mode: Literal["json", "python"] | str = "python",
|
112
|
-
include=None,
|
113
|
-
exclude=None,
|
114
|
-
context:
|
112
|
+
include: set[str] | list[str] | None = None,
|
113
|
+
exclude: set[str] | list[str] | None = None,
|
114
|
+
context: object | None = None,
|
115
115
|
by_alias: bool | None = None,
|
116
116
|
exclude_unset: bool = False,
|
117
117
|
exclude_defaults: bool = False,
|
@@ -120,7 +120,7 @@ class UserData(BaseModel):
|
|
120
120
|
warnings: bool | Literal["none", "warn", "error"] = True,
|
121
121
|
fallback: Callable[[Any], Any] | None = None,
|
122
122
|
serialize_as_any: bool = False,
|
123
|
-
):
|
123
|
+
) -> dict:
|
124
124
|
return super().model_dump(
|
125
125
|
mode=mode,
|
126
126
|
include=include,
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: usso
|
3
|
-
Version: 0.28.
|
3
|
+
Version: 0.28.28
|
4
4
|
Summary: A plug-and-play client for integrating universal single sign-on (SSO) with Python frameworks, enabling secure and seamless authentication across microservices.
|
5
5
|
Author-email: Mahdi Kiani <mahdikiany@gmail.com>
|
6
6
|
Maintainer-email: Mahdi Kiani <mahdikiany@gmail.com>
|
@@ -28,7 +28,7 @@ Requires-Dist: cachetools
|
|
28
28
|
Requires-Dist: singleton_package
|
29
29
|
Requires-Dist: json-advanced
|
30
30
|
Requires-Dist: httpx
|
31
|
-
Requires-Dist: usso-jwt>=0.2.
|
31
|
+
Requires-Dist: usso-jwt>=0.2.6
|
32
32
|
Provides-Extra: fastapi
|
33
33
|
Requires-Dist: fastapi>=0.65.0; extra == "fastapi"
|
34
34
|
Requires-Dist: uvicorn[standard]>=0.13.0; extra == "fastapi"
|
@@ -22,11 +22,6 @@ from src.usso.authorization import (
|
|
22
22
|
("//files", "file-manager/files", True),
|
23
23
|
("//files", "media/file-manager/files", True),
|
24
24
|
("media//files", "media/file-manager/files", True),
|
25
|
-
(
|
26
|
-
"media//files",
|
27
|
-
"media/files",
|
28
|
-
True,
|
29
|
-
), # !! atention it matches because the middle part is empty
|
30
25
|
("media/files/*", "media/files/transactions", True),
|
31
26
|
("*/*/transactions", "media/files/transactions", True),
|
32
27
|
("media/*/transactions", "media/images/transactions", True),
|
@@ -35,14 +30,17 @@ from src.usso.authorization import (
|
|
35
30
|
("files", "media/files/transactions", False),
|
36
31
|
("media/files", "media/files/transactions", False),
|
37
32
|
("finance/*/*", "wallet", False),
|
33
|
+
("media//files", "media/files", True),
|
38
34
|
],
|
39
35
|
)
|
40
|
-
def test_path_match(
|
36
|
+
def test_path_match(
|
37
|
+
user_path: str, requested_path: str, expected: bool
|
38
|
+
) -> None:
|
41
39
|
assert is_path_match(user_path, requested_path, strict=False) == expected
|
42
40
|
|
43
41
|
|
44
42
|
# Define pytest tests
|
45
|
-
def test_exact_match_id():
|
43
|
+
def test_exact_match_id() -> None:
|
46
44
|
scopes = ["read:media/file-manager/files?uid=file123"]
|
47
45
|
assert (
|
48
46
|
check_access(
|
@@ -50,9 +48,9 @@ def test_exact_match_id():
|
|
50
48
|
"files",
|
51
49
|
action="read",
|
52
50
|
filters=[
|
53
|
-
|
54
|
-
|
55
|
-
|
51
|
+
{"namespace": "media"},
|
52
|
+
{"service": "file-manager"},
|
53
|
+
{"uid": "file123"},
|
56
54
|
],
|
57
55
|
)
|
58
56
|
is True
|
@@ -60,7 +58,7 @@ def test_exact_match_id():
|
|
60
58
|
|
61
59
|
|
62
60
|
# Define pytest tests
|
63
|
-
def test_wildcard():
|
61
|
+
def test_wildcard() -> None:
|
64
62
|
scopes = [
|
65
63
|
"update:media/files/transactions?user_id=abc",
|
66
64
|
"read:media/files/*",
|
@@ -86,7 +84,7 @@ def test_wildcard():
|
|
86
84
|
)
|
87
85
|
|
88
86
|
|
89
|
-
def test_insufficient_privilege():
|
87
|
+
def test_insufficient_privilege() -> None:
|
90
88
|
scopes = ["read:media/files/file:uid:file123"]
|
91
89
|
assert (
|
92
90
|
check_access(
|
@@ -94,33 +92,33 @@ def test_insufficient_privilege():
|
|
94
92
|
"file",
|
95
93
|
"update",
|
96
94
|
filters=[
|
97
|
-
|
98
|
-
|
99
|
-
|
95
|
+
{"namespace": "finance"},
|
96
|
+
{"service": "wallet"},
|
97
|
+
{"workspace_id": "ws_7"},
|
100
98
|
],
|
101
99
|
)
|
102
100
|
is False
|
103
101
|
)
|
104
102
|
|
105
103
|
|
106
|
-
def test_wildcard_match():
|
104
|
+
def test_wildcard_match() -> None:
|
107
105
|
scopes = ["manage:media/files/file?*"]
|
108
106
|
assert (
|
109
107
|
check_access(
|
110
108
|
scopes,
|
111
109
|
"file",
|
112
110
|
"update",
|
113
|
-
filters=
|
114
|
-
namespace
|
115
|
-
service
|
116
|
-
workspace_id
|
117
|
-
|
111
|
+
filters={
|
112
|
+
"namespace": "finance",
|
113
|
+
"service": "wallet",
|
114
|
+
"workspace_id": "ws_7",
|
115
|
+
},
|
118
116
|
)
|
119
117
|
is True
|
120
118
|
)
|
121
119
|
|
122
120
|
|
123
|
-
def test_match_by_user_id():
|
121
|
+
def test_match_by_user_id() -> None:
|
124
122
|
scopes = ["manage:finance/wallet/transaction?user=user_1"]
|
125
123
|
assert (
|
126
124
|
check_access(
|
@@ -128,9 +126,9 @@ def test_match_by_user_id():
|
|
128
126
|
"transaction",
|
129
127
|
"update",
|
130
128
|
filters=[
|
131
|
-
|
132
|
-
|
133
|
-
|
129
|
+
{"namespace": "finance"},
|
130
|
+
{"service": "wallet"},
|
131
|
+
{"workspace_id": "ws_7"},
|
134
132
|
],
|
135
133
|
)
|
136
134
|
is False
|
@@ -140,13 +138,13 @@ def test_match_by_user_id():
|
|
140
138
|
scopes,
|
141
139
|
"transaction",
|
142
140
|
"update",
|
143
|
-
filters=
|
141
|
+
filters={"namespace": "finance", "user": "user_1"},
|
144
142
|
)
|
145
143
|
is True
|
146
144
|
)
|
147
145
|
|
148
146
|
|
149
|
-
def test_match_by_workspace_id():
|
147
|
+
def test_match_by_workspace_id() -> None:
|
150
148
|
scopes = ["delete:finance/wallet/transaction?workspace_id=ws_7"]
|
151
149
|
assert (
|
152
150
|
check_access(
|
@@ -154,31 +152,31 @@ def test_match_by_workspace_id():
|
|
154
152
|
"transaction",
|
155
153
|
"delete",
|
156
154
|
filters=[
|
157
|
-
|
158
|
-
|
159
|
-
|
155
|
+
{"namespace": "finance"},
|
156
|
+
{"service": "wallet"},
|
157
|
+
{"workspace_id": "ws_7"},
|
160
158
|
],
|
161
159
|
)
|
162
160
|
is True
|
163
161
|
)
|
164
162
|
|
165
163
|
|
166
|
-
def test_minimal_params_success():
|
164
|
+
def test_minimal_params_success() -> None:
|
167
165
|
scopes = ["create:file?*"]
|
168
166
|
assert check_access(scopes, "file", "create") is True
|
169
167
|
|
170
168
|
|
171
|
-
def test_minimal_params_fail():
|
169
|
+
def test_minimal_params_fail() -> None:
|
172
170
|
scopes = ["read:file?*"]
|
173
171
|
assert check_access(scopes, "file", "create") is False
|
174
172
|
|
175
173
|
|
176
|
-
def test_minimal_params_read_create_fail():
|
174
|
+
def test_minimal_params_read_create_fail() -> None:
|
177
175
|
scopes = ["file"]
|
178
176
|
assert check_access(scopes, "file", "create") is False
|
179
177
|
|
180
178
|
|
181
|
-
def test_scope_subset():
|
179
|
+
def test_scope_subset() -> None:
|
182
180
|
assert is_subset_scope(
|
183
181
|
subset_scope="read:media/files?user_id=123",
|
184
182
|
super_scope="read:media/files",
|
@@ -5,7 +5,7 @@ from collections.abc import AsyncGenerator
|
|
5
5
|
import httpx
|
6
6
|
import pytest
|
7
7
|
import pytest_asyncio
|
8
|
-
from fastapi import Depends, WebSocket
|
8
|
+
from fastapi import Depends, FastAPI, WebSocket
|
9
9
|
from starlette.testclient import TestClient
|
10
10
|
from usso_jwt.algorithms import AbstractKey
|
11
11
|
|
@@ -18,7 +18,7 @@ from src.usso.integrations.fastapi import (
|
|
18
18
|
|
19
19
|
|
20
20
|
@pytest.fixture(scope="session")
|
21
|
-
def app(test_key: AbstractKey):
|
21
|
+
def app(test_key: AbstractKey) -> FastAPI:
|
22
22
|
import fastapi
|
23
23
|
|
24
24
|
os.environ["JWT_CONFIG"] = json.dumps({
|
@@ -36,14 +36,14 @@ def app(test_key: AbstractKey):
|
|
36
36
|
@app.get("/user")
|
37
37
|
async def get_user(
|
38
38
|
user: UserData = Depends(usso.usso_access_security), # noqa: B008
|
39
|
-
):
|
39
|
+
) -> dict:
|
40
40
|
return user.model_dump()
|
41
41
|
|
42
42
|
@app.websocket("/ws")
|
43
43
|
async def websocket_endpoint(
|
44
44
|
websocket: WebSocket,
|
45
45
|
user: UserData = Depends(usso.jwt_access_security_ws), # noqa: B008
|
46
|
-
):
|
46
|
+
) -> None:
|
47
47
|
await websocket.accept()
|
48
48
|
await websocket.send_json({"msg": user.model_dump()})
|
49
49
|
# await websocket.send_json({"msg": "Hello WebSocket"})
|
@@ -53,7 +53,7 @@ def app(test_key: AbstractKey):
|
|
53
53
|
|
54
54
|
|
55
55
|
@pytest_asyncio.fixture(scope="session")
|
56
|
-
async def client(app) -> AsyncGenerator[httpx.AsyncClient]:
|
56
|
+
async def client(app: FastAPI) -> AsyncGenerator[httpx.AsyncClient]:
|
57
57
|
"""Fixture to provide an AsyncClient for FastAPI app."""
|
58
58
|
|
59
59
|
async with httpx.AsyncClient(
|
@@ -64,14 +64,13 @@ async def client(app) -> AsyncGenerator[httpx.AsyncClient]:
|
|
64
64
|
|
65
65
|
|
66
66
|
@pytest.mark.asyncio
|
67
|
-
async def test_get_user_no_token(client: httpx.AsyncClient):
|
67
|
+
async def test_get_user_no_token(client: httpx.AsyncClient) -> None:
|
68
68
|
response = await client.get("/user")
|
69
|
-
print(response.json())
|
70
69
|
assert response.status_code == 401
|
71
70
|
|
72
71
|
|
73
72
|
@pytest.mark.asyncio
|
74
|
-
async def test_get_user_with_invalid_token(client: httpx.AsyncClient):
|
73
|
+
async def test_get_user_with_invalid_token(client: httpx.AsyncClient) -> None:
|
75
74
|
response = await client.get(
|
76
75
|
"/user",
|
77
76
|
headers={"Authorization": "Bearer test"},
|
@@ -84,7 +83,7 @@ async def test_get_user_with_token(
|
|
84
83
|
client: httpx.AsyncClient,
|
85
84
|
test_valid_token: str,
|
86
85
|
test_valid_payload: dict,
|
87
|
-
):
|
86
|
+
) -> None:
|
88
87
|
response = await client.get(
|
89
88
|
"/user",
|
90
89
|
headers={"Authorization": f"Bearer {test_valid_token}"},
|
@@ -93,7 +92,11 @@ async def test_get_user_with_token(
|
|
93
92
|
assert response.json().get("claims") == test_valid_payload
|
94
93
|
|
95
94
|
|
96
|
-
def test_websocket(
|
95
|
+
def test_websocket(
|
96
|
+
app: FastAPI,
|
97
|
+
test_valid_token: str,
|
98
|
+
test_valid_payload: dict,
|
99
|
+
) -> None:
|
97
100
|
client = TestClient(app)
|
98
101
|
with client.websocket_connect(
|
99
102
|
"/ws", headers={"Authorization": f"Bearer {test_valid_token}"}
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|