Skip to content

Commit 51a1931

Browse files
Start improvements
1 parent 8fdf2e6 commit 51a1931

4 files changed

Lines changed: 196 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,29 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.0.3] - 2025-10-02
9+
10+
- Add a `roles` property to the `Identity` object and built-in classes for
11+
role based authorization.
12+
- Add support for validating JWTs signed using symmetric encryption.
13+
814
## [1.0.2] - 2023-06-16 :corn:
15+
916
- Raises a more specific exception `ForbiddenError` when the user of an
1017
operation is authenticated properly, but authorization fails.
1118
This enables better handling of authorization error, differentiating when the
1219
user context is missing or invalid, and when the context is valid but the
1320
user has no rights to do a certain operation. See [#371](https://github.com/Neoteroi/BlackSheep/issues/371).
1421

1522
## [1.0.1] - 2023-03-20 :sun_with_face:
23+
1624
- Improves the automatic rotation of `JWKS`: when validating `JWTs`, `JWKS` are
1725
refreshed automatically if an unknown `kid` is encountered, and `JWKS` were
1826
last fetched more than `refresh_time` seconds ago (by default 120 seconds).
1927
- Corrects an inconsistency in how `claims` are read in the `User` class.
2028

2129
## [1.0.0] - 2023-01-07 :star:
30+
2231
- Adds built-in support for dependency injection, using the new `ContainerProtocol`
2332
in `rodi` v2.
2433
- Removes the synchronous code API, maintaining only the asynchronous code API
@@ -29,24 +38,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2938
- Corrects `Identity.__getitem__` to raise `KeyError` if a claim is missing.
3039

3140
## [0.1.0] - 2022-11-06 :snake:
41+
3242
- Workflow maintenance.
3343

3444
## [0.0.9] - 2021-11-14 :swan:
45+
3546
- Adds `sub`, `access_token`, and `refresh_token` properties to the `Identity`.
3647
class
3748
- Adds `py.typed` file.
3849

3950
## [0.0.8] - 2021-10-31 :shield:
51+
4052
- Adds classes to handle `JWT`s validation, but only for `RSA` keys.
4153
- Fixes issue (wrong arrangement in test) #5.
4254
- Includes `Python 3.10` in the CI/CD matrix.
4355
- Enforces `black` and `isort` in the CI pipeline.
4456

4557
## [0.0.7] - 2021-01-31 :grapes:
58+
4659
- Corrects a bug in the `Policy` class (#2).
4760
- Changes the type annotation of `Identity` claims (#3).
4861

4962
## [0.0.6] - 2020-12-12 :octocat:
63+
5064
- Completely migrates to GitHub Workflows.
5165
- Improves build to test Python 3.6 and 3.9.
5266
- Adds a changelog.

guardpost/authentication.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ def __init__(
2828
def sub(self) -> Optional[str]:
2929
return self.get("sub")
3030

31+
@property
32+
def roles(self) -> Optional[str]:
33+
return self.get("roles")
34+
3135
def is_authenticated(self) -> bool:
3236
return bool(self.authentication_mode)
3337

@@ -43,6 +47,11 @@ def has_claim(self, name: str) -> bool:
4347
def has_claim_value(self, name: str, value: str) -> bool:
4448
return self.claims.get(name) == value
4549

50+
def has_role(self, name: str) -> bool:
51+
if not self.roles:
52+
return False
53+
return name in self.roles
54+
4655

4756
class User(Identity):
4857
@property

guardpost/common.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from collections.abc import Mapping
2-
from typing import Mapping as MappingType
2+
from typing import Mapping as MappingType, Optional
33
from typing import Sequence, Union
44

55
from .authorization import AuthorizationContext, Policy, Requirement
@@ -64,3 +64,34 @@ def handle(self, context: AuthorizationContext):
6464
else:
6565
if all(identity.has_claim(name) for name in self.required_claims):
6666
context.succeed(self)
67+
68+
69+
class RolesRequirement(Requirement):
70+
"""
71+
Requires an identity with certain roles.
72+
"""
73+
74+
__slots__ = ("_required_roles", "_sufficient_roles")
75+
76+
def __init__(
77+
self,
78+
required_roles: Optional[Sequence[str]] = None,
79+
sufficient_roles: Optional[Sequence[str]] = None,
80+
):
81+
self._required_roles = list(required_roles or [])
82+
self._sufficient_roles = list(sufficient_roles or [])
83+
84+
def handle(self, context: AuthorizationContext):
85+
identity = context.identity
86+
87+
if not identity:
88+
context.fail("Missing identity")
89+
return
90+
91+
if self._required_roles:
92+
if all(identity.has_role(name) for name in self._required_roles):
93+
context.succeed(self)
94+
95+
if self._sufficient_roles:
96+
if any(identity.has_role(name) for name in self._required_roles):
97+
context.succeed(self)

guardpost/jwts/__init__.py

Lines changed: 141 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, Dict, Optional, Sequence
1+
from typing import Any, Dict, Optional, Sequence, List, Union, Protocol
22

33
import jwt
44
from jwt.exceptions import InvalidIssuerError, InvalidTokenError
@@ -33,7 +33,29 @@ def get_kid(token: str) -> Optional[str]:
3333
return headers.get("kid")
3434

3535

36-
class JWTValidator:
36+
class JWTValidatorProtocol(Protocol):
37+
"""Protocol defining the interface for JWT validators"""
38+
39+
async def validate_jwt(self, access_token: str) -> Dict[str, Any]: ...
40+
41+
42+
class AbstractJWTValidator:
43+
"""Base class for JWT validators with common functionality"""
44+
45+
def __init__(
46+
self,
47+
*,
48+
valid_issuers: Sequence[str],
49+
valid_audiences: Sequence[str],
50+
algorithms: Sequence[str],
51+
) -> None:
52+
self._valid_issuers = list(valid_issuers)
53+
self._valid_audiences = list(valid_audiences)
54+
self._algorithms = list(algorithms)
55+
self.logger = get_logger()
56+
57+
58+
class AsymmetricJWTValidator(AbstractJWTValidator):
3759
def __init__(
3860
self,
3961
*,
@@ -48,7 +70,7 @@ def __init__(
4870
refresh_time: float = 120,
4971
) -> None:
5072
"""
51-
Creates a new instance of JWTValidator. This class only supports validating
73+
Creates a new instance of AsymmetricJWTValidator. This class supports validating
5274
access tokens signed using asymmetric keys and handling JWKs of RSA type.
5375
5476
Parameters
@@ -83,6 +105,12 @@ def __init__(
83105
JWKS were last fetched more than `refresh_time` seconds ago (by default
84106
120 seconds)
85107
"""
108+
super().__init__(
109+
valid_issuers=valid_issuers,
110+
valid_audiences=valid_audiences,
111+
algorithms=algorithms,
112+
)
113+
86114
if keys_provider:
87115
pass
88116
elif authority:
@@ -96,14 +124,10 @@ def __init__(
96124
"`authority`, or `keys_provider`."
97125
)
98126

99-
keys_provider = CachingKeysProvider(keys_provider, cache_time, refresh_time)
100-
101-
self._valid_issuers = list(valid_issuers)
102-
self._valid_audiences = list(valid_audiences)
103-
self._algorithms = list(algorithms)
104-
self._keys_provider = keys_provider
127+
self._keys_provider = CachingKeysProvider(
128+
keys_provider, cache_time, refresh_time
129+
)
105130
self.require_kid = require_kid
106-
self.logger = get_logger()
107131

108132
async def get_jwks(self) -> JWKS:
109133
return await self._keys_provider.get_keys()
@@ -170,3 +194,110 @@ async def validate_jwt(self, access_token: str) -> Dict[str, Any]:
170194
return data
171195

172196
raise InvalidAccessToken()
197+
198+
199+
class SymmetricJWTValidator(AbstractJWTValidator):
200+
def __init__(
201+
self,
202+
*,
203+
valid_issuers: Sequence[str],
204+
valid_audiences: Sequence[str],
205+
secret_key: Union[str, bytes],
206+
algorithms: Sequence[str] = ["HS256"],
207+
) -> None:
208+
"""
209+
Creates a new instance of SymmetricJWTValidator. This class supports validating
210+
access tokens signed using symmetric keys (HMAC).
211+
212+
Parameters
213+
----------
214+
valid_issuers : Sequence[str]
215+
Sequence of acceptable issuers (iss).
216+
valid_audiences : Sequence[str]
217+
Sequence of acceptable audiences (aud).
218+
secret_key : Union[str, bytes]
219+
The secret key used for symmetric validation.
220+
algorithms : Sequence[str], optional
221+
Sequence of acceptable algorithms, by default ["HS256"].
222+
Supported algorithms: HS256, HS384, HS512
223+
"""
224+
super().__init__(
225+
valid_issuers=valid_issuers,
226+
valid_audiences=valid_audiences,
227+
algorithms=algorithms,
228+
)
229+
230+
supported_algorithms = ["HS256", "HS384", "HS512"]
231+
for algorithm in algorithms:
232+
if algorithm not in supported_algorithms:
233+
raise ValueError(
234+
f"Algorithm '{algorithm}' is not supported for symmetric validation. "
235+
f"Use one of: {', '.join(supported_algorithms)}"
236+
)
237+
238+
self._secret_key = secret_key
239+
240+
async def validate_jwt(self, access_token: str) -> Dict[str, Any]:
241+
"""
242+
Validates the given JWT using symmetric key and returns its payload.
243+
This method throws exception if the JWT is not valid.
244+
"""
245+
for issuer in self._valid_issuers:
246+
try:
247+
return jwt.decode(
248+
access_token,
249+
self._secret_key,
250+
verify=True,
251+
algorithms=self._algorithms,
252+
audience=self._valid_audiences,
253+
issuer=issuer,
254+
)
255+
except InvalidIssuerError:
256+
# Try the next issuer
257+
pass
258+
except InvalidTokenError as exc:
259+
self.logger.debug("Invalid access token: ", exc_info=exc)
260+
261+
# If we've tried all issuers and none worked
262+
raise InvalidAccessToken()
263+
264+
265+
class CompositeJWTValidator(AbstractJWTValidator):
266+
def __init__(self, validators: List[JWTValidatorProtocol]) -> None:
267+
"""
268+
Creates a composite validator that tries multiple validation strategies.
269+
Useful when you need to support both symmetric and asymmetric validation.
270+
271+
Parameters
272+
----------
273+
validators : List[JWTValidatorProtocol]
274+
List of validators to try in sequence
275+
"""
276+
self._validators = validators
277+
self.logger = get_logger()
278+
279+
async def validate_jwt(self, access_token: str) -> Dict[str, Any]:
280+
"""
281+
Attempts to validate the JWT using each validator in sequence.
282+
Returns the first successful validation result or raises InvalidAccessToken
283+
if all validators fail.
284+
"""
285+
exceptions = []
286+
287+
for validator in self._validators:
288+
try:
289+
return await validator.validate_jwt(access_token)
290+
except InvalidAccessToken as exc:
291+
exceptions.append(exc)
292+
# Continue to the next validator
293+
294+
# If we get here, all validators failed
295+
if exceptions:
296+
self.logger.debug(f"All validators failed: {exceptions}")
297+
raise InvalidAccessToken(
298+
"Token validation failed with all configured validators"
299+
)
300+
301+
302+
# For backward compatibility, keep the original name
303+
JWTValidator = AsymmetricJWTValidator

0 commit comments

Comments
 (0)