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`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.`}
+
+ ${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.`}
+