diff --git a/authentik/providers/oauth2/api/provider.py b/authentik/providers/oauth2/api/provider.py index a534d0648..cd058e829 100644 --- a/authentik/providers/oauth2/api/provider.py +++ b/authentik/providers/oauth2/api/provider.py @@ -35,6 +35,7 @@ class OAuth2ProviderSerializer(ProviderSerializer): "property_mappings", "issuer_mode", "verification_keys", + "jwks_sources", ] diff --git a/authentik/providers/oauth2/migrations/0009_oauth2provider_verification_keys_and_more.py b/authentik/providers/oauth2/migrations/0009_oauth2provider_verification_keys_and_more.py index 8503b060b..78cbb66f1 100644 --- a/authentik/providers/oauth2/migrations/0009_oauth2provider_verification_keys_and_more.py +++ b/authentik/providers/oauth2/migrations/0009_oauth2provider_verification_keys_and_more.py @@ -16,7 +16,7 @@ class Migration(migrations.Migration): model_name="oauth2provider", name="verification_keys", field=models.ManyToManyField( - help_text="JWTs created with the configured certificates can authenticate with this provider.", + help_text="DEPRECATED. JWTs created with the configured certificates can authenticate with this provider.", related_name="+", to="authentik_crypto.certificatekeypair", verbose_name="Allowed certificates for JWT-based client_credentials", diff --git a/authentik/providers/oauth2/migrations/0011_oauth2provider_jwks_sources_and_more.py b/authentik/providers/oauth2/migrations/0011_oauth2provider_jwks_sources_and_more.py new file mode 100644 index 000000000..c1245df67 --- /dev/null +++ b/authentik/providers/oauth2/migrations/0011_oauth2provider_jwks_sources_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 4.0.4 on 2022-05-23 20:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "authentik_sources_oauth", + "0007_oauthsource_oidc_jwks_oauthsource_oidc_jwks_url_and_more", + ), + ("authentik_crypto", "0003_certificatekeypair_managed"), + ("authentik_providers_oauth2", "0010_alter_oauth2provider_verification_keys"), + ] + + operations = [ + migrations.AddField( + model_name="oauth2provider", + name="jwks_sources", + field=models.ManyToManyField( + blank=True, + default=None, + related_name="+", + to="authentik_sources_oauth.oauthsource", + verbose_name="Any JWT signed by the JWK of the selected source can be used to authenticate.", + ), + ), + migrations.AlterField( + model_name="oauth2provider", + name="verification_keys", + field=models.ManyToManyField( + blank=True, + default=None, + help_text="DEPRECATED. JWTs created with the configured certificates can authenticate with this provider.", + related_name="+", + to="authentik_crypto.certificatekeypair", + verbose_name="Allowed certificates for JWT-based client_credentials", + ), + ), + ] diff --git a/authentik/providers/oauth2/models.py b/authentik/providers/oauth2/models.py index f85887662..8ceea8e1d 100644 --- a/authentik/providers/oauth2/models.py +++ b/authentik/providers/oauth2/models.py @@ -27,6 +27,7 @@ from authentik.lib.generators import generate_id, generate_key from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator from authentik.providers.oauth2.apps import AuthentikProviderOAuth2Config from authentik.providers.oauth2.constants import ACR_AUTHENTIK_DEFAULT +from authentik.sources.oauth.models import OAuthSource class ClientTypes(models.TextChoices): @@ -225,7 +226,19 @@ class OAuth2Provider(Provider): CertificateKeyPair, verbose_name=_("Allowed certificates for JWT-based client_credentials"), help_text=_( - "JWTs created with the configured certificates can authenticate with this provider." + ( + "DEPRECATED. JWTs created with the configured " + "certificates can authenticate with this provider." + ) + ), + related_name="+", + default=None, + blank=True, + ) + jwks_sources = models.ManyToManyField( + OAuthSource, + verbose_name=_( + "Any JWT signed by the JWK of the selected source can be used to authenticate." ), related_name="+", default=None, diff --git a/authentik/providers/oauth2/tests/test_token_client_credentials.py b/authentik/providers/oauth2/tests/test_token_cc.py similarity index 100% rename from authentik/providers/oauth2/tests/test_token_client_credentials.py rename to authentik/providers/oauth2/tests/test_token_cc.py diff --git a/authentik/providers/oauth2/tests/test_token_client_credentials_jwt.py b/authentik/providers/oauth2/tests/test_token_cc_jwt.py similarity index 95% rename from authentik/providers/oauth2/tests/test_token_client_credentials_jwt.py rename to authentik/providers/oauth2/tests/test_token_cc_jwt.py index e6a012621..4f879b3b3 100644 --- a/authentik/providers/oauth2/tests/test_token_client_credentials_jwt.py +++ b/authentik/providers/oauth2/tests/test_token_cc_jwt.py @@ -6,8 +6,8 @@ from django.test import RequestFactory from django.urls import reverse from jwt import decode -from authentik.core.models import USER_ATTRIBUTE_SA, Application, Group -from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow +from authentik.core.models import Application, Group +from authentik.core.tests.utils import create_test_cert, create_test_flow from authentik.lib.generators import generate_id, generate_key from authentik.managed.manager import ObjectManager from authentik.policies.models import PolicyBinding @@ -40,9 +40,6 @@ class TestTokenClientCredentialsJWT(OAuthTestCase): self.provider.verification_keys.set([self.cert]) self.provider.property_mappings.set(ScopeMapping.objects.all()) self.app = Application.objects.create(name="test", slug="test", provider=self.provider) - self.user = create_test_admin_user("sa") - self.user.attributes[USER_ATTRIBUTE_SA] = True - self.user.save() def test_invalid_type(self): """test invalid type""" @@ -76,7 +73,7 @@ class TestTokenClientCredentialsJWT(OAuthTestCase): body = loads(response.content.decode()) self.assertEqual(body["error"], "invalid_grant") - def test_invalid_signautre(self): + def test_invalid_signature(self): """test invalid JWT""" token = self.provider.encode( { diff --git a/authentik/providers/oauth2/tests/test_token_cc_jwt_source.py b/authentik/providers/oauth2/tests/test_token_cc_jwt_source.py new file mode 100644 index 000000000..df83d8695 --- /dev/null +++ b/authentik/providers/oauth2/tests/test_token_cc_jwt_source.py @@ -0,0 +1,223 @@ +"""Test token view""" +from datetime import datetime, timedelta +from json import loads + +from django.test import RequestFactory +from django.urls import reverse +from jwt import decode + +from authentik.core.models import Application, Group +from authentik.core.tests.utils import create_test_cert, create_test_flow +from authentik.lib.generators import generate_id, generate_key +from authentik.managed.manager import ObjectManager +from authentik.policies.models import PolicyBinding +from authentik.providers.oauth2.constants import ( + GRANT_TYPE_CLIENT_CREDENTIALS, + SCOPE_OPENID, + SCOPE_OPENID_EMAIL, + SCOPE_OPENID_PROFILE, +) +from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping +from authentik.providers.oauth2.tests.utils import OAuthTestCase +from authentik.providers.oauth2.views.jwks import JWKSView +from authentik.sources.oauth.models import OAuthSource + + +class TestTokenClientCredentialsJWTSource(OAuthTestCase): + """Test token (client_credentials, with JWT) view""" + + def setUp(self) -> None: + super().setUp() + ObjectManager().run() + self.factory = RequestFactory() + self.cert = create_test_cert() + + jwk = JWKSView().get_jwk_for_key(self.cert) + self.source: OAuthSource = OAuthSource.objects.create( + name=generate_id(), + slug=generate_id(), + provider_type="openidconnect", + consumer_key=generate_id(), + consumer_secret=generate_key(), + authorization_url="http://foo", + access_token_url=f"http://{generate_id()}", + profile_url="http://foo", + oidc_well_known_url="", + oidc_jwks_url="", + oidc_jwks={ + "keys": [jwk], + }, + ) + + self.provider: OAuth2Provider = OAuth2Provider.objects.create( + name="test", + client_id=generate_id(), + client_secret=generate_key(), + authorization_flow=create_test_flow(), + redirect_uris="http://testserver", + signing_key=self.cert, + ) + self.provider.jwks_sources.add(self.source) + self.provider.property_mappings.set(ScopeMapping.objects.all()) + self.app = Application.objects.create(name="test", slug="test", provider=self.provider) + + def test_invalid_type(self): + """test invalid type""" + response = self.client.post( + reverse("authentik_providers_oauth2:token"), + { + "grant_type": GRANT_TYPE_CLIENT_CREDENTIALS, + "scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}", + "client_id": self.provider.client_id, + "client_assertion_type": "foo", + "client_assertion": "foo.bar", + }, + ) + self.assertEqual(response.status_code, 400) + body = loads(response.content.decode()) + self.assertEqual(body["error"], "invalid_grant") + + def test_invalid_jwt(self): + """test invalid JWT""" + response = self.client.post( + reverse("authentik_providers_oauth2:token"), + { + "grant_type": GRANT_TYPE_CLIENT_CREDENTIALS, + "scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}", + "client_id": self.provider.client_id, + "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + "client_assertion": "foo.bar", + }, + ) + self.assertEqual(response.status_code, 400) + body = loads(response.content.decode()) + self.assertEqual(body["error"], "invalid_grant") + + def test_invalid_signature(self): + """test invalid JWT""" + token = self.provider.encode( + { + "sub": "foo", + "exp": datetime.now() + timedelta(hours=2), + } + ) + response = self.client.post( + reverse("authentik_providers_oauth2:token"), + { + "grant_type": GRANT_TYPE_CLIENT_CREDENTIALS, + "scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}", + "client_id": self.provider.client_id, + "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + "client_assertion": token + "foo", + }, + ) + self.assertEqual(response.status_code, 400) + body = loads(response.content.decode()) + self.assertEqual(body["error"], "invalid_grant") + + def test_invalid_expired(self): + """test invalid JWT""" + token = self.provider.encode( + { + "sub": "foo", + "exp": datetime.now() - timedelta(hours=2), + } + ) + response = self.client.post( + reverse("authentik_providers_oauth2:token"), + { + "grant_type": GRANT_TYPE_CLIENT_CREDENTIALS, + "scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}", + "client_id": self.provider.client_id, + "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + "client_assertion": token, + }, + ) + self.assertEqual(response.status_code, 400) + body = loads(response.content.decode()) + self.assertEqual(body["error"], "invalid_grant") + + def test_invalid_no_app(self): + """test invalid JWT""" + self.app.provider = None + self.app.save() + token = self.provider.encode( + { + "sub": "foo", + "exp": datetime.now() + timedelta(hours=2), + } + ) + response = self.client.post( + reverse("authentik_providers_oauth2:token"), + { + "grant_type": GRANT_TYPE_CLIENT_CREDENTIALS, + "scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}", + "client_id": self.provider.client_id, + "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + "client_assertion": token, + }, + ) + self.assertEqual(response.status_code, 400) + body = loads(response.content.decode()) + self.assertEqual(body["error"], "invalid_grant") + + def test_invalid_access_denied(self): + """test invalid JWT""" + group = Group.objects.create(name="foo") + PolicyBinding.objects.create( + group=group, + target=self.app, + order=0, + ) + token = self.provider.encode( + { + "sub": "foo", + "exp": datetime.now() + timedelta(hours=2), + } + ) + response = self.client.post( + reverse("authentik_providers_oauth2:token"), + { + "grant_type": GRANT_TYPE_CLIENT_CREDENTIALS, + "scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}", + "client_id": self.provider.client_id, + "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + "client_assertion": token, + }, + ) + self.assertEqual(response.status_code, 400) + body = loads(response.content.decode()) + self.assertEqual(body["error"], "invalid_grant") + + def test_successful(self): + """test successful""" + token = self.provider.encode( + { + "sub": "foo", + "exp": datetime.now() + timedelta(hours=2), + } + ) + response = self.client.post( + reverse("authentik_providers_oauth2:token"), + { + "grant_type": GRANT_TYPE_CLIENT_CREDENTIALS, + "scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}", + "client_id": self.provider.client_id, + "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + "client_assertion": token, + }, + ) + self.assertEqual(response.status_code, 200) + body = loads(response.content.decode()) + self.assertEqual(body["token_type"], "bearer") + _, alg = self.provider.get_jwt_key() + jwt = decode( + body["access_token"], + key=self.provider.signing_key.public_key, + algorithms=[alg], + audience=self.provider.client_id, + ) + self.assertEqual( + jwt["given_name"], "Autogenerated user from application test (client credentials JWT)" + ) + self.assertEqual(jwt["preferred_username"], "test-foo") diff --git a/authentik/providers/oauth2/views/jwks.py b/authentik/providers/oauth2/views/jwks.py index a350b95d8..a0b406ab3 100644 --- a/authentik/providers/oauth2/views/jwks.py +++ b/authentik/providers/oauth2/views/jwks.py @@ -1,5 +1,6 @@ """authentik OAuth2 JWKS Views""" from base64 import urlsafe_b64encode +from typing import Optional from cryptography.hazmat.primitives.asymmetric.ec import ( EllipticCurvePrivateKey, @@ -26,8 +27,37 @@ def b64_enc(number: int) -> str: class JWKSView(View): """Show RSA Key data for Provider""" + def get_jwk_for_key(self, key: CertificateKeyPair) -> Optional[dict]: + """Convert a certificate-key pair into JWK""" + private_key = key.private_key + if not private_key: + return None + if isinstance(private_key, RSAPrivateKey): + public_key: RSAPublicKey = private_key.public_key() + public_numbers = public_key.public_numbers() + return { + "kty": "RSA", + "alg": JWTAlgorithms.RS256, + "use": "sig", + "kid": key.kid, + "n": b64_enc(public_numbers.n), + "e": b64_enc(public_numbers.e), + } + if isinstance(private_key, EllipticCurvePrivateKey): + public_key: EllipticCurvePublicKey = private_key.public_key() + public_numbers = public_key.public_numbers() + return { + "kty": "EC", + "alg": JWTAlgorithms.ES256, + "use": "sig", + "kid": key.kid, + "n": b64_enc(public_numbers.n), + "e": b64_enc(public_numbers.e), + } + return None + def get(self, request: HttpRequest, application_slug: str) -> HttpResponse: - """Show RSA Key data for Provider""" + """Show JWK Key data for Provider""" application = get_object_or_404(Application, slug=application_slug) provider: OAuth2Provider = get_object_or_404(OAuth2Provider, pk=application.provider_id) signing_key: CertificateKeyPair = provider.signing_key @@ -35,33 +65,9 @@ class JWKSView(View): response_data = {} if signing_key: - private_key = signing_key.private_key - if isinstance(private_key, RSAPrivateKey): - public_key: RSAPublicKey = private_key.public_key() - public_numbers = public_key.public_numbers() - response_data["keys"] = [ - { - "kty": "RSA", - "alg": JWTAlgorithms.RS256, - "use": "sig", - "kid": signing_key.kid, - "n": b64_enc(public_numbers.n), - "e": b64_enc(public_numbers.e), - } - ] - elif isinstance(private_key, EllipticCurvePrivateKey): - public_key: EllipticCurvePublicKey = private_key.public_key() - public_numbers = public_key.public_numbers() - response_data["keys"] = [ - { - "kty": "EC", - "alg": JWTAlgorithms.ES256, - "use": "sig", - "kid": signing_key.kid, - "n": b64_enc(public_numbers.n), - "e": b64_enc(public_numbers.e), - } - ] + jwk = self.get_jwk_for_key(signing_key) + if jwk: + response_data["keys"] = [jwk] response = JsonResponse(response_data) response["Access-Control-Allow-Origin"] = "*" diff --git a/authentik/providers/oauth2/views/token.py b/authentik/providers/oauth2/views/token.py index baed2ef75..72b13a33b 100644 --- a/authentik/providers/oauth2/views/token.py +++ b/authentik/providers/oauth2/views/token.py @@ -9,7 +9,7 @@ from typing import Any, Optional from django.http import HttpRequest, HttpResponse from django.utils.timezone import datetime, now from django.views import View -from jwt import InvalidTokenError, decode +from jwt import InvalidTokenError, PyJWK, decode from sentry_sdk.hub import Hub from structlog.stdlib import get_logger @@ -43,6 +43,7 @@ from authentik.providers.oauth2.models import ( RefreshToken, ) from authentik.providers.oauth2.utils import TokenResponse, cors_allow, extract_client_auth +from authentik.sources.oauth.models import OAuthSource LOGGER = get_logger() @@ -258,17 +259,22 @@ class TokenParams: ).from_http(request, user=user) return None + # pylint: disable=too-many-locals def __post_init_client_credentials_jwt(self, request: HttpRequest): assertion_type = request.POST.get(CLIENT_ASSERTION_TYPE, "") if assertion_type != CLIENT_ASSERTION_TYPE_JWT: + LOGGER.warning("Invalid assertion type", assertion_type=assertion_type) raise TokenError("invalid_grant") client_secret = request.POST.get("client_secret", None) assertion = request.POST.get(CLIENT_ASSERTION, client_secret) if not assertion: + LOGGER.warning("Missing client assertion") raise TokenError("invalid_grant") token = None + + # TODO: Remove in 2022.7, deprecated field `verification_keys`` for cert in self.provider.verification_keys.all(): LOGGER.debug("verifying jwt with key", key=cert.name) cert: CertificateKeyPair @@ -286,7 +292,30 @@ class TokenParams: ) except (InvalidTokenError, ValueError, TypeError) as last_exc: LOGGER.warning("failed to validate jwt", last_exc=last_exc) + # TODO: End remove block + + source: Optional[OAuthSource] = None + parsed_key: Optional[PyJWK] = None + for source in self.provider.jwks_sources.all(): + LOGGER.debug("verifying jwt with source", source=source.name) + keys = source.oidc_jwks.get("keys", []) + for key in keys: + LOGGER.debug("verifying jwt with key", source=source.name, key=key.get("kid")) + try: + parsed_key = PyJWK.from_dict(key) + token = decode( + assertion, + parsed_key.key, + algorithms=[key.get("alg")], + options={ + "verify_aud": False, + }, + ) + except (InvalidTokenError, ValueError, TypeError) as last_exc: + LOGGER.warning("failed to validate jwt", last_exc=last_exc) + if not token: + LOGGER.warning("No token could be verified") raise TokenError("invalid_grant") if "exp" in token: @@ -315,12 +344,17 @@ class TokenParams: }, ) + method_args = { + "jwt": token, + } + if source: + method_args["source"] = source + if parsed_key: + method_args["jwk_id"] = parsed_key.key_id Event.new( action=EventAction.LOGIN, PLAN_CONTEXT_METHOD="jwt", - PLAN_CONTEXT_METHOD_ARGS={ - "jwt": token, - }, + PLAN_CONTEXT_METHOD_ARGS=method_args, PLAN_CONTEXT_APPLICATION=app, ).from_http(request, user=self.user) diff --git a/authentik/sources/oauth/api/source.py b/authentik/sources/oauth/api/source.py index b288b909d..645082cf9 100644 --- a/authentik/sources/oauth/api/source.py +++ b/authentik/sources/oauth/api/source.py @@ -2,6 +2,7 @@ from django.urls.base import reverse_lazy from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_field +from requests import RequestException from rest_framework.decorators import action from rest_framework.fields import BooleanField, CharField, ChoiceField, SerializerMethodField from rest_framework.request import Request @@ -12,6 +13,7 @@ from rest_framework.viewsets import ModelViewSet from authentik.core.api.sources import SourceSerializer from authentik.core.api.used_by import UsedByMixin from authentik.core.api.utils import PassiveSerializer +from authentik.lib.utils.http import get_http_session from authentik.sources.oauth.models import OAuthSource from authentik.sources.oauth.types.manager import MANAGER, SourceType @@ -52,6 +54,33 @@ class OAuthSourceSerializer(SourceSerializer): return SourceTypeSerializer(instance.type).data def validate(self, attrs: dict) -> dict: + session = get_http_session() + well_known = attrs.get("oidc_well_known_url") + if well_known and well_known != "": + try: + well_known_config = session.get(well_known) + well_known_config.raise_for_status() + except RequestException as exc: + raise ValidationError(exc.response.text) + config = well_known_config.json() + try: + attrs["authorization_url"] = config["authorization_endpoint"] + attrs["access_token_url"] = config["token_endpoint"] + attrs["profile_url"] = config["userinfo_endpoint"] + attrs["oidc_jwks_url"] = config["jwks_uri"] + except (IndexError, KeyError) as exc: + raise ValidationError(f"Invalid well-known configuration: {exc}") + + jwks_url = attrs.get("oidc_jwks_url") + if jwks_url and jwks_url != "": + try: + jwks_config = session.get(jwks_url) + jwks_config.raise_for_status() + except RequestException as exc: + raise ValidationError(exc.response.text) + config = jwks_config.json() + attrs["oidc_jwks"] = config + provider_type = MANAGER.find_type(attrs.get("provider_type", "")) for url in [ "authorization_url", @@ -61,6 +90,7 @@ class OAuthSourceSerializer(SourceSerializer): if getattr(provider_type, url, None) is None: if url not in attrs: raise ValidationError(f"{url} is required for provider {provider_type.name}") + print(attrs) return attrs class Meta: @@ -76,6 +106,9 @@ class OAuthSourceSerializer(SourceSerializer): "callback_url", "additional_scopes", "type", + "oidc_well_known_url", + "oidc_jwks_url", + "oidc_jwks", ] extra_kwargs = {"consumer_secret": {"write_only": True}} diff --git a/authentik/sources/oauth/migrations/0007_oauthsource_oidc_jwks_oauthsource_oidc_jwks_url_and_more.py b/authentik/sources/oauth/migrations/0007_oauthsource_oidc_jwks_oauthsource_oidc_jwks_url_and_more.py new file mode 100644 index 000000000..742bcf922 --- /dev/null +++ b/authentik/sources/oauth/migrations/0007_oauthsource_oidc_jwks_oauthsource_oidc_jwks_url_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.0.4 on 2022-05-23 20:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_sources_oauth", "0006_oauthsource_additional_scopes"), + ] + + operations = [ + migrations.AddField( + model_name="oauthsource", + name="oidc_jwks", + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name="oauthsource", + name="oidc_jwks_url", + field=models.TextField(blank=True, default=""), + ), + migrations.AddField( + model_name="oauthsource", + name="oidc_well_known_url", + field=models.TextField(blank=True, default=""), + ), + ] diff --git a/authentik/sources/oauth/models.py b/authentik/sources/oauth/models.py index 18d90341f..bda1c816b 100644 --- a/authentik/sources/oauth/models.py +++ b/authentik/sources/oauth/models.py @@ -50,6 +50,10 @@ class OAuthSource(Source): consumer_key = models.TextField() consumer_secret = models.TextField() + oidc_well_known_url = models.TextField(default="", blank=True) + oidc_jwks_url = models.TextField(default="", blank=True) + oidc_jwks = models.JSONField(default=dict, blank=True) + @property def type(self) -> type["SourceType"]: """Return the provider instance for this source""" diff --git a/authentik/sources/oauth/tests/test_views.py b/authentik/sources/oauth/tests/test_views.py index b38634682..2e1919c17 100644 --- a/authentik/sources/oauth/tests/test_views.py +++ b/authentik/sources/oauth/tests/test_views.py @@ -1,6 +1,7 @@ """OAuth Source tests""" from django.test import TestCase from django.urls import reverse +from requests_mock import Mocker from authentik.sources.oauth.api.source import OAuthSourceSerializer from authentik.sources.oauth.models import OAuthSource @@ -29,6 +30,8 @@ class TestOAuthSource(TestCase): "provider_type": "google", "consumer_key": "foo", "consumer_secret": "foo", + "oidc_well_known_url": "", + "oidc_jwks_url": "", } ).is_valid() ) @@ -44,6 +47,70 @@ class TestOAuthSource(TestCase): ).is_valid() ) + def test_api_validate_openid_connect(self): + """Test API validation (with OIDC endpoints)""" + openid_config = { + "authorization_endpoint": "http://mock/oauth/authorize", + "token_endpoint": "http://mock/oauth/token", + "userinfo_endpoint": "http://mock/oauth/userinfo", + "jwks_uri": "http://mock/oauth/discovery/keys", + } + jwks_config = {"keys": []} + with Mocker() as mocker: + url = "http://mock/.well-known/openid-configuration" + mocker.get(url, json=openid_config) + mocker.get(openid_config["jwks_uri"], json=jwks_config) + serializer = OAuthSourceSerializer( + instance=self.source, + data={ + "name": "foo", + "slug": "bar", + "provider_type": "openidconnect", + "consumer_key": "foo", + "consumer_secret": "foo", + "authorization_url": "http://foo", + "access_token_url": "http://foo", + "profile_url": "http://foo", + "oidc_well_known_url": url, + "oidc_jwks_url": "", + }, + ) + self.assertTrue(serializer.is_valid()) + self.assertEqual( + serializer.validated_data["authorization_url"], "http://mock/oauth/authorize" + ) + self.assertEqual( + serializer.validated_data["access_token_url"], "http://mock/oauth/token" + ) + self.assertEqual(serializer.validated_data["profile_url"], "http://mock/oauth/userinfo") + self.assertEqual( + serializer.validated_data["oidc_jwks_url"], "http://mock/oauth/discovery/keys" + ) + self.assertEqual(serializer.validated_data["oidc_jwks"], jwks_config) + + def test_api_validate_openid_connect_invalid(self): + """Test API validation (with OIDC endpoints)""" + openid_config = {} + with Mocker() as mocker: + url = "http://mock/.well-known/openid-configuration" + mocker.get(url, json=openid_config) + serializer = OAuthSourceSerializer( + instance=self.source, + data={ + "name": "foo", + "slug": "bar", + "provider_type": "openidconnect", + "consumer_key": "foo", + "consumer_secret": "foo", + "authorization_url": "http://foo", + "access_token_url": "http://foo", + "profile_url": "http://foo", + "oidc_well_known_url": url, + "oidc_jwks_url": "", + }, + ) + self.assertFalse(serializer.is_valid()) + def test_source_redirect(self): """test redirect view""" self.client.get( diff --git a/schema.yml b/schema.yml index ccb18428f..39b716538 100644 --- a/schema.yml +++ b/schema.yml @@ -23186,8 +23186,16 @@ components: format: uuid title: Allowed certificates for JWT-based client_credentials title: Allowed certificates for JWT-based client_credentials - description: JWTs created with the configured certificates can authenticate - with this provider. + description: DEPRECATED. JWTs created with the configured certificates can + authenticate with this provider. + jwks_sources: + type: array + items: + type: string + format: uuid + title: Any JWT signed by the JWK of the selected source can be used to + authenticate. + title: Any JWT signed by the JWK of the selected source can be used to authenticate. required: - assigned_application_name - assigned_application_slug @@ -23266,8 +23274,16 @@ components: format: uuid title: Allowed certificates for JWT-based client_credentials title: Allowed certificates for JWT-based client_credentials - description: JWTs created with the configured certificates can authenticate - with this provider. + description: DEPRECATED. JWTs created with the configured certificates can + authenticate with this provider. + jwks_sources: + type: array + items: + type: string + format: uuid + title: Any JWT signed by the JWK of the selected source can be used to + authenticate. + title: Any JWT signed by the JWK of the selected source can be used to authenticate. required: - authorization_flow - name @@ -23382,6 +23398,13 @@ components: allOf: - $ref: '#/components/schemas/SourceType' readOnly: true + oidc_well_known_url: + type: string + oidc_jwks_url: + type: string + oidc_jwks: + type: object + additionalProperties: {} required: - callback_url - component @@ -23463,6 +23486,13 @@ components: minLength: 1 additional_scopes: type: string + oidc_well_known_url: + type: string + oidc_jwks_url: + type: string + oidc_jwks: + type: object + additionalProperties: {} required: - consumer_key - consumer_secret @@ -27608,8 +27638,16 @@ components: format: uuid title: Allowed certificates for JWT-based client_credentials title: Allowed certificates for JWT-based client_credentials - description: JWTs created with the configured certificates can authenticate - with this provider. + description: DEPRECATED. JWTs created with the configured certificates can + authenticate with this provider. + jwks_sources: + type: array + items: + type: string + format: uuid + title: Any JWT signed by the JWK of the selected source can be used to + authenticate. + title: Any JWT signed by the JWK of the selected source can be used to authenticate. PatchedOAuthSourceRequest: type: object description: OAuth Source Serializer @@ -27679,6 +27717,13 @@ components: minLength: 1 additional_scopes: type: string + oidc_well_known_url: + type: string + oidc_jwks_url: + type: string + oidc_jwks: + type: object + additionalProperties: {} PatchedOutpostRequest: type: object description: Outpost Serializer diff --git a/web/src/pages/providers/oauth2/OAuth2ProviderForm.ts b/web/src/pages/providers/oauth2/OAuth2ProviderForm.ts index 3176db807..57dcd9e60 100644 --- a/web/src/pages/providers/oauth2/OAuth2ProviderForm.ts +++ b/web/src/pages/providers/oauth2/OAuth2ProviderForm.ts @@ -14,6 +14,7 @@ import { OAuth2Provider, PropertymappingsApi, ProvidersApi, + SourcesApi, SubModeEnum, } from "@goauthentik/api"; @@ -289,41 +290,6 @@ ${this.instance?.redirectUris} - - -

- ${t`JWTs signed by certificates configured here can be used to authenticate to the provider.`} -

-

- ${t`Hold control/command to select multiple items.`} -

-
+ + + ${t`Machine-to-Machine authentication settings`} +
+ + +

+ ${t`Deprecated. Instead of using this field, configure the JWKS data/URL in Sources.`} +

+

+ ${t`JWTs signed by certificates configured here can be used to authenticate to the provider.`} +

+

+ ${t`Hold control/command to select multiple items.`} +

+
+ + +

+ ${t`Deprecated. Instead of using this field, configure the JWKS data/URL in Sources.`} +

+

+ ${t`JWTs signed by certificates configured here can be used to authenticate to the provider.`} +

+

+ ${t`Hold control/command to select multiple items.`} +

+
+
+
`; } } diff --git a/web/src/pages/sources/oauth/OAuthSourceForm.ts b/web/src/pages/sources/oauth/OAuthSourceForm.ts index 2a4fa7d21..541d1a1c0 100644 --- a/web/src/pages/sources/oauth/OAuthSourceForm.ts +++ b/web/src/pages/sources/oauth/OAuthSourceForm.ts @@ -10,12 +10,14 @@ import { FlowsInstancesListDesignationEnum, OAuthSource, OAuthSourceRequest, + ProviderTypeEnum, SourceType, SourcesApi, UserMatchingModeEnum, } from "@goauthentik/api"; import { DEFAULT_CONFIG } from "../../../api/Config"; +import "../../../elements/CodeMirror"; import "../../../elements/forms/FormGroup"; import "../../../elements/forms/HorizontalFormElement"; import { ModelForm } from "../../../elements/forms/ModelForm"; @@ -155,6 +157,42 @@ export class OAuthSourceForm extends ModelForm {

` : html``} + ${this.providerType.slug === ProviderTypeEnum.Openidconnect + ? html` + + +

+ ${t`OIDC well-known configuration URL. Can be used to automatically configure the URLs above.`} +

+
+ + +

+ ${t`JSON Web Key URL. Keys from the URL will be used to validate JWTs from this source.`} +

+
+ + + + +

${t`Raw JWKS data.`}

+
+ ` + : html``} `; } diff --git a/website/docs/flow/executors/user-settings.md b/website/docs/flow/executors/user-settings.md index b15192cfb..a535de2f4 100644 --- a/website/docs/flow/executors/user-settings.md +++ b/website/docs/flow/executors/user-settings.md @@ -3,7 +3,7 @@ title: User settings --- :::info -Requires authentik 2022.3.1 +Requires authentik 2022.3 ::: The user interface (`/if/user/`) embeds a downsized flow executor to allow the user to configure their profile using custom stages and prompts. diff --git a/website/docs/providers/oauth2/client_credentials.md b/website/docs/providers/oauth2/client_credentials.md index c0f3a1ec9..a0018795d 100644 --- a/website/docs/providers/oauth2/client_credentials.md +++ b/website/docs/providers/oauth2/client_credentials.md @@ -31,6 +31,10 @@ Starting with authentik 2022.4, you can authenticate and get a token using an ex To configure this, the certificate used to sign the input JWT must be created in authentik. The certificate is enough, a private key is not required. Afterwards, configure the certificate in the OAuth2 provider settings under _Verification certificates_. +:::info +Starting with authentik 2022.6, you can define a JWKS URL/raw JWKS data in OAuth Sources, and use those to verify the key instead of having to manually create a certificate in authentik for them. This method is still supported but will be removed in a later version. +::: + With this configure, any JWT issued by the configured certificates can be used to authenticate: ``` diff --git a/website/docs/releases/v2022.6.md b/website/docs/releases/v2022.6.md new file mode 100644 index 000000000..9f0bbd5f1 --- /dev/null +++ b/website/docs/releases/v2022.6.md @@ -0,0 +1,32 @@ +--- +title: Release 2022.6 +slug: "2022.6" +--- + +## Breaking changes + +## New features + +- Added well-known and JWKS URL in OAuth Source + + These fields can be used to automatically configure OAuth Sources based on the [OpenID Connect Discovery Spec](https://openid.net/specs/openid-connect-discovery-1_0.html). Additionally, you can manually define a JWKS URL or raw JWKS data, and this can be used for Machine-to-machine authentication for OAuth2 Providers. + +## Minor changes/fixes + +## Upgrading + +This release does not introduce any new requirements. + +### docker-compose + +Download the docker-compose file for 2022.6 from [here](https://goauthentik.io/version/2022.6/docker-compose.yml). Afterwards, simply run `docker-compose up -d`. + +### Kubernetes + +Update your values to use the new images: + +```yaml +image: + repository: ghcr.io/goauthentik/server + tag: 2022.6.1 +``` diff --git a/website/integrations/sources/oauth/index.md b/website/integrations/sources/oauth/index.md index 44d231000..56e7c8df6 100644 --- a/website/integrations/sources/oauth/index.md +++ b/website/integrations/sources/oauth/index.md @@ -16,3 +16,21 @@ This source allows users to enroll themselves with an external OAuth-based Ident - Access Token URL: This value will be provided by the provider. - Profile URL: This URL is called by authentik to retrieve user information upon successful authentication. - Consumer key/Consumer secret: These values will be provided by the provider. + +### OpenID Connect + +:::info +Requires authentik 2022.6 +::: + +#### Well-known + +Instead of configuring the URLs for a source manually, and the application you're configuring implements the [OpenID Connect Discovery Spec](https://openid.net/specs/openid-connect-discovery-1_0.html), you can configure the source with a single URL. The URL should always end with `.well-known/openid-configuration`. Many applications don't explicitly mention this URL, but for most of them it will be `https://application.company/.well-known/openid-configuration`. + +This URL is fetched upon saving the source, and all the URLs will be replaced by the ones from the Discovery document. No automatic re-fetching is done. + +#### JWKS + +To simplify Machine-to-machine authentication, you can create an OAuth Source as "trusted" source of JWTs. Create a source and configure either the Well-known URL or the OIDC JWKS URL, or you can manually enter the JWKS data if you so desire. + +Afterwards, this source can be selected in one or multiple OAuth2 providers, and any JWT issued by any of the configured sources' JWKS will be able to authenticate. To learn more about this, see [JWT-authentication](/docs/providers/oauth2/client_credentials#jwt-authentication).