From e390f5b2d11ad713fbb4e9fbc93fd82f14f91bee Mon Sep 17 00:00:00 2001 From: Jens L Date: Wed, 18 Jan 2023 19:11:36 +0100 Subject: [PATCH] providers/oauth2: more x5c and ecdsa x/y tests (#4463) * add option to exclude x5* Signed-off-by: Jens Langhammer #4082 * cleanup jwks, add flaky test Signed-off-by: Jens Langhammer * add workaround based on https://github.com/jpadilla/pyjwt/issues/709 Signed-off-by: Jens Langhammer * don't rstrip hashes Signed-off-by: Jens Langhammer * keycloak seems to strip equals Signed-off-by: Jens Langhammer Signed-off-by: Jens Langhammer --- authentik/providers/oauth2/tests/test_jwks.py | 52 ++++++++++++++ authentik/providers/oauth2/views/jwks.py | 71 ++++++++++++------- .../services/apache-guacamole/index.mdx | 4 +- 3 files changed, 100 insertions(+), 27 deletions(-) diff --git a/authentik/providers/oauth2/tests/test_jwks.py b/authentik/providers/oauth2/tests/test_jwks.py index 89d44ebd6..2a678b4a9 100644 --- a/authentik/providers/oauth2/tests/test_jwks.py +++ b/authentik/providers/oauth2/tests/test_jwks.py @@ -1,14 +1,42 @@ """JWKS tests""" +import base64 import json +from cryptography.hazmat.backends import default_backend +from cryptography.x509 import load_der_x509_certificate from django.urls.base import reverse from jwt import PyJWKSet from authentik.core.models import Application from authentik.core.tests.utils import create_test_cert, create_test_flow +from authentik.crypto.models import CertificateKeyPair +from authentik.lib.generators import generate_id from authentik.providers.oauth2.models import OAuth2Provider from authentik.providers.oauth2.tests.utils import OAuthTestCase +TEST_CORDS_CERT = """ +-----BEGIN CERTIFICATE----- +MIIB6jCCAZCgAwIBAgIRAOsdE3N7zETzs+7shTXGj5wwCgYIKoZIzj0EAwIwHjEc +MBoGA1UEAwwTYXV0aGVudGlrIDIwMjIuMTIuMjAeFw0yMzAxMTYyMjU2MjVaFw0y +NDAxMTIyMjU2MjVaMHgxTDBKBgNVBAMMQ0NsbDR2TzFJSGxvdFFhTGwwMHpES2tM +WENYdzRPUFF2eEtZN1NrczAuc2VsZi1zaWduZWQuZ29hdXRoZW50aWsuaW8xEjAQ +BgNVBAoMCWF1dGhlbnRpazEUMBIGA1UECwwLU2VsZi1zaWduZWQwWTATBgcqhkjO +PQIBBggqhkjOPQMBBwNCAAQAwOGam7AKOi5LKmb9lK1rAzA2JTppqrFiIaUdjqmH +ZICJP00Wt0dfqOtEjgMEv1Hhu1DmKZn2ehvpxwPSzBr5o1UwUzBRBgNVHREBAf8E +RzBFgkNCNkw4YlI0UldJRU42NUZLamdUTzV1YmRvNUZWdkpNS2lxdjFZeTRULnNl +bGYtc2lnbmVkLmdvYXV0aGVudGlrLmlvMAoGCCqGSM49BAMCA0gAMEUCIC/JAfnl +uC30ihqepbiMCaTaPMbL8Ka2Lk92IYfMhf46AiEAz9Kmv6HF2D4MK54iwhz2WqvF +8vo+OiGdTQ1Qoj7fgYU= +-----END CERTIFICATE----- +""" +TEST_CORDS_KEY = """ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIKy6mPLJc5v71InMMvYaxyXI3xXpwQTPLyAYWVFnZHVioAoGCCqGSM49 +AwEHoUQDQgAEAMDhmpuwCjouSypm/ZStawMwNiU6aaqxYiGlHY6ph2SAiT9NFrdH +X6jrRI4DBL9R4btQ5imZ9nob6ccD0swa+Q== +-----END EC PRIVATE KEY----- +""" + class TestJWKS(OAuthTestCase): """Test JWKS view""" @@ -29,6 +57,8 @@ class TestJWKS(OAuthTestCase): body = json.loads(response.content.decode()) self.assertEqual(len(body["keys"]), 1) PyJWKSet.from_dict(body) + key = body["keys"][0] + load_der_x509_certificate(base64.b64decode(key["x5c"][0]), default_backend()).public_key() def test_hs256(self): """Test JWKS request with HS256""" @@ -60,3 +90,25 @@ class TestJWKS(OAuthTestCase): body = json.loads(response.content.decode()) self.assertEqual(len(body["keys"]), 1) PyJWKSet.from_dict(body) + + def test_ecdsa_coords_mismatched(self): + """Test JWKS request with ES256""" + cert = CertificateKeyPair.objects.create( + name=generate_id(), + key_data=TEST_CORDS_KEY, + certificate_data=TEST_CORDS_CERT, + ) + provider = OAuth2Provider.objects.create( + name="test", + client_id="test", + authorization_flow=create_test_flow(), + redirect_uris="http://local.invalid", + signing_key=cert, + ) + app = Application.objects.create(name="test", slug="test", provider=provider) + response = self.client.get( + reverse("authentik_providers_oauth2:jwks", kwargs={"application_slug": app.slug}) + ) + body = json.loads(response.content.decode()) + self.assertEqual(len(body["keys"]), 1) + PyJWKSet.from_dict(body) diff --git a/authentik/providers/oauth2/views/jwks.py b/authentik/providers/oauth2/views/jwks.py index 0227897bb..2a1d3bea8 100644 --- a/authentik/providers/oauth2/views/jwks.py +++ b/authentik/providers/oauth2/views/jwks.py @@ -15,27 +15,49 @@ from cryptography.hazmat.primitives.serialization import Encoding from django.http import HttpRequest, HttpResponse, JsonResponse from django.shortcuts import get_object_or_404 from django.views import View +from jwt.utils import base64url_encode from authentik.core.models import Application from authentik.crypto.models import CertificateKeyPair from authentik.providers.oauth2.models import JWTAlgorithms, OAuth2Provider - -def b64_enc(number: int) -> str: - """Convert number to base64-encoded octet-value""" - length = ((number).bit_length() + 7) // 8 - number_bytes = number.to_bytes(length, "big") - final = urlsafe_b64encode(number_bytes).rstrip(b"=") - return final.decode("ascii") - - # See https://notes.salrahman.com/generate-es256-es384-es512-private-keys/ # and _CURVE_TYPES in the same file as the below curve files ec_crv_map = { SECP256R1: "P-256", SECP384R1: "P-384", - SECP521R1: "P-512", + SECP521R1: "P-521", } +min_length_map = { + SECP256R1: 32, + SECP384R1: 48, + SECP521R1: 66, +} + +# https://github.com/jpadilla/pyjwt/issues/709 +def bytes_from_int(val: int, min_length: int = 0) -> bytes: + """Custom bytes_from_int that accepts a minimum length""" + remaining = val + byte_length = 0 + + while remaining != 0: + remaining >>= 8 + byte_length += 1 + length = max([byte_length, min_length]) + return val.to_bytes(length, "big", signed=False) + + +def to_base64url_uint(val: int, min_length: int = 0) -> bytes: + """Custom to_base64url_uint that accepts a minimum length""" + if val < 0: + raise ValueError("Must be a positive integer") + + int_bytes = bytes_from_int(val, min_length) + + if len(int_bytes) == 0: + int_bytes = b"\x00" + + return base64url_encode(int_bytes) class JWKSView(View): @@ -55,34 +77,33 @@ class JWKSView(View): "kty": "RSA", "alg": JWTAlgorithms.RS256, "use": "sig", - "n": b64_enc(public_numbers.n), - "e": b64_enc(public_numbers.e), + "n": to_base64url_uint(public_numbers.n).decode(), + "e": to_base64url_uint(public_numbers.e).decode(), } elif isinstance(private_key, EllipticCurvePrivateKey): public_key: EllipticCurvePublicKey = private_key.public_key() public_numbers = public_key.public_numbers() + curve_type = type(public_key.curve) key_data = { "kid": key.kid, "kty": "EC", "alg": JWTAlgorithms.ES256, "use": "sig", - "x": b64_enc(public_numbers.x), - "y": b64_enc(public_numbers.y), - "crv": ec_crv_map.get(type(public_key.curve), public_key.curve.name), + "x": to_base64url_uint(public_numbers.x, min_length_map[curve_type]).decode(), + "y": to_base64url_uint(public_numbers.y, min_length_map[curve_type]).decode(), + "crv": ec_crv_map.get(curve_type, public_key.curve.name), } else: return key_data key_data["x5c"] = [b64encode(key.certificate.public_bytes(Encoding.DER)).decode("utf-8")] - key_data["x5t"] = ( - urlsafe_b64encode(key.certificate.fingerprint(hashes.SHA1())) # nosec - .decode("utf-8") - .rstrip("=") - ) - key_data["x5t#S256"] = ( - urlsafe_b64encode(key.certificate.fingerprint(hashes.SHA256())) - .decode("utf-8") - .rstrip("=") - ) + key_data["x5t"] = urlsafe_b64encode( + key.certificate.fingerprint(hashes.SHA1()) + ).decode( # nosec + "utf-8" + ).rstrip("=") + key_data["x5t#S256"] = urlsafe_b64encode( + key.certificate.fingerprint(hashes.SHA256()) + ).decode("utf-8").rstrip("=") return key_data def get(self, request: HttpRequest, application_slug: str) -> HttpResponse: diff --git a/website/integrations/services/apache-guacamole/index.mdx b/website/integrations/services/apache-guacamole/index.mdx index 5faf25ca0..529a4425d 100644 --- a/website/integrations/services/apache-guacamole/index.mdx +++ b/website/integrations/services/apache-guacamole/index.mdx @@ -52,7 +52,7 @@ import TabItem from "@theme/TabItem"; OPENID_AUTHORIZATION_ENDPOINT: https://authentik.company/application/o/authorize/ OPENID_CLIENT_ID: # client ID from above OPENID_ISSUER: https://authentik.company/application/o/*Slug of the application from above*/ -OPENID_JWKS_ENDPOINT: https://authentik.company/application/o/*Slug of the application from above*/jwks/ +OPENID_JWKS_ENDPOINT: https://authentik.company/application/o/*Slug of the application from above*/jwks/?exclude_x5 OPENID_REDIRECT_URI: https://guacamole.company/ # This must match the redirect URI above ``` @@ -64,7 +64,7 @@ OPENID_REDIRECT_URI: https://guacamole.company/ # This must match the redirect U openid-authorization-endpoint=https://authentik.company/application/o/authorize/ openid-client-id=# client ID from above openid-issuer=https://authentik.company/application/o/*Slug of the application from above*/ -openid-jwks-endpoint=https://authentik.company/application/o/*Slug of the application from above*/jwks/ +openid-jwks-endpoint=https://authentik.company/application/o/*Slug of the application from above*/jwks/?exclude_x5 openid-redirect-uri=https://guacamole.company/ # This must match the redirect URI above ```