usso 0.27.11__tar.gz → 0.27.13__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.27.11/src/usso.egg-info → usso-0.27.13}/PKG-INFO +1 -1
- {usso-0.27.11 → usso-0.27.13}/pyproject.toml +1 -1
- usso-0.27.13/src/usso/core.py +156 -0
- {usso-0.27.11 → usso-0.27.13}/src/usso/exceptions.py +4 -4
- usso-0.27.13/src/usso/schemas.py +67 -0
- {usso-0.27.11 → usso-0.27.13}/src/usso/session/async_session.py +12 -12
- {usso-0.27.11 → usso-0.27.13}/src/usso/session/base_session.py +6 -0
- {usso-0.27.11 → usso-0.27.13}/src/usso/session/session.py +12 -13
- {usso-0.27.11 → usso-0.27.13/src/usso.egg-info}/PKG-INFO +1 -1
- {usso-0.27.11 → usso-0.27.13}/src/usso.egg-info/SOURCES.txt +1 -4
- usso-0.27.11/src/usso/core.py +0 -233
- usso-0.27.11/src/usso/schemas.py +0 -39
- usso-0.27.11/tests/test_api.py +0 -61
- usso-0.27.11/tests/test_core.py +0 -81
- usso-0.27.11/tests/test_simple.py +0 -17
- {usso-0.27.11 → usso-0.27.13}/LICENSE.txt +0 -0
- {usso-0.27.11 → usso-0.27.13}/README.md +0 -0
- {usso-0.27.11 → usso-0.27.13}/setup.cfg +0 -0
- {usso-0.27.11 → usso-0.27.13}/src/usso/__init__.py +0 -0
- {usso-0.27.11 → usso-0.27.13}/src/usso/b64tools.py +0 -0
- {usso-0.27.11 → usso-0.27.13}/src/usso/client/__init__.py +0 -0
- {usso-0.27.11 → usso-0.27.13}/src/usso/client/api.py +0 -0
- {usso-0.27.11 → usso-0.27.13}/src/usso/client/async_api.py +0 -0
- {usso-0.27.11 → usso-0.27.13}/src/usso/django/__init__.py +0 -0
- {usso-0.27.11 → usso-0.27.13}/src/usso/django/middleware.py +0 -0
- {usso-0.27.11 → usso-0.27.13}/src/usso/fastapi/__init__.py +0 -0
- {usso-0.27.11 → usso-0.27.13}/src/usso/fastapi/integration.py +0 -0
- {usso-0.27.11 → usso-0.27.13}/src/usso/session/__init__.py +0 -0
- {usso-0.27.11 → usso-0.27.13}/src/usso.egg-info/dependency_links.txt +0 -0
- {usso-0.27.11 → usso-0.27.13}/src/usso.egg-info/entry_points.txt +0 -0
- {usso-0.27.11 → usso-0.27.13}/src/usso.egg-info/requires.txt +0 -0
- {usso-0.27.11 → usso-0.27.13}/src/usso.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: usso
|
3
|
-
Version: 0.27.
|
3
|
+
Version: 0.27.13
|
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>
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
4
4
|
|
5
5
|
[project]
|
6
6
|
name = "usso"
|
7
|
-
version = "0.27.
|
7
|
+
version = "0.27.13"
|
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"
|
@@ -0,0 +1,156 @@
|
|
1
|
+
import json
|
2
|
+
import logging
|
3
|
+
import os
|
4
|
+
from datetime import datetime, timedelta
|
5
|
+
from urllib.parse import urlparse
|
6
|
+
|
7
|
+
import cachetools.func
|
8
|
+
import httpx
|
9
|
+
import jwt
|
10
|
+
|
11
|
+
from .exceptions import USSOException
|
12
|
+
from .schemas import JWTConfig, UserData
|
13
|
+
|
14
|
+
logger = logging.getLogger("usso")
|
15
|
+
|
16
|
+
|
17
|
+
def get_authorization_scheme_param(
|
18
|
+
authorization_header_value: str | None,
|
19
|
+
) -> tuple[str, str]:
|
20
|
+
if not authorization_header_value:
|
21
|
+
return "", ""
|
22
|
+
scheme, _, param = authorization_header_value.partition(" ")
|
23
|
+
return scheme, param
|
24
|
+
|
25
|
+
|
26
|
+
def decode_token(key, token: str, algorithms=["RS256"], **kwargs) -> dict:
|
27
|
+
"""Decode a JWT token."""
|
28
|
+
try:
|
29
|
+
decoded = jwt.decode(token, key, algorithms=algorithms)
|
30
|
+
decoded.update({"data": decoded, "token": token})
|
31
|
+
return UserData(**decoded)
|
32
|
+
except jwt.ExpiredSignatureError:
|
33
|
+
_handle_exception("expired_signature", **kwargs)
|
34
|
+
except jwt.InvalidSignatureError:
|
35
|
+
_handle_exception("invalid_signature", **kwargs)
|
36
|
+
except jwt.InvalidAlgorithmError:
|
37
|
+
_handle_exception("invalid_algorithm", **kwargs)
|
38
|
+
except jwt.InvalidIssuedAtError:
|
39
|
+
_handle_exception("invalid_issued_at", **kwargs)
|
40
|
+
except jwt.InvalidTokenError:
|
41
|
+
_handle_exception("invalid_token", **kwargs)
|
42
|
+
except jwt.InvalidKeyError:
|
43
|
+
_handle_exception("invalid_key", **kwargs)
|
44
|
+
except Exception as e:
|
45
|
+
_handle_exception("error", message=str(e), **kwargs)
|
46
|
+
|
47
|
+
|
48
|
+
def _handle_exception(error_type: str, **kwargs):
|
49
|
+
"""Handle JWT-related exceptions."""
|
50
|
+
if kwargs.get("raise_exception", True):
|
51
|
+
raise USSOException(
|
52
|
+
status_code=401, error=error_type, message=kwargs.get("message")
|
53
|
+
)
|
54
|
+
logger.error(kwargs.get("message") or error_type)
|
55
|
+
|
56
|
+
|
57
|
+
def is_expired(token: str, **kwargs) -> bool:
|
58
|
+
now = datetime.now()
|
59
|
+
decoded_token: dict = jwt.decode(token, options={"verify_signature": False})
|
60
|
+
exp = decoded_token.get("exp", (now + timedelta(days=1)).timestamp())
|
61
|
+
exp = datetime.fromtimestamp(exp)
|
62
|
+
return exp < now
|
63
|
+
|
64
|
+
|
65
|
+
@cachetools.func.ttl_cache(maxsize=128, ttl=10 * 60)
|
66
|
+
def get_jwk_keys(jwk_url: str) -> jwt.PyJWKClient:
|
67
|
+
return jwt.PyJWKClient(jwk_url, headers={"User-Agent": "usso-python"})
|
68
|
+
|
69
|
+
|
70
|
+
def decode_token_with_jwk(jwk_url: str, token: str, **kwargs) -> UserData | None:
|
71
|
+
"""Return the user associated with a token value."""
|
72
|
+
try:
|
73
|
+
jwk_client = get_jwk_keys(jwk_url)
|
74
|
+
signing_key = jwk_client.get_signing_key_from_jwt(token)
|
75
|
+
return decode_token(signing_key.key, token, **kwargs)
|
76
|
+
except Exception as e:
|
77
|
+
_handle_exception("error", message=str(e), **kwargs)
|
78
|
+
|
79
|
+
|
80
|
+
@cachetools.func.ttl_cache(maxsize=128, ttl=10 * 60)
|
81
|
+
def fetch_api_key_data(jwk_url: str, api_key: str):
|
82
|
+
parsed = urlparse(jwk_url)
|
83
|
+
url = f"{parsed.scheme}://{parsed.netloc}/api_key/verify"
|
84
|
+
response = httpx.post(url, json={"api_key": api_key})
|
85
|
+
response.raise_for_status()
|
86
|
+
return UserData(**response.json())
|
87
|
+
|
88
|
+
|
89
|
+
class Usso:
|
90
|
+
def __init__(
|
91
|
+
self,
|
92
|
+
*,
|
93
|
+
jwt_config: (
|
94
|
+
str | dict | JWTConfig | list[str] | list[dict] | list[JWTConfig] | None
|
95
|
+
) = None,
|
96
|
+
jwk_url: str | None = None,
|
97
|
+
secret: str | None = None,
|
98
|
+
):
|
99
|
+
self.jwt_configs = self._initialize_configs(jwt_config, jwk_url, secret)
|
100
|
+
|
101
|
+
def _initialize_configs(
|
102
|
+
self,
|
103
|
+
jwt_config: (
|
104
|
+
str | dict | JWTConfig | list[str] | list[dict] | list[JWTConfig] | None
|
105
|
+
) = None,
|
106
|
+
jwk_url: str | None = None,
|
107
|
+
secret: str | None = None,
|
108
|
+
):
|
109
|
+
"""Initialize JWT configurations."""
|
110
|
+
if jwt_config is None:
|
111
|
+
jwt_config = os.getenv("USSO_JWT_CONFIG")
|
112
|
+
|
113
|
+
if jwt_config is None:
|
114
|
+
jwk_url = jwk_url or os.getenv("USSO_JWK_URL") or os.getenv("USSO_JWKS_URL")
|
115
|
+
secret = secret or os.getenv("USSO_SECRET")
|
116
|
+
if jwk_url:
|
117
|
+
return [JWTConfig(jwk_url=jwk_url)]
|
118
|
+
if secret:
|
119
|
+
return [JWTConfig(secret=secret)]
|
120
|
+
raise ValueError(
|
121
|
+
"Provide jwt_config, jwk_url, or secret, or set the appropriate environment variables."
|
122
|
+
)
|
123
|
+
|
124
|
+
if isinstance(jwt_config, (str, dict, JWTConfig)):
|
125
|
+
return [self._parse_config(jwt_config)]
|
126
|
+
if isinstance(jwt_config, list):
|
127
|
+
return [self._parse_config(config) for config in jwt_config]
|
128
|
+
raise ValueError("Invalid jwt_config format")
|
129
|
+
|
130
|
+
def _parse_config(self, config):
|
131
|
+
"""Parse a single JWT configuration."""
|
132
|
+
if isinstance(config, str):
|
133
|
+
config = json.loads(config)
|
134
|
+
if isinstance(config, dict):
|
135
|
+
return JWTConfig(**config)
|
136
|
+
return config
|
137
|
+
|
138
|
+
def user_data_from_token(self, token: str, **kwargs) -> UserData | None:
|
139
|
+
"""Return the user associated with a token value."""
|
140
|
+
exp = None
|
141
|
+
for jwk_config in self.jwt_configs:
|
142
|
+
try:
|
143
|
+
user_data = jwk_config.decode(token)
|
144
|
+
if user_data.token_type.lower() != kwargs.get("token_type", "access"):
|
145
|
+
_handle_exception("invalid_token_type", **kwargs)
|
146
|
+
return user_data
|
147
|
+
except USSOException as e:
|
148
|
+
exp = e
|
149
|
+
|
150
|
+
if kwargs.get("raise_exception", True):
|
151
|
+
if exp:
|
152
|
+
_handle_exception(exp.error, message=str(exp), **kwargs)
|
153
|
+
_handle_exception("unauthorized", **kwargs)
|
154
|
+
|
155
|
+
def user_data_from_api_key(self, api_key: str):
|
156
|
+
return fetch_api_key_data(self.jwt_configs[0].jwk_url, api_key)
|
@@ -1,8 +1,9 @@
|
|
1
1
|
error_messages = {
|
2
|
-
"invalid_signature": "
|
3
|
-
"invalid_token": "
|
4
|
-
"expired_signature": "
|
2
|
+
"invalid_signature": "Unauthorized. The JWT signature is invalid.",
|
3
|
+
"invalid_token": "Unauthorized. The JWT is invalid or not provided.",
|
4
|
+
"expired_signature": "Unauthorized. The JWT is expired.",
|
5
5
|
"unauthorized": "Unauthorized",
|
6
|
+
"invalid_token_type": "Unauthorized. Token type must be 'access'",
|
6
7
|
}
|
7
8
|
|
8
9
|
|
@@ -14,4 +15,3 @@ class USSOException(Exception):
|
|
14
15
|
if message is None:
|
15
16
|
self.message = error_messages[error]
|
16
17
|
super().__init__(message)
|
17
|
-
|
@@ -0,0 +1,67 @@
|
|
1
|
+
import uuid
|
2
|
+
|
3
|
+
import cachetools.func
|
4
|
+
from pydantic import BaseModel, model_validator
|
5
|
+
|
6
|
+
from . import b64tools
|
7
|
+
|
8
|
+
|
9
|
+
class UserData(BaseModel):
|
10
|
+
user_id: str
|
11
|
+
workspace_id: str | None = None
|
12
|
+
workspace_ids: list[str] = []
|
13
|
+
token_type: str = "access"
|
14
|
+
|
15
|
+
email: str | None = None
|
16
|
+
phone: str | None = None
|
17
|
+
username: str | None = None
|
18
|
+
|
19
|
+
authentication_method: str | None = None
|
20
|
+
is_active: bool = False
|
21
|
+
|
22
|
+
jti: str | None = None
|
23
|
+
data: dict | None = None
|
24
|
+
|
25
|
+
token: str | None = None
|
26
|
+
|
27
|
+
@property
|
28
|
+
def uid(self) -> uuid.UUID:
|
29
|
+
user_id = self.user_id
|
30
|
+
|
31
|
+
if user_id.startswith("u_"):
|
32
|
+
user_id = user_id[2:]
|
33
|
+
if 22 <= len(user_id) <= 24:
|
34
|
+
user_id = b64tools.b64_decode_uuid(user_id)
|
35
|
+
|
36
|
+
return uuid.UUID(user_id)
|
37
|
+
|
38
|
+
@property
|
39
|
+
def b64id(self) -> uuid.UUID:
|
40
|
+
return b64tools.b64_encode_uuid_strip(self.uid)
|
41
|
+
|
42
|
+
|
43
|
+
class JWTConfig(BaseModel):
|
44
|
+
"""Configuration for JWT processing."""
|
45
|
+
|
46
|
+
jwk_url: str | None = None
|
47
|
+
secret: str | None = None
|
48
|
+
algorithm: str = "RS256"
|
49
|
+
header: dict[str, str] = {"type": "Cookie", "name": "usso_access_token"}
|
50
|
+
|
51
|
+
def __hash__(self):
|
52
|
+
return hash(self.model_dump_json())
|
53
|
+
|
54
|
+
@model_validator(mode="before")
|
55
|
+
def validate_config(cls, data: dict):
|
56
|
+
if not data.get("jwk_url") and not data.get("secret"):
|
57
|
+
raise ValueError("Either jwk_url or secret must be provided")
|
58
|
+
return data
|
59
|
+
|
60
|
+
@cachetools.func.ttl_cache(maxsize=128, ttl=600)
|
61
|
+
def decode(self, token: str):
|
62
|
+
"""Decode a token using the configured method."""
|
63
|
+
from .core import decode_token, decode_token_with_jwk
|
64
|
+
|
65
|
+
if self.jwk_url:
|
66
|
+
return decode_token_with_jwk(self.jwk_url, token)
|
67
|
+
return decode_token(self.secret, token, algorithms=[self.algorithm])
|
@@ -1,5 +1,7 @@
|
|
1
1
|
import os
|
2
|
+
|
2
3
|
import httpx
|
4
|
+
|
3
5
|
from ..core import is_expired
|
4
6
|
from .base_session import BaseUssoSession
|
5
7
|
|
@@ -17,18 +19,16 @@ class AsyncUssoSession(httpx.AsyncClient, BaseUssoSession):
|
|
17
19
|
client: "AsyncUssoSession" | None = None,
|
18
20
|
):
|
19
21
|
httpx.AsyncClient.__init__(self)
|
20
|
-
|
21
|
-
self
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
user_id=user_id,
|
31
|
-
)
|
22
|
+
BaseUssoSession.__init__(
|
23
|
+
self,
|
24
|
+
usso_base_url=usso_base_url,
|
25
|
+
api_key=api_key,
|
26
|
+
usso_refresh_url=usso_refresh_url,
|
27
|
+
refresh_token=refresh_token,
|
28
|
+
usso_api_key=usso_api_key,
|
29
|
+
user_id=user_id,
|
30
|
+
client=client,
|
31
|
+
)
|
32
32
|
self._refresh_sync()
|
33
33
|
|
34
34
|
def _prepare_refresh_request(self) -> tuple[dict, dict]:
|
@@ -1,5 +1,6 @@
|
|
1
1
|
import os
|
2
2
|
from urllib.parse import urlparse
|
3
|
+
|
3
4
|
from usso.core import is_expired
|
4
5
|
|
5
6
|
|
@@ -13,7 +14,12 @@ class BaseUssoSession:
|
|
13
14
|
refresh_token: str | None = os.getenv("USSO_REFRESH_TOKEN"),
|
14
15
|
usso_api_key: str | None = os.getenv("USSO_ADMIN_API_KEY"),
|
15
16
|
user_id: str | None = None,
|
17
|
+
client: "BaseUssoSession" | None = None,
|
16
18
|
):
|
19
|
+
if client:
|
20
|
+
self.copy_attributes_from(client)
|
21
|
+
return
|
22
|
+
|
17
23
|
assert (
|
18
24
|
usso_base_url or usso_refresh_url
|
19
25
|
), "usso_base_url or usso_refresh_url is required"
|
@@ -1,12 +1,13 @@
|
|
1
1
|
import os
|
2
2
|
|
3
3
|
import httpx
|
4
|
+
|
4
5
|
from usso.core import is_expired
|
5
6
|
|
6
7
|
from .base_session import BaseUssoSession
|
7
8
|
|
8
9
|
|
9
|
-
class UssoSession(
|
10
|
+
class UssoSession(httpx.Client, BaseUssoSession):
|
10
11
|
|
11
12
|
def __init__(
|
12
13
|
self,
|
@@ -21,18 +22,16 @@ class UssoSession(BaseUssoSession, httpx.Client):
|
|
21
22
|
):
|
22
23
|
httpx.Client.__init__(self)
|
23
24
|
|
24
|
-
|
25
|
-
self
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
user_id=user_id,
|
35
|
-
)
|
25
|
+
BaseUssoSession.__init__(
|
26
|
+
self,
|
27
|
+
usso_base_url=usso_base_url,
|
28
|
+
api_key=api_key,
|
29
|
+
usso_refresh_url=usso_refresh_url,
|
30
|
+
refresh_token=refresh_token,
|
31
|
+
usso_api_key=usso_api_key,
|
32
|
+
user_id=user_id,
|
33
|
+
client=client,
|
34
|
+
)
|
36
35
|
self._refresh()
|
37
36
|
|
38
37
|
def _refresh_api(self):
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: usso
|
3
|
-
Version: 0.27.
|
3
|
+
Version: 0.27.13
|
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>
|
@@ -22,7 +22,4 @@ src/usso/fastapi/integration.py
|
|
22
22
|
src/usso/session/__init__.py
|
23
23
|
src/usso/session/async_session.py
|
24
24
|
src/usso/session/base_session.py
|
25
|
-
src/usso/session/session.py
|
26
|
-
tests/test_api.py
|
27
|
-
tests/test_core.py
|
28
|
-
tests/test_simple.py
|
25
|
+
src/usso/session/session.py
|
usso-0.27.11/src/usso/core.py
DELETED
@@ -1,233 +0,0 @@
|
|
1
|
-
import json
|
2
|
-
import logging
|
3
|
-
import os
|
4
|
-
from datetime import datetime, timedelta
|
5
|
-
from urllib.parse import urlparse
|
6
|
-
|
7
|
-
import cachetools.func
|
8
|
-
import jwt
|
9
|
-
import requests
|
10
|
-
from cachetools import TTLCache, cached
|
11
|
-
from pydantic import BaseModel, model_validator
|
12
|
-
|
13
|
-
from .exceptions import USSOException
|
14
|
-
from .schemas import UserData
|
15
|
-
|
16
|
-
logger = logging.getLogger("usso")
|
17
|
-
|
18
|
-
|
19
|
-
def get_authorization_scheme_param(
|
20
|
-
authorization_header_value: str | None,
|
21
|
-
) -> tuple[str, str]:
|
22
|
-
if not authorization_header_value:
|
23
|
-
return "", ""
|
24
|
-
scheme, _, param = authorization_header_value.partition(" ")
|
25
|
-
return scheme, param
|
26
|
-
|
27
|
-
|
28
|
-
def decode_token(key, token: str, algorithms=["RS256"], **kwargs) -> dict:
|
29
|
-
try:
|
30
|
-
decoded = jwt.decode(token, key, algorithms=algorithms)
|
31
|
-
decoded["data"] = decoded
|
32
|
-
decoded["token"] = token
|
33
|
-
return UserData(**decoded)
|
34
|
-
except jwt.exceptions.ExpiredSignatureError:
|
35
|
-
if kwargs.get("raise_exception", True):
|
36
|
-
raise USSOException(status_code=401, error="expired_signature")
|
37
|
-
except jwt.exceptions.InvalidSignatureError:
|
38
|
-
if kwargs.get("raise_exception", True):
|
39
|
-
raise USSOException(status_code=401, error="invalid_signature")
|
40
|
-
except jwt.exceptions.InvalidAlgorithmError:
|
41
|
-
if kwargs.get("raise_exception", True):
|
42
|
-
raise USSOException(status_code=401, error="invalid_algorithm")
|
43
|
-
except jwt.exceptions.InvalidIssuedAtError:
|
44
|
-
if kwargs.get("raise_exception", True):
|
45
|
-
raise USSOException(status_code=401, error="invalid_issued_at")
|
46
|
-
except jwt.exceptions.InvalidTokenError:
|
47
|
-
if kwargs.get("raise_exception", True):
|
48
|
-
raise USSOException(status_code=401, error="invalid_token")
|
49
|
-
except jwt.exceptions.InvalidKeyError:
|
50
|
-
if kwargs.get("raise_exception", True):
|
51
|
-
raise USSOException(status_code=401, error="invalid_key")
|
52
|
-
except USSOException as e:
|
53
|
-
if kwargs.get("raise_exception", True):
|
54
|
-
raise e
|
55
|
-
except Exception as e:
|
56
|
-
if kwargs.get("raise_exception", True):
|
57
|
-
raise USSOException(status_code=401, error="error", message=str(e))
|
58
|
-
logger.error(e)
|
59
|
-
|
60
|
-
|
61
|
-
def is_expired(token: str, **kwargs) -> bool:
|
62
|
-
now = datetime.now()
|
63
|
-
decoded_token: dict = jwt.decode(token, options={"verify_signature": False})
|
64
|
-
exp = decoded_token.get("exp", (now + timedelta(days=1)).timestamp())
|
65
|
-
exp = datetime.fromtimestamp(exp)
|
66
|
-
return exp < now
|
67
|
-
|
68
|
-
|
69
|
-
@cached(TTLCache(maxsize=128, ttl=10 * 60))
|
70
|
-
def get_jwk_keys(jwk_url: str) -> jwt.PyJWKClient:
|
71
|
-
return jwt.PyJWKClient(jwk_url, headers={"User-Agent": "usso-python"})
|
72
|
-
|
73
|
-
|
74
|
-
def decode_token_jwk(jwk_url: str, token: str, **kwargs) -> UserData | None:
|
75
|
-
"""Return the user associated with a token value."""
|
76
|
-
try:
|
77
|
-
jwk_client = get_jwk_keys(jwk_url)
|
78
|
-
signing_key = jwk_client.get_signing_key_from_jwt(token)
|
79
|
-
return decode_token(signing_key.key, token, **kwargs)
|
80
|
-
except USSOException as e:
|
81
|
-
if kwargs.get("raise_exception", True):
|
82
|
-
raise e
|
83
|
-
logger.error(e)
|
84
|
-
except Exception as e:
|
85
|
-
if kwargs.get("raise_exception", True):
|
86
|
-
raise USSOException(
|
87
|
-
status_code=401,
|
88
|
-
error="error",
|
89
|
-
message=str(e),
|
90
|
-
)
|
91
|
-
logger.error(e)
|
92
|
-
|
93
|
-
|
94
|
-
@cached(TTLCache(maxsize=128, ttl=10 * 60))
|
95
|
-
def get_api_key_data(jwk_url: str, api_key: str):
|
96
|
-
parsed = urlparse(jwk_url)
|
97
|
-
url = f"{parsed.scheme}://{parsed.netloc}/api_key/verify"
|
98
|
-
response = requests.post(url, json={"api_key": api_key})
|
99
|
-
response.raise_for_status()
|
100
|
-
return UserData(**response.json())
|
101
|
-
|
102
|
-
|
103
|
-
class JWTConfig(BaseModel):
|
104
|
-
jwk_url: str | None = None
|
105
|
-
secret: str | None = None
|
106
|
-
type: str = "RS256"
|
107
|
-
header: dict[str, str] = {"type": "Cookie", "name": "usso_access_token"}
|
108
|
-
|
109
|
-
def __hash__(self):
|
110
|
-
return hash(self.model_dump_json())
|
111
|
-
|
112
|
-
@model_validator(mode="before")
|
113
|
-
def validate_secret(cls, data: dict):
|
114
|
-
if not data.get("jwk_url") and not data.get("secret"):
|
115
|
-
raise ValueError("Either jwk_url or secret must be provided")
|
116
|
-
return data
|
117
|
-
|
118
|
-
@classmethod
|
119
|
-
@cachetools.func.ttl_cache(maxsize=128, ttl=10 * 60)
|
120
|
-
def get_jwk_keys(cls, jwk_url):
|
121
|
-
return get_jwk_keys(jwk_url)
|
122
|
-
|
123
|
-
@cachetools.func.ttl_cache(maxsize=128, ttl=10 * 60)
|
124
|
-
def decode(self, token: str):
|
125
|
-
if self.jwk_url:
|
126
|
-
return decode_token_jwk(self.jwk_url, token)
|
127
|
-
return decode_token(self.secret, token, algorithms=[self.type])
|
128
|
-
|
129
|
-
|
130
|
-
class Usso:
|
131
|
-
|
132
|
-
def __init__(
|
133
|
-
self,
|
134
|
-
*,
|
135
|
-
jwt_config: (
|
136
|
-
str | dict | JWTConfig | list[str] | list[dict] | list[JWTConfig] | None
|
137
|
-
) = None,
|
138
|
-
jwk_url: str | None = None,
|
139
|
-
secret: str | None = None,
|
140
|
-
):
|
141
|
-
if jwt_config is None:
|
142
|
-
jwt_config = os.getenv("USSO_JWT_CONFIG")
|
143
|
-
|
144
|
-
if jwt_config is None:
|
145
|
-
if not jwk_url:
|
146
|
-
jwk_url = os.getenv("USSO_JWK_URL") or os.getenv("USSO_JWKS_URL")
|
147
|
-
if jwk_url:
|
148
|
-
self.jwt_configs = [JWTConfig(jwk_url=jwk_url)]
|
149
|
-
return
|
150
|
-
|
151
|
-
if not secret:
|
152
|
-
secret = os.getenv("USSO_SECRET")
|
153
|
-
if secret:
|
154
|
-
self.jwt_configs = [JWTConfig(secret=secret)]
|
155
|
-
return
|
156
|
-
|
157
|
-
raise ValueError(
|
158
|
-
"\n".join(
|
159
|
-
[
|
160
|
-
"jwt_config or jwk_url or secret must be provided",
|
161
|
-
"or set the environment variable USSO_JWT_CONFIG or USSO_JWK_URL or USSO_SECRET",
|
162
|
-
]
|
163
|
-
)
|
164
|
-
)
|
165
|
-
|
166
|
-
def _get_config(jwt_config):
|
167
|
-
if isinstance(jwt_config, str):
|
168
|
-
jwt_config = json.loads(jwt_config)
|
169
|
-
if isinstance(jwt_config, dict):
|
170
|
-
jwt_config = JWTConfig(**jwt_config)
|
171
|
-
return jwt_config
|
172
|
-
|
173
|
-
if isinstance(jwt_config, str | dict | JWTConfig):
|
174
|
-
jwt_config = [_get_config(jwt_config)]
|
175
|
-
elif isinstance(jwt_config, list):
|
176
|
-
jwt_config = [_get_config(j) for j in jwt_config]
|
177
|
-
|
178
|
-
# self.jwk_url = jwt_config
|
179
|
-
self.jwt_configs = jwt_config
|
180
|
-
|
181
|
-
def user_data_from_token(self, token: str, **kwargs) -> UserData | None:
|
182
|
-
"""Return the user associated with a token value."""
|
183
|
-
exp = None
|
184
|
-
for jwk_config in self.jwt_configs:
|
185
|
-
try:
|
186
|
-
user_data = jwk_config.decode(token)
|
187
|
-
if user_data.token_type.lower() != kwargs.get("token_type", "access"):
|
188
|
-
raise USSOException(
|
189
|
-
status_code=401,
|
190
|
-
error="invalid_token_type",
|
191
|
-
message="Token type must be 'access'",
|
192
|
-
)
|
193
|
-
|
194
|
-
return user_data
|
195
|
-
|
196
|
-
except USSOException as e:
|
197
|
-
exp = e
|
198
|
-
|
199
|
-
if kwargs.get("raise_exception", True):
|
200
|
-
if exp:
|
201
|
-
raise exp
|
202
|
-
raise USSOException(
|
203
|
-
status_code=401,
|
204
|
-
error="unauthorized",
|
205
|
-
)
|
206
|
-
|
207
|
-
def user_data_api_key(self, api_key: str, **kwargs) -> UserData | None:
|
208
|
-
"""get user data from auth server by api_key."""
|
209
|
-
for jwk_config in self.jwt_configs:
|
210
|
-
try:
|
211
|
-
user_data = jwk_config.decode(api_key)
|
212
|
-
if user_data.token_type.lower() != kwargs.get("token_type", "access"):
|
213
|
-
raise USSOException(
|
214
|
-
status_code=401,
|
215
|
-
error="invalid_token_type",
|
216
|
-
message="Token type must be 'access'",
|
217
|
-
)
|
218
|
-
|
219
|
-
return user_data
|
220
|
-
|
221
|
-
except USSOException as e:
|
222
|
-
exp = e
|
223
|
-
|
224
|
-
if kwargs.get("raise_exception", True):
|
225
|
-
if exp:
|
226
|
-
raise exp
|
227
|
-
raise USSOException(
|
228
|
-
status_code=401,
|
229
|
-
error="unauthorized",
|
230
|
-
)
|
231
|
-
|
232
|
-
def user_data_from_api_key(self, api_key: str):
|
233
|
-
return get_api_key_data(self.jwt_configs[0].jwk_url, api_key)
|
usso-0.27.11/src/usso/schemas.py
DELETED
@@ -1,39 +0,0 @@
|
|
1
|
-
import uuid
|
2
|
-
|
3
|
-
from pydantic import BaseModel
|
4
|
-
|
5
|
-
from . import b64tools
|
6
|
-
|
7
|
-
|
8
|
-
class UserData(BaseModel):
|
9
|
-
user_id: str
|
10
|
-
workspace_id: str | None = None
|
11
|
-
workspace_ids: list[str] = []
|
12
|
-
token_type: str = "access"
|
13
|
-
|
14
|
-
email: str | None = None
|
15
|
-
phone: str | None = None
|
16
|
-
username: str | None = None
|
17
|
-
|
18
|
-
authentication_method: str | None = None
|
19
|
-
is_active: bool = False
|
20
|
-
|
21
|
-
jti: str | None = None
|
22
|
-
data: dict | None = None
|
23
|
-
|
24
|
-
token: str | None = None
|
25
|
-
|
26
|
-
@property
|
27
|
-
def uid(self) -> uuid.UUID:
|
28
|
-
user_id = self.user_id
|
29
|
-
|
30
|
-
if user_id.startswith("u_"):
|
31
|
-
user_id = user_id[2:]
|
32
|
-
if 22 <= len(user_id) <= 24:
|
33
|
-
user_id = b64tools.b64_decode_uuid(user_id)
|
34
|
-
|
35
|
-
return uuid.UUID(user_id)
|
36
|
-
|
37
|
-
@property
|
38
|
-
def b64id(self) -> uuid.UUID:
|
39
|
-
return b64tools.b64_encode_uuid_strip(self.uid)
|
usso-0.27.11/tests/test_api.py
DELETED
@@ -1,61 +0,0 @@
|
|
1
|
-
import os
|
2
|
-
import unittest
|
3
|
-
|
4
|
-
from usso.api import UssoAPI
|
5
|
-
from usso.core import UserData
|
6
|
-
|
7
|
-
|
8
|
-
class TestAPI(unittest.TestCase):
|
9
|
-
def get_usso(self):
|
10
|
-
return UssoAPI(
|
11
|
-
url="https://sso.usso.io",
|
12
|
-
api_key=os.getenv("USSO_API_KEY"),
|
13
|
-
)
|
14
|
-
|
15
|
-
def test_get_users(self):
|
16
|
-
usso_api = self.get_usso()
|
17
|
-
users = usso_api.get_users()
|
18
|
-
self.assertIsInstance(users, list)
|
19
|
-
for user in users:
|
20
|
-
self.assertIsInstance(user, UserData)
|
21
|
-
|
22
|
-
def test_get_user(self):
|
23
|
-
usso_api = self.get_usso()
|
24
|
-
users = usso_api.get_users()
|
25
|
-
if len(users) == 0:
|
26
|
-
self.skipTest("No users found")
|
27
|
-
user = users[0]
|
28
|
-
usso_api = self.get_usso()
|
29
|
-
user = usso_api.get_user(user.user_id)
|
30
|
-
self.assertIsInstance(user, UserData)
|
31
|
-
|
32
|
-
def test_get_user_by_credentials(self):
|
33
|
-
usso_api = self.get_usso()
|
34
|
-
users = usso_api._request(endpoint="website/users")
|
35
|
-
if len(users) == 0:
|
36
|
-
self.skipTest("No users found")
|
37
|
-
for user in users:
|
38
|
-
for auth in user["authenticators"]:
|
39
|
-
cred = {
|
40
|
-
"auth_method": auth["auth_method"],
|
41
|
-
"representor": auth["representor"],
|
42
|
-
}
|
43
|
-
user = usso_api.get_user_by_credentials(cred)
|
44
|
-
self.assertIsInstance(user, UserData)
|
45
|
-
|
46
|
-
def test_create_user_by_credentials(self):
|
47
|
-
import requests
|
48
|
-
|
49
|
-
usso_api = self.get_usso()
|
50
|
-
telegram_id = os.getenv("TELEGRAM_ID")
|
51
|
-
cred = {"auth_method": "telegram", "representor": telegram_id}
|
52
|
-
try:
|
53
|
-
usso_api.create_user_by_credentials(credentials=cred)
|
54
|
-
except requests.HTTPError as e:
|
55
|
-
if e.response.status_code == 400:
|
56
|
-
if e.response.json().get("error") == "already_exists":
|
57
|
-
self.skipTest("Credential already exists")
|
58
|
-
|
59
|
-
|
60
|
-
if __name__ == "__main__":
|
61
|
-
unittest.main()
|
usso-0.27.11/tests/test_core.py
DELETED
@@ -1,81 +0,0 @@
|
|
1
|
-
import unittest
|
2
|
-
import uuid
|
3
|
-
|
4
|
-
from usso.core import Usso
|
5
|
-
from usso.exceptions import USSOException
|
6
|
-
|
7
|
-
|
8
|
-
def generate_expired_token():
|
9
|
-
# Your code to generate an expired token goes here
|
10
|
-
pass
|
11
|
-
|
12
|
-
|
13
|
-
# Generate an invalid token for testing
|
14
|
-
def generate_invalid_token():
|
15
|
-
# Your code to generate an invalid token goes here
|
16
|
-
pass
|
17
|
-
|
18
|
-
|
19
|
-
# Generate a valid token for testing
|
20
|
-
def generate_valid_token():
|
21
|
-
# Your code to generate a valid token goes here
|
22
|
-
pass
|
23
|
-
|
24
|
-
|
25
|
-
class TestCore(unittest.TestCase):
|
26
|
-
def test_user_data_from_token_valid_token(self):
|
27
|
-
return
|
28
|
-
# Generate a valid token for testing
|
29
|
-
valid_token = generate_valid_token()
|
30
|
-
|
31
|
-
# Call the user_data_from_token function with the valid token
|
32
|
-
user_data = Usso().user_data_from_token(valid_token)
|
33
|
-
|
34
|
-
# Assert that the user_data is not None
|
35
|
-
self.assertIsNotNone(user_data)
|
36
|
-
|
37
|
-
# Assert that the user_data has the expected attributes
|
38
|
-
self.assertEqual(user_data.uid, uuid.UUID(""))
|
39
|
-
self.assertEqual(user_data.token, valid_token)
|
40
|
-
# Add more assertions for other attributes
|
41
|
-
# Generate an expired token for testing
|
42
|
-
|
43
|
-
def test_user_data_from_token_expired_token(self):
|
44
|
-
return
|
45
|
-
|
46
|
-
# Generate an expired token for testing
|
47
|
-
expired_token = generate_expired_token()
|
48
|
-
|
49
|
-
# Call the user_data_from_token function with the expired token
|
50
|
-
user_data = Usso().user_data_from_token(expired_token)
|
51
|
-
|
52
|
-
# Assert that the user_data is None
|
53
|
-
self.assertIsNone(user_data)
|
54
|
-
|
55
|
-
# Assert that the USSOException is raised with the expected error
|
56
|
-
with self.assertRaises(USSOException) as context:
|
57
|
-
Usso().user_data_from_token(expired_token, raise_exception=True)
|
58
|
-
self.assertEqual(context.exception.error, "expired_signature")
|
59
|
-
|
60
|
-
def test_user_data_from_token_invalid_token(self):
|
61
|
-
return
|
62
|
-
|
63
|
-
# Generate an invalid token for testing
|
64
|
-
invalid_token = generate_invalid_token()
|
65
|
-
|
66
|
-
# Call the user_data_from_token function with the invalid token
|
67
|
-
user_data = Usso().user_data_from_token(invalid_token)
|
68
|
-
|
69
|
-
# Assert that the user_data is None
|
70
|
-
self.assertIsNone(user_data)
|
71
|
-
|
72
|
-
# Assert that the USSOException is raised with the expected error
|
73
|
-
with self.assertRaises(USSOException) as context:
|
74
|
-
Usso().user_data_from_token(invalid_token, raise_exception=True)
|
75
|
-
self.assertEqual(context.exception.error, "invalid_signature")
|
76
|
-
|
77
|
-
# Add more test cases for other scenarios
|
78
|
-
|
79
|
-
|
80
|
-
if __name__ == "__main__":
|
81
|
-
unittest.main()
|
@@ -1,17 +0,0 @@
|
|
1
|
-
# the inclusion of the tests module is not meant to offer best practices for
|
2
|
-
# testing in general, but rather to support the `find_packages` example in
|
3
|
-
# setup.py that excludes installing the "tests" package
|
4
|
-
|
5
|
-
import unittest
|
6
|
-
|
7
|
-
|
8
|
-
class TestSimple(unittest.TestCase):
|
9
|
-
def test_import(self):
|
10
|
-
import usso
|
11
|
-
|
12
|
-
usso.Usso()
|
13
|
-
usso.UserData(user_id="123")
|
14
|
-
|
15
|
-
|
16
|
-
if __name__ == "__main__":
|
17
|
-
unittest.main()
|
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
|
File without changes
|
File without changes
|