providers/oauth2: more x5c and ecdsa x/y tests (#4463)
* add option to exclude x5* Signed-off-by: Jens Langhammer <jens@goauthentik.io> #4082 * cleanup jwks, add flaky test Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add workaround based on https://github.com/jpadilla/pyjwt/issues/709 Signed-off-by: Jens Langhammer <jens@goauthentik.io> * don't rstrip hashes Signed-off-by: Jens Langhammer <jens@goauthentik.io> * keycloak seems to strip equals Signed-off-by: Jens Langhammer <jens@goauthentik.io> Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
parent
f09305a444
commit
e390f5b2d1
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
Reference in a new issue