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:
parent
ab1840dd66
commit
b4e75218f5
|
@ -35,6 +35,7 @@ class OAuth2ProviderSerializer(ProviderSerializer):
|
|||
"property_mappings",
|
||||
"issuer_mode",
|
||||
"verification_keys",
|
||||
"jwks_sources",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
{
|
|
@ -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")
|
|
@ -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"] = "*"
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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}}
|
||||
|
||||
|
|
|
@ -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=""),
|
||||
),
|
||||
]
|
|
@ -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"""
|
||||
|
|
|
@ -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(
|
||||
|
|
57
schema.yml
57
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
|
||||
|
|
|
@ -14,6 +14,7 @@ import {
|
|||
OAuth2Provider,
|
||||
PropertymappingsApi,
|
||||
ProvidersApi,
|
||||
SourcesApi,
|
||||
SubModeEnum,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
|
@ -289,41 +290,6 @@ ${this.instance?.redirectUris}</textarea
|
|||
${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`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`Subject mode`}
|
||||
?required=${true}
|
||||
|
@ -400,6 +366,85 @@ ${this.instance?.redirectUris}</textarea
|
|||
</ak-form-element-horizontal>
|
||||
</div>
|
||||
</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>`;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<OAuthSource, string> {
|
|||
</p>
|
||||
</ak-form-element-horizontal> `
|
||||
: 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>
|
||||
</ak-form-group>`;
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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:
|
||||
|
||||
```
|
||||
|
|
|
@ -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
|
||||
```
|
|
@ -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).
|
||||
|
|
Reference in New Issue