sources/oauth: OIDC well-known and JWKS (#2936)

* add initial

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* add provider

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* include source and jwk key id in event

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* add more docs

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* add tests for source

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* fix web formatting

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* add provider tests

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* fix lint error

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
Jens L 2022-05-24 21:02:50 +02:00 committed by GitHub
parent ab1840dd66
commit b4e75218f5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 711 additions and 82 deletions

View File

@ -35,6 +35,7 @@ class OAuth2ProviderSerializer(ProviderSerializer):
"property_mappings", "property_mappings",
"issuer_mode", "issuer_mode",
"verification_keys", "verification_keys",
"jwks_sources",
] ]

View File

@ -16,7 +16,7 @@ class Migration(migrations.Migration):
model_name="oauth2provider", model_name="oauth2provider",
name="verification_keys", name="verification_keys",
field=models.ManyToManyField( 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="+", related_name="+",
to="authentik_crypto.certificatekeypair", to="authentik_crypto.certificatekeypair",
verbose_name="Allowed certificates for JWT-based client_credentials", verbose_name="Allowed certificates for JWT-based client_credentials",

View File

@ -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",
),
),
]

View File

@ -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.lib.utils.time import timedelta_from_string, timedelta_string_validator
from authentik.providers.oauth2.apps import AuthentikProviderOAuth2Config from authentik.providers.oauth2.apps import AuthentikProviderOAuth2Config
from authentik.providers.oauth2.constants import ACR_AUTHENTIK_DEFAULT from authentik.providers.oauth2.constants import ACR_AUTHENTIK_DEFAULT
from authentik.sources.oauth.models import OAuthSource
class ClientTypes(models.TextChoices): class ClientTypes(models.TextChoices):
@ -225,7 +226,19 @@ class OAuth2Provider(Provider):
CertificateKeyPair, CertificateKeyPair,
verbose_name=_("Allowed certificates for JWT-based client_credentials"), verbose_name=_("Allowed certificates for JWT-based client_credentials"),
help_text=_( 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="+", related_name="+",
default=None, default=None,

View File

@ -6,8 +6,8 @@ from django.test import RequestFactory
from django.urls import reverse from django.urls import reverse
from jwt import decode from jwt import decode
from authentik.core.models import USER_ATTRIBUTE_SA, Application, Group from authentik.core.models import Application, Group
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow from authentik.core.tests.utils import create_test_cert, create_test_flow
from authentik.lib.generators import generate_id, generate_key from authentik.lib.generators import generate_id, generate_key
from authentik.managed.manager import ObjectManager from authentik.managed.manager import ObjectManager
from authentik.policies.models import PolicyBinding from authentik.policies.models import PolicyBinding
@ -40,9 +40,6 @@ class TestTokenClientCredentialsJWT(OAuthTestCase):
self.provider.verification_keys.set([self.cert]) self.provider.verification_keys.set([self.cert])
self.provider.property_mappings.set(ScopeMapping.objects.all()) self.provider.property_mappings.set(ScopeMapping.objects.all())
self.app = Application.objects.create(name="test", slug="test", provider=self.provider) 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): def test_invalid_type(self):
"""test invalid type""" """test invalid type"""
@ -76,7 +73,7 @@ class TestTokenClientCredentialsJWT(OAuthTestCase):
body = loads(response.content.decode()) body = loads(response.content.decode())
self.assertEqual(body["error"], "invalid_grant") self.assertEqual(body["error"], "invalid_grant")
def test_invalid_signautre(self): def test_invalid_signature(self):
"""test invalid JWT""" """test invalid JWT"""
token = self.provider.encode( token = self.provider.encode(
{ {

View File

@ -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")

View File

@ -1,5 +1,6 @@
"""authentik OAuth2 JWKS Views""" """authentik OAuth2 JWKS Views"""
from base64 import urlsafe_b64encode from base64 import urlsafe_b64encode
from typing import Optional
from cryptography.hazmat.primitives.asymmetric.ec import ( from cryptography.hazmat.primitives.asymmetric.ec import (
EllipticCurvePrivateKey, EllipticCurvePrivateKey,
@ -26,8 +27,37 @@ def b64_enc(number: int) -> str:
class JWKSView(View): class JWKSView(View):
"""Show RSA Key data for Provider""" """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: 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) application = get_object_or_404(Application, slug=application_slug)
provider: OAuth2Provider = get_object_or_404(OAuth2Provider, pk=application.provider_id) provider: OAuth2Provider = get_object_or_404(OAuth2Provider, pk=application.provider_id)
signing_key: CertificateKeyPair = provider.signing_key signing_key: CertificateKeyPair = provider.signing_key
@ -35,33 +65,9 @@ class JWKSView(View):
response_data = {} response_data = {}
if signing_key: if signing_key:
private_key = signing_key.private_key jwk = self.get_jwk_for_key(signing_key)
if isinstance(private_key, RSAPrivateKey): if jwk:
public_key: RSAPublicKey = private_key.public_key() response_data["keys"] = [jwk]
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),
}
]
response = JsonResponse(response_data) response = JsonResponse(response_data)
response["Access-Control-Allow-Origin"] = "*" response["Access-Control-Allow-Origin"] = "*"

View File

@ -9,7 +9,7 @@ from typing import Any, Optional
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.utils.timezone import datetime, now from django.utils.timezone import datetime, now
from django.views import View from django.views import View
from jwt import InvalidTokenError, decode from jwt import InvalidTokenError, PyJWK, decode
from sentry_sdk.hub import Hub from sentry_sdk.hub import Hub
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
@ -43,6 +43,7 @@ from authentik.providers.oauth2.models import (
RefreshToken, RefreshToken,
) )
from authentik.providers.oauth2.utils import TokenResponse, cors_allow, extract_client_auth from authentik.providers.oauth2.utils import TokenResponse, cors_allow, extract_client_auth
from authentik.sources.oauth.models import OAuthSource
LOGGER = get_logger() LOGGER = get_logger()
@ -258,17 +259,22 @@ class TokenParams:
).from_http(request, user=user) ).from_http(request, user=user)
return None return None
# pylint: disable=too-many-locals
def __post_init_client_credentials_jwt(self, request: HttpRequest): def __post_init_client_credentials_jwt(self, request: HttpRequest):
assertion_type = request.POST.get(CLIENT_ASSERTION_TYPE, "") assertion_type = request.POST.get(CLIENT_ASSERTION_TYPE, "")
if assertion_type != CLIENT_ASSERTION_TYPE_JWT: if assertion_type != CLIENT_ASSERTION_TYPE_JWT:
LOGGER.warning("Invalid assertion type", assertion_type=assertion_type)
raise TokenError("invalid_grant") raise TokenError("invalid_grant")
client_secret = request.POST.get("client_secret", None) client_secret = request.POST.get("client_secret", None)
assertion = request.POST.get(CLIENT_ASSERTION, client_secret) assertion = request.POST.get(CLIENT_ASSERTION, client_secret)
if not assertion: if not assertion:
LOGGER.warning("Missing client assertion")
raise TokenError("invalid_grant") raise TokenError("invalid_grant")
token = None token = None
# TODO: Remove in 2022.7, deprecated field `verification_keys``
for cert in self.provider.verification_keys.all(): for cert in self.provider.verification_keys.all():
LOGGER.debug("verifying jwt with key", key=cert.name) LOGGER.debug("verifying jwt with key", key=cert.name)
cert: CertificateKeyPair cert: CertificateKeyPair
@ -286,7 +292,30 @@ class TokenParams:
) )
except (InvalidTokenError, ValueError, TypeError) as last_exc: except (InvalidTokenError, ValueError, TypeError) as last_exc:
LOGGER.warning("failed to validate jwt", last_exc=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: if not token:
LOGGER.warning("No token could be verified")
raise TokenError("invalid_grant") raise TokenError("invalid_grant")
if "exp" in token: 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( Event.new(
action=EventAction.LOGIN, action=EventAction.LOGIN,
PLAN_CONTEXT_METHOD="jwt", PLAN_CONTEXT_METHOD="jwt",
PLAN_CONTEXT_METHOD_ARGS={ PLAN_CONTEXT_METHOD_ARGS=method_args,
"jwt": token,
},
PLAN_CONTEXT_APPLICATION=app, PLAN_CONTEXT_APPLICATION=app,
).from_http(request, user=self.user) ).from_http(request, user=self.user)

View File

@ -2,6 +2,7 @@
from django.urls.base import reverse_lazy from django.urls.base import reverse_lazy
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_field from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_field
from requests import RequestException
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.fields import BooleanField, CharField, ChoiceField, SerializerMethodField from rest_framework.fields import BooleanField, CharField, ChoiceField, SerializerMethodField
from rest_framework.request import Request 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.sources import SourceSerializer
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import PassiveSerializer 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.models import OAuthSource
from authentik.sources.oauth.types.manager import MANAGER, SourceType from authentik.sources.oauth.types.manager import MANAGER, SourceType
@ -52,6 +54,33 @@ class OAuthSourceSerializer(SourceSerializer):
return SourceTypeSerializer(instance.type).data return SourceTypeSerializer(instance.type).data
def validate(self, attrs: dict) -> dict: 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", "")) provider_type = MANAGER.find_type(attrs.get("provider_type", ""))
for url in [ for url in [
"authorization_url", "authorization_url",
@ -61,6 +90,7 @@ class OAuthSourceSerializer(SourceSerializer):
if getattr(provider_type, url, None) is None: if getattr(provider_type, url, None) is None:
if url not in attrs: if url not in attrs:
raise ValidationError(f"{url} is required for provider {provider_type.name}") raise ValidationError(f"{url} is required for provider {provider_type.name}")
print(attrs)
return attrs return attrs
class Meta: class Meta:
@ -76,6 +106,9 @@ class OAuthSourceSerializer(SourceSerializer):
"callback_url", "callback_url",
"additional_scopes", "additional_scopes",
"type", "type",
"oidc_well_known_url",
"oidc_jwks_url",
"oidc_jwks",
] ]
extra_kwargs = {"consumer_secret": {"write_only": True}} extra_kwargs = {"consumer_secret": {"write_only": True}}

View File

@ -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=""),
),
]

View File

@ -50,6 +50,10 @@ class OAuthSource(Source):
consumer_key = models.TextField() consumer_key = models.TextField()
consumer_secret = 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 @property
def type(self) -> type["SourceType"]: def type(self) -> type["SourceType"]:
"""Return the provider instance for this source""" """Return the provider instance for this source"""

View File

@ -1,6 +1,7 @@
"""OAuth Source tests""" """OAuth Source tests"""
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from requests_mock import Mocker
from authentik.sources.oauth.api.source import OAuthSourceSerializer from authentik.sources.oauth.api.source import OAuthSourceSerializer
from authentik.sources.oauth.models import OAuthSource from authentik.sources.oauth.models import OAuthSource
@ -29,6 +30,8 @@ class TestOAuthSource(TestCase):
"provider_type": "google", "provider_type": "google",
"consumer_key": "foo", "consumer_key": "foo",
"consumer_secret": "foo", "consumer_secret": "foo",
"oidc_well_known_url": "",
"oidc_jwks_url": "",
} }
).is_valid() ).is_valid()
) )
@ -44,6 +47,70 @@ class TestOAuthSource(TestCase):
).is_valid() ).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): def test_source_redirect(self):
"""test redirect view""" """test redirect view"""
self.client.get( self.client.get(

View File

@ -23186,8 +23186,16 @@ components:
format: uuid format: uuid
title: Allowed certificates for JWT-based client_credentials title: Allowed certificates for JWT-based client_credentials
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 description: DEPRECATED. JWTs created with the configured certificates can
with this provider. 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: required:
- assigned_application_name - assigned_application_name
- assigned_application_slug - assigned_application_slug
@ -23266,8 +23274,16 @@ components:
format: uuid format: uuid
title: Allowed certificates for JWT-based client_credentials title: Allowed certificates for JWT-based client_credentials
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 description: DEPRECATED. JWTs created with the configured certificates can
with this provider. 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: required:
- authorization_flow - authorization_flow
- name - name
@ -23382,6 +23398,13 @@ components:
allOf: allOf:
- $ref: '#/components/schemas/SourceType' - $ref: '#/components/schemas/SourceType'
readOnly: true readOnly: true
oidc_well_known_url:
type: string
oidc_jwks_url:
type: string
oidc_jwks:
type: object
additionalProperties: {}
required: required:
- callback_url - callback_url
- component - component
@ -23463,6 +23486,13 @@ components:
minLength: 1 minLength: 1
additional_scopes: additional_scopes:
type: string type: string
oidc_well_known_url:
type: string
oidc_jwks_url:
type: string
oidc_jwks:
type: object
additionalProperties: {}
required: required:
- consumer_key - consumer_key
- consumer_secret - consumer_secret
@ -27608,8 +27638,16 @@ components:
format: uuid format: uuid
title: Allowed certificates for JWT-based client_credentials title: Allowed certificates for JWT-based client_credentials
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 description: DEPRECATED. JWTs created with the configured certificates can
with this provider. 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: PatchedOAuthSourceRequest:
type: object type: object
description: OAuth Source Serializer description: OAuth Source Serializer
@ -27679,6 +27717,13 @@ components:
minLength: 1 minLength: 1
additional_scopes: additional_scopes:
type: string type: string
oidc_well_known_url:
type: string
oidc_jwks_url:
type: string
oidc_jwks:
type: object
additionalProperties: {}
PatchedOutpostRequest: PatchedOutpostRequest:
type: object type: object
description: Outpost Serializer description: Outpost Serializer

View File

@ -14,6 +14,7 @@ import {
OAuth2Provider, OAuth2Provider,
PropertymappingsApi, PropertymappingsApi,
ProvidersApi, ProvidersApi,
SourcesApi,
SubModeEnum, SubModeEnum,
} from "@goauthentik/api"; } from "@goauthentik/api";
@ -289,41 +290,6 @@ ${this.instance?.redirectUris}</textarea
${t`Hold control/command to select multiple items.`} ${t`Hold control/command to select multiple items.`}
</p> </p>
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal
label=${t`Verification certificates`}
name="verificationKeys"
>
<select class="pf-c-form-control" multiple>
${until(
new CryptoApi(DEFAULT_CONFIG)
.cryptoCertificatekeypairsList({
ordering: "name",
})
.then((keys) => {
return keys.results.map((key) => {
const selected = (
this.instance?.verificationKeys || []
).some((su) => {
return su == key.pk;
});
return html`<option
value=${key.pk}
?selected=${selected}
>
${key.name} (${key.privateKeyType?.toUpperCase()})
</option>`;
});
}),
html`<option>${t`Loading...`}</option>`,
)}
</select>
<p class="pf-c-form__helper-text">
${t`JWTs signed by certificates configured here can be used to authenticate to the provider.`}
</p>
<p class="pf-c-form__helper-text">
${t`Hold control/command to select multiple items.`}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal <ak-form-element-horizontal
label=${t`Subject mode`} label=${t`Subject mode`}
?required=${true} ?required=${true}
@ -400,6 +366,85 @@ ${this.instance?.redirectUris}</textarea
</ak-form-element-horizontal> </ak-form-element-horizontal>
</div> </div>
</ak-form-group> </ak-form-group>
<ak-form-group>
<span slot="header">${t`Machine-to-Machine authentication settings`}</span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal label=${t`Trusted OIDC Sources`} name="jwksSources">
<select class="pf-c-form-control" multiple>
${until(
new SourcesApi(DEFAULT_CONFIG)
.sourcesOauthList({
ordering: "name",
})
.then((sources) => {
return sources.results.map((source) => {
const selected = (
this.instance?.jwksSources || []
).some((su) => {
return su == source.pk;
});
return html`<option
value=${source.pk}
?selected=${selected}
>
${source.name} (${source.slug})
</option>`;
});
}),
html`<option>${t`Loading...`}</option>`,
)}
</select>
<p class="pf-c-form__helper-text">
${t`Deprecated. Instead of using this field, configure the JWKS data/URL in Sources.`}
</p>
<p class="pf-c-form__helper-text">
${t`JWTs signed by certificates configured here can be used to authenticate to the provider.`}
</p>
<p class="pf-c-form__helper-text">
${t`Hold control/command to select multiple items.`}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${t`Verification certificates`}
name="verificationKeys"
>
<select class="pf-c-form-control" multiple>
${until(
new CryptoApi(DEFAULT_CONFIG)
.cryptoCertificatekeypairsList({
ordering: "name",
})
.then((keys) => {
return keys.results.map((key) => {
const selected = (
this.instance?.verificationKeys || []
).some((su) => {
return su == key.pk;
});
return html`<option
value=${key.pk}
?selected=${selected}
>
${key.name} (${key.privateKeyType?.toUpperCase()})
</option>`;
});
}),
html`<option>${t`Loading...`}</option>`,
)}
</select>
<p class="pf-c-form__helper-text">
${t`Deprecated. Instead of using this field, configure the JWKS data/URL in Sources.`}
</p>
<p class="pf-c-form__helper-text">
${t`JWTs signed by certificates configured here can be used to authenticate to the provider.`}
</p>
<p class="pf-c-form__helper-text">
${t`Hold control/command to select multiple items.`}
</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>
</form>`; </form>`;
} }
} }

View File

@ -10,12 +10,14 @@ import {
FlowsInstancesListDesignationEnum, FlowsInstancesListDesignationEnum,
OAuthSource, OAuthSource,
OAuthSourceRequest, OAuthSourceRequest,
ProviderTypeEnum,
SourceType, SourceType,
SourcesApi, SourcesApi,
UserMatchingModeEnum, UserMatchingModeEnum,
} from "@goauthentik/api"; } from "@goauthentik/api";
import { DEFAULT_CONFIG } from "../../../api/Config"; import { DEFAULT_CONFIG } from "../../../api/Config";
import "../../../elements/CodeMirror";
import "../../../elements/forms/FormGroup"; import "../../../elements/forms/FormGroup";
import "../../../elements/forms/HorizontalFormElement"; import "../../../elements/forms/HorizontalFormElement";
import { ModelForm } from "../../../elements/forms/ModelForm"; import { ModelForm } from "../../../elements/forms/ModelForm";
@ -155,6 +157,42 @@ export class OAuthSourceForm extends ModelForm<OAuthSource, string> {
</p> </p>
</ak-form-element-horizontal> ` </ak-form-element-horizontal> `
: html``} : html``}
${this.providerType.slug === ProviderTypeEnum.Openidconnect
? html`
<ak-form-element-horizontal
label=${t`OIDC Well-known URL`}
name="oidcWellKnownUrl"
>
<input
type="text"
value="${ifDefined(this.instance?.oidcWellKnownUrl)}"
class="pf-c-form-control"
/>
<p class="pf-c-form__helper-text">
${t`OIDC well-known configuration URL. Can be used to automatically configure the URLs above.`}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`OIDC JWKS URL`} name="oidcJwksUrl">
<input
type="text"
value="${ifDefined(this.instance?.oidcJwksUrl)}"
class="pf-c-form-control"
/>
<p class="pf-c-form__helper-text">
${t`JSON Web Key URL. Keys from the URL will be used to validate JWTs from this source.`}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`OIDC JWKS`} name="oidcJwks">
<ak-codemirror
mode="javascript"
value="${JSON.stringify(first(this.instance?.oidcJwks, {}))}"
>
</ak-codemirror>
<p class="pf-c-form__helper-text">${t`Raw JWKS data.`}</p>
</ak-form-element-horizontal>
`
: html``}
</div> </div>
</ak-form-group>`; </ak-form-group>`;
} }

View File

@ -3,7 +3,7 @@ title: User settings
--- ---
:::info :::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. The user interface (`/if/user/`) embeds a downsized flow executor to allow the user to configure their profile using custom stages and prompts.

View File

@ -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_. 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: With this configure, any JWT issued by the configured certificates can be used to authenticate:
``` ```

View File

@ -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
```

View File

@ -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. - 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. - 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. - 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).