Skip to content

Commit 98b9b24

Browse files
authored
feat: decode token with all keys, added get client certificate info method (#704)
* fix: do not throw when processing signed userinfo response * chore: remove custom pyproject commit * fix: add all keys for decoding the token * feat: new methods for certificate key info * chore: lint fix * fix: use async * test: updated regex
1 parent f5333a0 commit 98b9b24

8 files changed

Lines changed: 441 additions & 344 deletions

File tree

poetry.lock

Lines changed: 302 additions & 327 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/keycloak/authorization/permission.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ def type(self) -> str:
118118
"""
119119
return self._type
120120

121-
@type.setter
121+
@type.setter # noqa: A003
122122
def type(self, value: str) -> None:
123123
self._type = value
124124

src/keycloak/authorization/policy.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ def type(self) -> str:
115115
"""
116116
return self._type
117117

118-
@type.setter
118+
@type.setter # noqa: A003
119119
def type(self, value: str) -> None:
120120
self._type = value
121121

src/keycloak/keycloak_admin.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7518,6 +7518,35 @@ def get_role_client_level_children(self, client_id: str, role_id: str) -> list:
75187518

75197519
return res
75207520

7521+
def get_client_certificate_key_info(self, client_id: str, attribute: str) -> dict:
7522+
"""
7523+
Get client certificate key info.
7524+
7525+
:param client_id: id of the client
7526+
:type client_id: str
7527+
:param attribute: attribute to query for
7528+
:type attribute: str
7529+
:return: Certificate key info
7530+
:rtype: dict
7531+
"""
7532+
params_path = {
7533+
"realm-name": self.connection.realm_name,
7534+
"id": client_id,
7535+
"attr": attribute,
7536+
}
7537+
data_raw = self.connection.raw_get(
7538+
urls_patterns.URL_ADMIN_CLIENT_CERTS.format(**params_path)
7539+
)
7540+
res = raise_error_from_response(data_raw, KeycloakGetError)
7541+
if not isinstance(res, dict):
7542+
msg = (
7543+
"Unexpected response type. Expected 'dict', received "
7544+
f"'{type(res)}', value '{res}'."
7545+
)
7546+
raise TypeError(msg)
7547+
7548+
return res
7549+
75217550
def upload_certificate(self, client_id: str, certcont: str) -> dict:
75227551
"""
75237552
Upload a new certificate for the client.
@@ -14313,6 +14342,35 @@ async def a_get_role_client_level_children(self, client_id: str, role_id: str) -
1431314342

1431414343
return res
1431514344

14345+
async def a_get_client_certificate_key_info(self, client_id: str, attribute: str) -> dict:
14346+
"""
14347+
Get client certificate key info.
14348+
14349+
:param client_id: id of the client
14350+
:type client_id: str
14351+
:param attribute: attribute to query for
14352+
:type attribute: str
14353+
:return: Certificate key info
14354+
:rtype: dict
14355+
"""
14356+
params_path = {
14357+
"realm-name": self.connection.realm_name,
14358+
"id": client_id,
14359+
"attr": attribute,
14360+
}
14361+
data_raw = await self.connection.a_raw_get(
14362+
urls_patterns.URL_ADMIN_CLIENT_CERTS.format(**params_path)
14363+
)
14364+
res = raise_error_from_response(data_raw, KeycloakGetError)
14365+
if not isinstance(res, dict):
14366+
msg = (
14367+
"Unexpected response type. Expected 'dict', received "
14368+
f"'{type(res)}', value '{res}'."
14369+
)
14370+
raise TypeError(msg)
14371+
14372+
return res
14373+
1431614374
async def a_upload_certificate(self, client_id: str, certcont: str) -> dict:
1431714375
"""
1431814376
Upload a new certificate for the client asynchronously.

src/keycloak/keycloak_openid.py

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -791,12 +791,9 @@ def decode_token(self, token: str, validate: bool = True, **kwargs: Any) -> dict
791791
key = kwargs.pop("key", None)
792792
if validate:
793793
if key is None:
794-
key = (
795-
"-----BEGIN PUBLIC KEY-----\n"
796-
+ self.public_key()
797-
+ "\n-----END PUBLIC KEY-----"
798-
)
799-
key = jwk.JWK.from_pem(key.encode("utf-8"))
794+
key = jwk.JWKSet()
795+
for cert in self.certs()["keys"]:
796+
key.add(jwk.JWK(**cert))
800797
else:
801798
key = None
802799

@@ -1656,12 +1653,9 @@ async def a_decode_token(self, token: str, validate: bool = True, **kwargs: Any)
16561653
key = kwargs.pop("key", None)
16571654
if validate:
16581655
if key is None:
1659-
key = (
1660-
"-----BEGIN PUBLIC KEY-----\n"
1661-
+ await self.a_public_key()
1662-
+ "\n-----END PUBLIC KEY-----"
1663-
)
1664-
key = jwk.JWK.from_pem(key.encode("utf-8"))
1656+
key = jwk.JWKSet()
1657+
for cert in (await self.a_certs())["keys"]:
1658+
key.add(jwk.JWK(**cert))
16651659
else:
16661660
key = None
16671661

tests/test_connection.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ def test_counter_part() -> None:
8989
assert sync_sign.parameters == async_sign.parameters
9090

9191
for async_method in async_methods:
92-
if async_method in ["aclose"]:
92+
if async_method == "aclose":
9393
continue
9494
if async_method[2:].startswith("_"):
9595
continue

tests/test_keycloak_admin.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
NO_CLIENT_SCOPE_REGEX = '404: b\'{"error":"Could not find client scope".*}\''
4141
UNKOWN_ERROR_REGEX = 'b\'{"error":"unknown_error".*}\''
4242
USER_NOT_FOUND_REGEX = '404: b\'{"error":"User not found".*}\''
43+
COULD_NOT_FIND_CLIENT_REGEX = '404: b\'{"error":"Could not find client".*}\''
4344

4445

4546
def test_keycloak_version() -> None:
@@ -7697,6 +7698,23 @@ async def test_a_consents(
76977698
assert err.match(CONSENT_NOT_FOUND_REGEX)
76987699

76997700

7701+
def test_keycloak_client_get_cert_info(admin: KeycloakAdmin, client: str) -> None:
7702+
"""Test get cert info."""
7703+
assert admin.get_client_certificate_key_info(client, "jwt.credential") == {}
7704+
with pytest.raises(KeycloakGetError) as res:
7705+
admin.get_client_certificate_key_info("blah", "blah")
7706+
assert res.match(COULD_NOT_FIND_CLIENT_REGEX)
7707+
7708+
7709+
@pytest.mark.asyncio
7710+
async def test_a_keycloak_client_get_cert_info(admin: KeycloakAdmin, client: str) -> None:
7711+
"""Test get cert info."""
7712+
assert await admin.a_get_client_certificate_key_info(client, "jwt.credential") == {}
7713+
with pytest.raises(KeycloakGetError) as res:
7714+
await admin.a_get_client_certificate_key_info("blah", "blah")
7715+
assert res.match(COULD_NOT_FIND_CLIENT_REGEX)
7716+
7717+
77007718
def test_counter_part() -> None:
77017719
"""Test that each function has its async counter part."""
77027720
admin_methods = [func for func in dir(KeycloakAdmin) if callable(getattr(KeycloakAdmin, func))]

tests/test_keycloak_openid.py

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import jwcrypto.jwk
77
import jwcrypto.jws
8+
import jwcrypto.jwt
89
import pytest
910

1011
from keycloak import KeycloakAdmin, KeycloakOpenID
@@ -379,7 +380,7 @@ def test_decode_token_invalid_token(oid_with_credentials: tuple[KeycloakOpenID,
379380
key = jwcrypto.jwk.JWK.from_pem(key.encode("utf-8"))
380381

381382
invalid_access_token = access_token + "a"
382-
with pytest.raises(jwcrypto.jws.InvalidJWSSignature):
383+
with pytest.raises(jwcrypto.jwt.JWTMissingKey):
383384
decoded_invalid_access_token = oid.decode_token(token=invalid_access_token, validate=True)
384385

385386
with pytest.raises(jwcrypto.jws.InvalidJWSSignature):
@@ -941,7 +942,7 @@ async def test_a_decode_token_invalid_token(
941942
key = jwcrypto.jwk.JWK.from_pem(key.encode("utf-8"))
942943

943944
invalid_access_token = access_token + "a"
944-
with pytest.raises(jwcrypto.jws.InvalidJWSSignature):
945+
with pytest.raises(jwcrypto.jwt.JWTMissingKey):
945946
decoded_invalid_access_token = await oid.a_decode_token(
946947
token=invalid_access_token,
947948
validate=True,
@@ -1219,3 +1220,54 @@ def test_counter_part() -> None:
12191220
continue
12201221

12211222
assert async_method[2:] in sync_methods
1223+
1224+
1225+
def test_other_signing_methods(
1226+
admin: KeycloakAdmin, oid_with_credentials: tuple[KeycloakOpenID, str, str]
1227+
) -> None:
1228+
"""Test other signing algs."""
1229+
oid, username, password = oid_with_credentials
1230+
1231+
admin.change_current_realm(oid.realm_name)
1232+
client_id = admin.get_client_id(oid.client_id)
1233+
assert client_id is not None
1234+
client_def = admin.get_client(client_id)
1235+
client_def["attributes"].update(
1236+
{
1237+
"access.token.signed.response.alg": "RS512",
1238+
"id.token.signed.response.alg": "RS512",
1239+
"userinfo.signed.response.alg": "RS512",
1240+
}
1241+
)
1242+
res = admin.update_client(client_id, client_def)
1243+
assert res == {}
1244+
1245+
token = oid.token(username, password)
1246+
res = oid.decode_token(token["access_token"])
1247+
assert res != {}
1248+
1249+
1250+
@pytest.mark.asyncio
1251+
async def test_a_other_signing_methods(
1252+
admin: KeycloakAdmin, oid_with_credentials: tuple[KeycloakOpenID, str, str]
1253+
) -> None:
1254+
"""Test other signing algs."""
1255+
oid, username, password = oid_with_credentials
1256+
1257+
await admin.a_change_current_realm(oid.realm_name)
1258+
client_id = await admin.a_get_client_id(oid.client_id)
1259+
assert client_id is not None
1260+
client_def = await admin.a_get_client(client_id)
1261+
client_def["attributes"].update(
1262+
{
1263+
"access.token.signed.response.alg": "RS512",
1264+
"id.token.signed.response.alg": "RS512",
1265+
"userinfo.signed.response.alg": "RS512",
1266+
}
1267+
)
1268+
res = await admin.a_update_client(client_id, client_def)
1269+
assert res == {}
1270+
1271+
token = await oid.a_token(username, password)
1272+
res = await oid.a_decode_token(token["access_token"])
1273+
assert res != {}

0 commit comments

Comments
 (0)