Skip to content

Commit 62f5674

Browse files
authored
feat: Support partial list matching in JWTClaimRegistry.validate, similar to aud claim logic (#63)
* test: add validation tests for custom claims in JWTClaimsRegistry (`list_inclusion` and `allow_blank`) * feat: improve claim value validation logic in ClaimsRegistry to cover list inclusion validation as well * docs: add list validation section to JWT claims registry guide
1 parent a9a9db1 commit 62f5674

File tree

3 files changed

+118
-8
lines changed

3 files changed

+118
-8
lines changed

docs/guide/jwt.rst

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,47 @@ The ``JWTClaimsRegistry`` has built-in validators for timing related fields:
143143
- ``nbf``: not before
144144
- ``iat``: issued at
145145

146+
List validation
147+
~~~~~~~~~~~~~~~
148+
149+
When validating claims that contain lists, the registry checks if **any** of the
150+
required values are present in the claim's list. This behavior is designed for
151+
flexible authorization checks where matching any of the required permissions grants
152+
access. For single values, it checks for an exact match.
153+
154+
This is particularly useful for validating role based or permission based claims. For
155+
example:
156+
157+
.. code-block:: python
158+
159+
# Claim containing a list of permissions
160+
claims = {"permissions": ["users:read", "users:write", "users:admin"]}
161+
162+
# Passes since "users:write" is present in the list
163+
claims_requests = JWTClaimsRegistry(
164+
permissions={"values": ["users:write", "system:admin"]}
165+
)
166+
claims_requests.validate(claims)
167+
168+
# Raises InvalidClaimError since none of the required values are present
169+
claims_requests = JWTClaimsRegistry(
170+
permissions={"values": ["system:admin"]}
171+
)
172+
claims_requests.validate(claims)
173+
174+
You can also validate against a single required value:
175+
176+
.. code-block:: python
177+
178+
# Claim containing a list of permissions
179+
claims = {"permissions": ["users:read", "users:write", "users:admin"]}
180+
181+
# Passes since "users:read" is present in the list
182+
claims_requests = JWTClaimsRegistry(
183+
permissions={"value": "users:read"}
184+
)
185+
claims_requests.validate(claims)
186+
146187
JWS & JWE
147188
---------
148189

src/joserfc/_rfc7519/registry.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,28 @@ def __init__(self, **kwargs: ClaimsOption):
2626

2727
def check_value(self, claim_name: str, value: Any) -> None:
2828
option = self.options.get(claim_name)
29-
if option:
30-
allow_blank = option.get("allow_blank")
31-
if not allow_blank and value == "":
32-
raise InvalidClaimError(claim_name)
29+
if not option:
30+
return
31+
32+
allow_blank = option.get("allow_blank")
33+
if not allow_blank and value in (None, "", [], {}):
34+
raise InvalidClaimError(claim_name)
3335

36+
option_values = option.get("values")
37+
38+
if option_values is None:
3439
option_value = option.get("value")
35-
if option_value is not None and value != option_value:
36-
raise InvalidClaimError(claim_name)
40+
if option_value is not None:
41+
option_values = [option_value]
3742

38-
option_values = option.get("values")
39-
if option_values is not None and value not in option_values:
43+
if not option_values:
44+
return
45+
46+
if isinstance(value, list):
47+
if not any(v in value for v in option_values):
48+
raise InvalidClaimError(claim_name)
49+
else:
50+
if value not in option_values:
4051
raise InvalidClaimError(claim_name)
4152

4253
def validate(self, claims: dict[str, Any]) -> None:

tests/jwt/test_claims.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,61 @@ def test_claims_with_uuid_field(self):
144144
encoded_text = jwt.encode({"alg": "HS256"}, claims, key, encoder_cls=UUIDEncoder)
145145
token = jwt.decode(encoded_text, key)
146146
self.assertEqual(token.claims, {"uuid": str(value)})
147+
148+
def test_validate_list_inclusion(self):
149+
# Case 1: use option value
150+
claims_requests = jwt.JWTClaimsRegistry(custom_claim={"essential": True, "value": "a"})
151+
self.assertRaises(MissingClaimError, claims_requests.validate, {"iss": "a"})
152+
self.assertRaises(InvalidClaimError, claims_requests.validate, {"custom_claim": "b"})
153+
self.assertRaises(InvalidClaimError, claims_requests.validate, {"custom_claim": ["b"]})
154+
claims_requests.validate({"custom_claim": "a"})
155+
claims_requests.validate({"custom_claim": ["a"]})
156+
157+
# Case 2: use option values
158+
claims_requests = jwt.JWTClaimsRegistry(custom_claim={"essential": True, "values": ["a", "b"]})
159+
self.assertRaises(MissingClaimError, claims_requests.validate, {"iss": "a"})
160+
self.assertRaises(InvalidClaimError, claims_requests.validate, {"custom_claim": "c"})
161+
self.assertRaises(InvalidClaimError, claims_requests.validate, {"custom_claim": ["c"]})
162+
claims_requests.validate({"custom_claim": "a"})
163+
claims_requests.validate({"custom_claim": "b"})
164+
claims_requests.validate({"custom_claim": ["a"]})
165+
claims_requests.validate({"custom_claim": ["b"]})
166+
claims_requests.validate({"custom_claim": ["a", "b"]})
167+
claims_requests.validate({"custom_claim": ["c", "a"]})
168+
169+
# Case 3: do not validate
170+
claims_requests = jwt.JWTClaimsRegistry()
171+
claims_requests.validate({"custom_claim": "a"})
172+
173+
# Case 4: essential claim without value(s)
174+
claims_requests = jwt.JWTClaimsRegistry(custom_claim={"essential": True})
175+
claims_requests.validate({"custom_claim": "a"})
176+
177+
def test_validate_allow_blank(self):
178+
# Case 1: allow blank value
179+
claims_requests = jwt.JWTClaimsRegistry(custom_claim={"essential": True, "allow_blank": True})
180+
self.assertRaises(MissingClaimError, claims_requests.validate, {"custom_claim": None})
181+
claims_requests.validate({"custom_claim": ""})
182+
claims_requests.validate({"custom_claim": []})
183+
claims_requests.validate({"custom_claim": {}})
184+
185+
# Case 2: allow blank value without essential
186+
claims_requests = jwt.JWTClaimsRegistry(custom_claim={"allow_blank": True})
187+
claims_requests.validate({"custom_claim": None})
188+
claims_requests.validate({"custom_claim": ""})
189+
claims_requests.validate({"custom_claim": []})
190+
claims_requests.validate({"custom_claim": {}})
191+
192+
# Case 3: do not allow blank value
193+
claims_requests = jwt.JWTClaimsRegistry(custom_claim={"essential": True, "allow_blank": False})
194+
self.assertRaises(MissingClaimError, claims_requests.validate, {"custom_claim": None})
195+
self.assertRaises(InvalidClaimError, claims_requests.validate, {"custom_claim": ""})
196+
self.assertRaises(InvalidClaimError, claims_requests.validate, {"custom_claim": []})
197+
self.assertRaises(InvalidClaimError, claims_requests.validate, {"custom_claim": {}})
198+
199+
# Case 4: do not allow blank value without essential
200+
claims_requests = jwt.JWTClaimsRegistry(custom_claim={"allow_blank": False})
201+
self.assertRaises(InvalidClaimError, claims_requests.validate, {"custom_claim": None})
202+
self.assertRaises(InvalidClaimError, claims_requests.validate, {"custom_claim": ""})
203+
self.assertRaises(InvalidClaimError, claims_requests.validate, {"custom_claim": []})
204+
self.assertRaises(InvalidClaimError, claims_requests.validate, {"custom_claim": {}})

0 commit comments

Comments
 (0)