Compare commits
27 Commits
version/20
...
version/20
Author | SHA1 | Date | |
---|---|---|---|
a9776a83d3 | |||
b9faae83b4 | |||
afc2998697 | |||
fabacc56c4 | |||
11b013d3b8 | |||
e10c47d8b8 | |||
d2b194f6b7 | |||
780a59c908 | |||
f8015fccd8 | |||
05f4e738a1 | |||
f535a23c03 | |||
91905530c7 | |||
40a970e321 | |||
b51d8d0ba3 | |||
7e8891338f | |||
3ae0001bb5 | |||
66a4970014 | |||
7ab9300761 | |||
a2eccd5022 | |||
31aeaa247f | |||
f49008bbb6 | |||
feb13c8ee5 | |||
d5ef831718 | |||
64676819ec | |||
7ed268fef4 | |||
f6526d1be9 | |||
12f8b4566b |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 2024.10.1
|
||||
current_version = 2024.10.3
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
from os import environ
|
||||
|
||||
__version__ = "2024.10.1"
|
||||
__version__ = "2024.10.3"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
@ -7,7 +7,7 @@ API Browser - {{ brand.branding_title }}
|
||||
{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{% versioned_script "dist/standalone/api-browser/index-%v.js" %}
|
||||
<script src="{% versioned_script 'dist/standalone/api-browser/index-%v.js' %}" type="module"></script>
|
||||
<meta name="theme-color" content="#151515" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#151515" media="(prefers-color-scheme: dark)">
|
||||
{% endblock %}
|
||||
|
@ -27,7 +27,8 @@ def blueprint_tester(file_name: Path) -> Callable:
|
||||
base = Path("blueprints/")
|
||||
rel_path = Path(file_name).relative_to(base)
|
||||
importer = Importer.from_string(BlueprintInstance(path=str(rel_path)).retrieve())
|
||||
self.assertTrue(importer.validate()[0])
|
||||
validation, logs = importer.validate()
|
||||
self.assertTrue(validation, logs)
|
||||
self.assertTrue(importer.apply())
|
||||
|
||||
return tester
|
||||
|
@ -4,7 +4,7 @@ from collections.abc import Callable
|
||||
|
||||
from django.http.request import HttpRequest
|
||||
from django.http.response import HttpResponse
|
||||
from django.utils.translation import activate
|
||||
from django.utils.translation import override
|
||||
|
||||
from authentik.brands.utils import get_brand_for_request
|
||||
|
||||
@ -18,10 +18,12 @@ class BrandMiddleware:
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request: HttpRequest) -> HttpResponse:
|
||||
locale_to_set = None
|
||||
if not hasattr(request, "brand"):
|
||||
brand = get_brand_for_request(request)
|
||||
request.brand = brand
|
||||
locale = brand.default_locale
|
||||
if locale != "":
|
||||
activate(locale)
|
||||
return self.get_response(request)
|
||||
locale_to_set = locale
|
||||
with override(locale_to_set):
|
||||
return self.get_response(request)
|
||||
|
@ -5,7 +5,7 @@ from contextvars import ContextVar
|
||||
from uuid import uuid4
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.utils.translation import activate
|
||||
from django.utils.translation import override
|
||||
from sentry_sdk.api import set_tag
|
||||
from structlog.contextvars import STRUCTLOG_KEY_PREFIX
|
||||
|
||||
@ -31,17 +31,19 @@ class ImpersonateMiddleware:
|
||||
def __call__(self, request: HttpRequest) -> HttpResponse:
|
||||
# No permission checks are done here, they need to be checked before
|
||||
# SESSION_KEY_IMPERSONATE_USER is set.
|
||||
locale_to_set = None
|
||||
if request.user.is_authenticated:
|
||||
locale = request.user.locale(request)
|
||||
if locale != "":
|
||||
activate(locale)
|
||||
locale_to_set = locale
|
||||
|
||||
if SESSION_KEY_IMPERSONATE_USER in request.session:
|
||||
request.user = request.session[SESSION_KEY_IMPERSONATE_USER]
|
||||
# Ensure that the user is active, otherwise nothing will work
|
||||
request.user.is_active = True
|
||||
|
||||
return self.get_response(request)
|
||||
with override(locale_to_set):
|
||||
return self.get_response(request)
|
||||
|
||||
|
||||
class RequestIDMiddleware:
|
||||
|
@ -129,6 +129,11 @@ class SourceFlowManager:
|
||||
)
|
||||
new_connection.user = self.request.user
|
||||
new_connection = self.update_user_connection(new_connection, **kwargs)
|
||||
if existing := self.user_connection_type.objects.filter(
|
||||
source=self.source, identifier=self.identifier
|
||||
).first():
|
||||
existing = self.update_user_connection(existing)
|
||||
return Action.AUTH, existing
|
||||
return Action.LINK, new_connection
|
||||
|
||||
action, connection = self.matcher.get_user_action(self.identifier, self.user_properties)
|
||||
|
@ -15,8 +15,8 @@
|
||||
{% endblock %}
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/custom.css' %}" data-inject>
|
||||
{% versioned_script "dist/poly-%v.js" %}
|
||||
{% versioned_script "dist/standalone/loading/index-%v.js" %}
|
||||
<script src="{% versioned_script 'dist/poly-%v.js' %}" type="module"></script>
|
||||
<script src="{% versioned_script 'dist/standalone/loading/index-%v.js' %}" type="module"></script>
|
||||
{% block head %}
|
||||
{% endblock %}
|
||||
<meta name="sentry-trace" content="{{ sentry_trace }}" />
|
||||
|
@ -3,7 +3,7 @@
|
||||
{% load authentik_core %}
|
||||
|
||||
{% block head %}
|
||||
{% versioned_script "dist/admin/AdminInterface-%v.js" %}
|
||||
<script src="{% versioned_script 'dist/admin/AdminInterface-%v.js' %}" type="module"></script>
|
||||
<meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)">
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
|
||||
{% include "base/header_js.html" %}
|
||||
|
@ -3,7 +3,7 @@
|
||||
{% load authentik_core %}
|
||||
|
||||
{% block head %}
|
||||
{% versioned_script "dist/user/UserInterface-%v.js" %}
|
||||
<script src="{% versioned_script 'dist/user/UserInterface-%v.js' %}" type="module"></script>
|
||||
<meta name="theme-color" content="#1c1e21" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#1c1e21" media="(prefers-color-scheme: dark)">
|
||||
{% include "base/header_js.html" %}
|
||||
|
@ -2,7 +2,6 @@
|
||||
|
||||
from django import template
|
||||
from django.templatetags.static import static as static_loader
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from authentik import get_full_version
|
||||
|
||||
@ -12,10 +11,4 @@ register = template.Library()
|
||||
@register.simple_tag()
|
||||
def versioned_script(path: str) -> str:
|
||||
"""Wrapper around {% static %} tag that supports setting the version"""
|
||||
returned_lines = [
|
||||
(
|
||||
f'<script src="{static_loader(path.replace("%v", get_full_version()))}'
|
||||
'" type="module"></script>'
|
||||
),
|
||||
]
|
||||
return mark_safe("".join(returned_lines)) # nosec
|
||||
return static_loader(path.replace("%v", get_full_version()))
|
||||
|
@ -12,7 +12,7 @@ from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.policies.dummy.models import DummyPolicy
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.providers.oauth2.models import OAuth2Provider
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, RedirectURI, RedirectURIMatchingMode
|
||||
from authentik.providers.proxy.models import ProxyProvider
|
||||
from authentik.providers.saml.models import SAMLProvider
|
||||
|
||||
@ -24,7 +24,7 @@ class TestApplicationsAPI(APITestCase):
|
||||
self.user = create_test_admin_user()
|
||||
self.provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
redirect_uris="http://some-other-domain",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://some-other-domain")],
|
||||
authorization_flow=create_test_flow(),
|
||||
)
|
||||
self.allowed: Application = Application.objects.create(
|
||||
|
@ -81,6 +81,22 @@ class TestSourceFlowManager(TestCase):
|
||||
reverse("authentik_core:if-user") + "#/settings;page-sources",
|
||||
)
|
||||
|
||||
def test_authenticated_auth(self):
|
||||
"""Test authenticated user linking"""
|
||||
user = User.objects.create(username="foo", email="foo@bar.baz")
|
||||
UserOAuthSourceConnection.objects.create(
|
||||
user=user, source=self.source, identifier=self.identifier
|
||||
)
|
||||
request = get_request("/", user=user)
|
||||
flow_manager = OAuthSourceFlowManager(
|
||||
self.source, request, self.identifier, {"info": {}}, {}
|
||||
)
|
||||
action, connection = flow_manager.get_action()
|
||||
self.assertEqual(action, Action.AUTH)
|
||||
self.assertIsNotNone(connection.pk)
|
||||
response = flow_manager.get_flow()
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_unauthenticated_link(self):
|
||||
"""Test un-authenticated user linking"""
|
||||
flow_manager = OAuthSourceFlowManager(
|
||||
|
@ -31,6 +31,7 @@ class TestTransactionalApplicationsAPI(APITestCase):
|
||||
"name": uid,
|
||||
"authorization_flow": str(create_test_flow().pk),
|
||||
"invalidation_flow": str(create_test_flow().pk),
|
||||
"redirect_uris": [],
|
||||
},
|
||||
},
|
||||
)
|
||||
@ -57,6 +58,7 @@ class TestTransactionalApplicationsAPI(APITestCase):
|
||||
"name": uid,
|
||||
"authorization_flow": "",
|
||||
"invalidation_flow": "",
|
||||
"redirect_uris": [],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
@ -24,6 +24,7 @@ from rest_framework.fields import (
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.validators import UniqueValidator
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
@ -181,7 +182,10 @@ class CertificateDataSerializer(PassiveSerializer):
|
||||
class CertificateGenerationSerializer(PassiveSerializer):
|
||||
"""Certificate generation parameters"""
|
||||
|
||||
common_name = CharField()
|
||||
common_name = CharField(
|
||||
validators=[UniqueValidator(queryset=CertificateKeyPair.objects.all())],
|
||||
source="name",
|
||||
)
|
||||
subject_alt_name = CharField(required=False, allow_blank=True, label=_("Subject-alt name"))
|
||||
validity_days = IntegerField(initial=365)
|
||||
alg = ChoiceField(default=PrivateKeyAlg.RSA, choices=PrivateKeyAlg.choices)
|
||||
@ -242,11 +246,10 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
|
||||
def generate(self, request: Request) -> Response:
|
||||
"""Generate a new, self-signed certificate-key pair"""
|
||||
data = CertificateGenerationSerializer(data=request.data)
|
||||
if not data.is_valid():
|
||||
return Response(data.errors, status=400)
|
||||
data.is_valid(raise_exception=True)
|
||||
raw_san = data.validated_data.get("subject_alt_name", "")
|
||||
sans = raw_san.split(",") if raw_san != "" else []
|
||||
builder = CertificateBuilder(data.validated_data["common_name"])
|
||||
builder = CertificateBuilder(data.validated_data["name"])
|
||||
builder.alg = data.validated_data["alg"]
|
||||
builder.build(
|
||||
subject_alt_names=sans,
|
||||
|
@ -18,7 +18,7 @@ from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.crypto.tasks import MANAGED_DISCOVERED, certificate_discovery
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.providers.oauth2.models import OAuth2Provider
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, RedirectURI, RedirectURIMatchingMode
|
||||
|
||||
|
||||
class TestCrypto(APITestCase):
|
||||
@ -89,6 +89,17 @@ class TestCrypto(APITestCase):
|
||||
self.assertIsInstance(ext[1], DNSName)
|
||||
self.assertEqual(ext[1].value, "baz")
|
||||
|
||||
def test_builder_api_duplicate(self):
|
||||
"""Test Builder (via API)"""
|
||||
cert = create_test_cert()
|
||||
self.client.force_login(create_test_admin_user())
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:certificatekeypair-generate"),
|
||||
data={"common_name": cert.name, "subject_alt_name": "bar,baz", "validity_days": 3},
|
||||
)
|
||||
self.assertEqual(res.status_code, 400)
|
||||
self.assertJSONEqual(res.content, {"common_name": ["This field must be unique."]})
|
||||
|
||||
def test_builder_api_empty_san(self):
|
||||
"""Test Builder (via API)"""
|
||||
self.client.force_login(create_test_admin_user())
|
||||
@ -263,7 +274,7 @@ class TestCrypto(APITestCase):
|
||||
client_id="test",
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://localhost",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
|
||||
signing_key=keypair,
|
||||
)
|
||||
response = self.client.get(
|
||||
@ -295,7 +306,7 @@ class TestCrypto(APITestCase):
|
||||
client_id="test",
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://localhost",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
|
||||
signing_key=keypair,
|
||||
)
|
||||
response = self.client.get(
|
||||
|
@ -3,7 +3,7 @@
|
||||
{% load authentik_core %}
|
||||
|
||||
{% block head %}
|
||||
{% versioned_script "dist/enterprise/rac/index-%v.js" %}
|
||||
<script src="{% versioned_script 'dist/enterprise/rac/index-%v.js' %}" type="module"></script>
|
||||
<meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)">
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
|
||||
<link rel="icon" href="{{ tenant.branding_favicon }}">
|
||||
|
@ -18,7 +18,7 @@ window.authentik.flow = {
|
||||
{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{% versioned_script "dist/flow/FlowInterface-%v.js" %}
|
||||
<script src="{% versioned_script 'dist/flow/FlowInterface-%v.js' %}" type="module"></script>
|
||||
<style>
|
||||
:root {
|
||||
--ak-flow-background: url("{{ flow.background_url }}");
|
||||
|
@ -89,6 +89,10 @@ class PasswordPolicy(Policy):
|
||||
|
||||
def passes_static(self, password: str, request: PolicyRequest) -> PolicyResult:
|
||||
"""Check static rules"""
|
||||
error_message = self.error_message
|
||||
if error_message == "":
|
||||
error_message = _("Invalid password.")
|
||||
|
||||
if len(password) < self.length_min:
|
||||
LOGGER.debug("password failed", check="static", reason="length")
|
||||
return PolicyResult(False, self.error_message)
|
||||
|
@ -159,7 +159,10 @@ class LDAPOutpostConfigViewSet(ListModelMixin, GenericViewSet):
|
||||
access_response = PolicyResult(result.passing)
|
||||
response = self.LDAPCheckAccessSerializer(
|
||||
instance={
|
||||
"has_search_permission": request.user.has_perm("search_full_directory", provider),
|
||||
"has_search_permission": (
|
||||
request.user.has_perm("search_full_directory", provider)
|
||||
or request.user.has_perm("authentik_providers_ldap.search_full_directory")
|
||||
),
|
||||
"access": access_response,
|
||||
}
|
||||
)
|
||||
|
@ -1,15 +1,18 @@
|
||||
"""OAuth2Provider API Views"""
|
||||
|
||||
from copy import copy
|
||||
from re import compile
|
||||
from re import error as RegexError
|
||||
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import CharField
|
||||
from rest_framework.fields import CharField, ChoiceField
|
||||
from rest_framework.generics import get_object_or_404
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
@ -20,13 +23,39 @@ from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import PassiveSerializer, PropertyMappingPreviewSerializer
|
||||
from authentik.core.models import Provider
|
||||
from authentik.providers.oauth2.id_token import IDToken
|
||||
from authentik.providers.oauth2.models import AccessToken, OAuth2Provider, ScopeMapping
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
OAuth2Provider,
|
||||
RedirectURIMatchingMode,
|
||||
ScopeMapping,
|
||||
)
|
||||
from authentik.rbac.decorators import permission_required
|
||||
|
||||
|
||||
class RedirectURISerializer(PassiveSerializer):
|
||||
"""A single allowed redirect URI entry"""
|
||||
|
||||
matching_mode = ChoiceField(choices=RedirectURIMatchingMode.choices)
|
||||
url = CharField()
|
||||
|
||||
|
||||
class OAuth2ProviderSerializer(ProviderSerializer):
|
||||
"""OAuth2Provider Serializer"""
|
||||
|
||||
redirect_uris = RedirectURISerializer(many=True, source="_redirect_uris")
|
||||
|
||||
def validate_redirect_uris(self, data: list) -> list:
|
||||
for entry in data:
|
||||
if entry.get("matching_mode") == RedirectURIMatchingMode.REGEX:
|
||||
url = entry.get("url")
|
||||
try:
|
||||
compile(url)
|
||||
except RegexError:
|
||||
raise ValidationError(
|
||||
_("Invalid Regex Pattern: {url}".format(url=url))
|
||||
) from None
|
||||
return data
|
||||
|
||||
class Meta:
|
||||
model = OAuth2Provider
|
||||
fields = ProviderSerializer.Meta.fields + [
|
||||
@ -79,7 +108,6 @@ class OAuth2ProviderViewSet(UsedByMixin, ModelViewSet):
|
||||
"refresh_token_validity",
|
||||
"include_claims_in_id_token",
|
||||
"signing_key",
|
||||
"redirect_uris",
|
||||
"sub_mode",
|
||||
"property_mappings",
|
||||
"issuer_mode",
|
||||
|
@ -7,7 +7,7 @@ from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.sentry import SentryIgnoredException
|
||||
from authentik.lib.views import bad_request_message
|
||||
from authentik.providers.oauth2.models import GrantTypes
|
||||
from authentik.providers.oauth2.models import GrantTypes, RedirectURI
|
||||
|
||||
|
||||
class OAuth2Error(SentryIgnoredException):
|
||||
@ -46,9 +46,9 @@ class RedirectUriError(OAuth2Error):
|
||||
)
|
||||
|
||||
provided_uri: str
|
||||
allowed_uris: list[str]
|
||||
allowed_uris: list[RedirectURI]
|
||||
|
||||
def __init__(self, provided_uri: str, allowed_uris: list[str]) -> None:
|
||||
def __init__(self, provided_uri: str, allowed_uris: list[RedirectURI]) -> None:
|
||||
super().__init__()
|
||||
self.provided_uri = provided_uri
|
||||
self.allowed_uris = allowed_uris
|
||||
|
@ -0,0 +1,48 @@
|
||||
# Generated by Django 5.0.9 on 2024-11-04 12:56
|
||||
from django.apps.registry import Apps
|
||||
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def migrate_redirect_uris(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
from authentik.providers.oauth2.models import RedirectURI, RedirectURIMatchingMode
|
||||
|
||||
OAuth2Provider = apps.get_model("authentik_providers_oauth2", "oauth2provider")
|
||||
|
||||
db_alias = schema_editor.connection.alias
|
||||
for provider in OAuth2Provider.objects.using(db_alias).all():
|
||||
uris = []
|
||||
for old in provider.old_redirect_uris.split("\n"):
|
||||
mode = RedirectURIMatchingMode.STRICT
|
||||
if old == "*" or old == ".*":
|
||||
mode = RedirectURIMatchingMode.REGEX
|
||||
uris.append(RedirectURI(mode, url=old))
|
||||
provider.redirect_uris = uris
|
||||
provider.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_providers_oauth2", "0023_alter_accesstoken_refreshtoken_use_hash_index"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="oauth2provider",
|
||||
old_name="redirect_uris",
|
||||
new_name="old_redirect_uris",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="oauth2provider",
|
||||
name="_redirect_uris",
|
||||
field=models.JSONField(default=dict, verbose_name="Redirect URIs"),
|
||||
),
|
||||
migrations.RunPython(migrate_redirect_uris, lambda *args: ...),
|
||||
migrations.RemoveField(
|
||||
model_name="oauth2provider",
|
||||
name="old_redirect_uris",
|
||||
),
|
||||
]
|
@ -3,7 +3,7 @@
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
from dataclasses import asdict
|
||||
from dataclasses import asdict, dataclass
|
||||
from functools import cached_property
|
||||
from hashlib import sha256
|
||||
from typing import Any
|
||||
@ -12,6 +12,7 @@ from urllib.parse import urlparse, urlunparse
|
||||
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
||||
from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes
|
||||
from dacite import Config
|
||||
from dacite.core import from_dict
|
||||
from django.contrib.postgres.indexes import HashIndex
|
||||
from django.db import models
|
||||
@ -77,11 +78,25 @@ class IssuerMode(models.TextChoices):
|
||||
"""Configure how the `iss` field is created."""
|
||||
|
||||
GLOBAL = "global", _("Same identifier is used for all providers")
|
||||
PER_PROVIDER = "per_provider", _(
|
||||
"Each provider has a different issuer, based on the application slug."
|
||||
PER_PROVIDER = (
|
||||
"per_provider",
|
||||
_("Each provider has a different issuer, based on the application slug."),
|
||||
)
|
||||
|
||||
|
||||
class RedirectURIMatchingMode(models.TextChoices):
|
||||
STRICT = "strict", _("Strict URL comparison")
|
||||
REGEX = "regex", _("Regular Expression URL matching")
|
||||
|
||||
|
||||
@dataclass
|
||||
class RedirectURI:
|
||||
"""A single redirect URI entry"""
|
||||
|
||||
matching_mode: RedirectURIMatchingMode
|
||||
url: str
|
||||
|
||||
|
||||
class ResponseTypes(models.TextChoices):
|
||||
"""Response Type required by the client."""
|
||||
|
||||
@ -156,11 +171,9 @@ class OAuth2Provider(WebfingerProvider, Provider):
|
||||
verbose_name=_("Client Secret"),
|
||||
default=generate_client_secret,
|
||||
)
|
||||
redirect_uris = models.TextField(
|
||||
default="",
|
||||
blank=True,
|
||||
_redirect_uris = models.JSONField(
|
||||
default=dict,
|
||||
verbose_name=_("Redirect URIs"),
|
||||
help_text=_("Enter each URI on a new line."),
|
||||
)
|
||||
|
||||
include_claims_in_id_token = models.BooleanField(
|
||||
@ -271,12 +284,33 @@ class OAuth2Provider(WebfingerProvider, Provider):
|
||||
except Provider.application.RelatedObjectDoesNotExist:
|
||||
return None
|
||||
|
||||
@property
|
||||
def redirect_uris(self) -> list[RedirectURI]:
|
||||
uris = []
|
||||
for entry in self._redirect_uris:
|
||||
uris.append(
|
||||
from_dict(
|
||||
RedirectURI,
|
||||
entry,
|
||||
config=Config(type_hooks={RedirectURIMatchingMode: RedirectURIMatchingMode}),
|
||||
)
|
||||
)
|
||||
return uris
|
||||
|
||||
@redirect_uris.setter
|
||||
def redirect_uris(self, value: list[RedirectURI]):
|
||||
cleansed = []
|
||||
for entry in value:
|
||||
cleansed.append(asdict(entry))
|
||||
self._redirect_uris = cleansed
|
||||
|
||||
@property
|
||||
def launch_url(self) -> str | None:
|
||||
"""Guess launch_url based on first redirect_uri"""
|
||||
if self.redirect_uris == "":
|
||||
redirects = self.redirect_uris
|
||||
if len(redirects) < 1:
|
||||
return None
|
||||
main_url = self.redirect_uris.split("\n", maxsplit=1)[0]
|
||||
main_url = redirects[0].url
|
||||
try:
|
||||
launch_url = urlparse(main_url)._replace(path="")
|
||||
return urlunparse(launch_url)
|
||||
|
@ -10,7 +10,13 @@ from rest_framework.test import APITestCase
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.models import (
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
ScopeMapping,
|
||||
)
|
||||
|
||||
|
||||
class TestAPI(APITestCase):
|
||||
@ -21,7 +27,7 @@ class TestAPI(APITestCase):
|
||||
self.provider: OAuth2Provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://testserver",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
)
|
||||
self.provider.property_mappings.set(ScopeMapping.objects.all())
|
||||
self.app = Application.objects.create(name="test", slug="test", provider=self.provider)
|
||||
@ -50,9 +56,29 @@ class TestAPI(APITestCase):
|
||||
@skipUnless(version_info >= (3, 11, 4), "This behaviour is only Python 3.11.4 and up")
|
||||
def test_launch_url(self):
|
||||
"""Test launch_url"""
|
||||
self.provider.redirect_uris = (
|
||||
"https://[\\d\\w]+.pr.test.goauthentik.io/source/oauth/callback/authentik/\n"
|
||||
)
|
||||
self.provider.redirect_uris = [
|
||||
RedirectURI(
|
||||
RedirectURIMatchingMode.REGEX,
|
||||
"https://[\\d\\w]+.pr.test.goauthentik.io/source/oauth/callback/authentik/",
|
||||
),
|
||||
]
|
||||
self.provider.save()
|
||||
self.provider.refresh_from_db()
|
||||
self.assertIsNone(self.provider.launch_url)
|
||||
|
||||
def test_validate_redirect_uris(self):
|
||||
"""Test redirect_uris API"""
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:oauth2provider-list"),
|
||||
data={
|
||||
"name": generate_id(),
|
||||
"authorization_flow": create_test_flow().pk,
|
||||
"invalidation_flow": create_test_flow().pk,
|
||||
"redirect_uris": [
|
||||
{"matching_mode": "strict", "url": "http://goauthentik.io"},
|
||||
{"matching_mode": "regex", "url": "**"},
|
||||
],
|
||||
},
|
||||
)
|
||||
self.assertJSONEqual(response.content, {"redirect_uris": ["Invalid Regex Pattern: **"]})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
@ -19,6 +19,8 @@ from authentik.providers.oauth2.models import (
|
||||
AuthorizationCode,
|
||||
GrantTypes,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
ScopeMapping,
|
||||
)
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
@ -39,7 +41,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid/Foo",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid/Foo")],
|
||||
)
|
||||
with self.assertRaises(AuthorizeError):
|
||||
request = self.factory.get(
|
||||
@ -64,7 +66,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid/Foo",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid/Foo")],
|
||||
)
|
||||
with self.assertRaises(AuthorizeError):
|
||||
request = self.factory.get(
|
||||
@ -84,7 +86,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
)
|
||||
with self.assertRaises(RedirectUriError):
|
||||
request = self.factory.get("/", data={"response_type": "code", "client_id": "test"})
|
||||
@ -106,7 +108,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="data:local.invalid",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "data:local.invalid")],
|
||||
)
|
||||
with self.assertRaises(RedirectUriError):
|
||||
request = self.factory.get(
|
||||
@ -125,7 +127,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="",
|
||||
redirect_uris=[],
|
||||
)
|
||||
with self.assertRaises(RedirectUriError):
|
||||
request = self.factory.get("/", data={"response_type": "code", "client_id": "test"})
|
||||
@ -140,7 +142,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
)
|
||||
OAuthAuthorizationParams.from_request(request)
|
||||
provider.refresh_from_db()
|
||||
self.assertEqual(provider.redirect_uris, "+")
|
||||
self.assertEqual(provider.redirect_uris, [RedirectURI(RedirectURIMatchingMode.STRICT, "+")])
|
||||
|
||||
def test_invalid_redirect_uri_regex(self):
|
||||
"""test missing/invalid redirect URI"""
|
||||
@ -148,7 +150,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid?",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid?")],
|
||||
)
|
||||
with self.assertRaises(RedirectUriError):
|
||||
request = self.factory.get("/", data={"response_type": "code", "client_id": "test"})
|
||||
@ -170,7 +172,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="+",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "+")],
|
||||
)
|
||||
with self.assertRaises(RedirectUriError):
|
||||
request = self.factory.get("/", data={"response_type": "code", "client_id": "test"})
|
||||
@ -213,7 +215,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid/Foo",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid/Foo")],
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
ScopeMapping.objects.filter(
|
||||
@ -301,7 +303,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=flow,
|
||||
redirect_uris="foo://localhost",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
|
||||
access_code_validity="seconds=100",
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
@ -343,7 +345,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=flow,
|
||||
redirect_uris="http://localhost",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
@ -420,7 +422,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=flow,
|
||||
redirect_uris="http://localhost",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
|
||||
signing_key=self.keypair,
|
||||
encryption_key=self.keypair,
|
||||
)
|
||||
@ -486,7 +488,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=flow,
|
||||
redirect_uris="http://localhost",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
@ -541,7 +543,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
authorization_flow=flow,
|
||||
redirect_uris="http://localhost",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
@ -599,7 +601,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
authorization_flow=flow,
|
||||
redirect_uris="http://localhost",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
|
||||
|
@ -3,6 +3,7 @@
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from authentik.core.models import Application, Group
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow
|
||||
@ -34,7 +35,10 @@ class TesOAuth2DeviceInit(OAuthTestCase):
|
||||
self.brand.flow_device_code = self.device_flow
|
||||
self.brand.save()
|
||||
|
||||
def test_device_init(self):
|
||||
self.api_client = APIClient()
|
||||
self.api_client.force_login(self.user)
|
||||
|
||||
def test_device_init_get(self):
|
||||
"""Test device init"""
|
||||
res = self.client.get(reverse("authentik_providers_oauth2_root:device-login"))
|
||||
self.assertEqual(res.status_code, 302)
|
||||
@ -48,6 +52,76 @@ class TesOAuth2DeviceInit(OAuthTestCase):
|
||||
),
|
||||
)
|
||||
|
||||
def test_device_init_post(self):
|
||||
"""Test device init"""
|
||||
res = self.api_client.get(reverse("authentik_providers_oauth2_root:device-login"))
|
||||
self.assertEqual(res.status_code, 302)
|
||||
self.assertEqual(
|
||||
res.url,
|
||||
reverse(
|
||||
"authentik_core:if-flow",
|
||||
kwargs={
|
||||
"flow_slug": self.device_flow.slug,
|
||||
},
|
||||
),
|
||||
)
|
||||
res = self.api_client.get(
|
||||
reverse(
|
||||
"authentik_api:flow-executor",
|
||||
kwargs={
|
||||
"flow_slug": self.device_flow.slug,
|
||||
},
|
||||
),
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertJSONEqual(
|
||||
res.content,
|
||||
{
|
||||
"component": "ak-provider-oauth2-device-code",
|
||||
"flow_info": {
|
||||
"background": "/static/dist/assets/images/flow_background.jpg",
|
||||
"cancel_url": "/flows/-/cancel/",
|
||||
"layout": "stacked",
|
||||
"title": self.device_flow.title,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
)
|
||||
Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
|
||||
token = DeviceToken.objects.create(
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
res = self.api_client.post(
|
||||
reverse(
|
||||
"authentik_api:flow-executor",
|
||||
kwargs={
|
||||
"flow_slug": self.device_flow.slug,
|
||||
},
|
||||
),
|
||||
data={
|
||||
"component": "ak-provider-oauth2-device-code",
|
||||
"code": token.user_code,
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertJSONEqual(
|
||||
res.content,
|
||||
{
|
||||
"component": "xak-flow-redirect",
|
||||
"to": reverse(
|
||||
"authentik_core:if-flow",
|
||||
kwargs={
|
||||
"flow_slug": provider.authorization_flow.slug,
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
def test_no_flow(self):
|
||||
"""Test no flow"""
|
||||
self.brand.flow_device_code = None
|
||||
|
@ -11,7 +11,14 @@ from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.constants import ACR_AUTHENTIK_DEFAULT
|
||||
from authentik.providers.oauth2.models import AccessToken, IDToken, OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
IDToken,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
RefreshToken,
|
||||
)
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
|
||||
|
||||
@ -23,7 +30,7 @@ class TesOAuth2Introspection(OAuthTestCase):
|
||||
self.provider: OAuth2Provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "")],
|
||||
signing_key=create_test_cert(),
|
||||
)
|
||||
self.app = Application.objects.create(
|
||||
@ -118,7 +125,7 @@ class TesOAuth2Introspection(OAuthTestCase):
|
||||
provider: OAuth2Provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "")],
|
||||
signing_key=create_test_cert(),
|
||||
)
|
||||
auth = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
|
@ -13,7 +13,7 @@ from authentik.core.tests.utils import create_test_cert, create_test_flow
|
||||
from authentik.crypto.builder import PrivateKeyAlg
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.models import OAuth2Provider
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, RedirectURI, RedirectURIMatchingMode
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
|
||||
TEST_CORDS_CERT = """
|
||||
@ -49,7 +49,7 @@ class TestJWKS(OAuthTestCase):
|
||||
name="test",
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
signing_key=create_test_cert(),
|
||||
)
|
||||
app = Application.objects.create(name="test", slug="test", provider=provider)
|
||||
@ -68,7 +68,7 @@ class TestJWKS(OAuthTestCase):
|
||||
name="test",
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
)
|
||||
app = Application.objects.create(name="test", slug="test", provider=provider)
|
||||
response = self.client.get(
|
||||
@ -82,7 +82,7 @@ class TestJWKS(OAuthTestCase):
|
||||
name="test",
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
signing_key=create_test_cert(PrivateKeyAlg.ECDSA),
|
||||
)
|
||||
app = Application.objects.create(name="test", slug="test", provider=provider)
|
||||
@ -99,7 +99,7 @@ class TestJWKS(OAuthTestCase):
|
||||
name="test",
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
signing_key=create_test_cert(PrivateKeyAlg.ECDSA),
|
||||
encryption_key=create_test_cert(PrivateKeyAlg.ECDSA),
|
||||
)
|
||||
@ -122,7 +122,7 @@ class TestJWKS(OAuthTestCase):
|
||||
name="test",
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
signing_key=cert,
|
||||
)
|
||||
app = Application.objects.create(name="test", slug="test", provider=provider)
|
||||
|
@ -10,7 +10,14 @@ from django.utils import timezone
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.models import AccessToken, IDToken, OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
IDToken,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
RefreshToken,
|
||||
)
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
|
||||
|
||||
@ -22,7 +29,7 @@ class TesOAuth2Revoke(OAuthTestCase):
|
||||
self.provider: OAuth2Provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "")],
|
||||
signing_key=create_test_cert(),
|
||||
)
|
||||
self.app = Application.objects.create(
|
||||
|
@ -22,6 +22,8 @@ from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
AuthorizationCode,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
RefreshToken,
|
||||
ScopeMapping,
|
||||
)
|
||||
@ -42,7 +44,7 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://TestServer",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://TestServer")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
@ -69,7 +71,7 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://testserver",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
@ -90,7 +92,7 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
@ -118,7 +120,7 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
# Needs to be assigned to an application for iss to be set
|
||||
@ -157,7 +159,7 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
signing_key=self.keypair,
|
||||
encryption_key=self.keypair,
|
||||
)
|
||||
@ -188,7 +190,7 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
@ -250,7 +252,7 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
@ -308,7 +310,7 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://testserver",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
|
@ -19,7 +19,12 @@ from authentik.providers.oauth2.constants import (
|
||||
SCOPE_OPENID_PROFILE,
|
||||
TOKEN_TYPE,
|
||||
)
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
|
||||
from authentik.providers.oauth2.models import (
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
ScopeMapping,
|
||||
)
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
from authentik.providers.oauth2.views.jwks import JWKSView
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
@ -54,7 +59,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase):
|
||||
self.provider: OAuth2Provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://testserver",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
signing_key=self.cert,
|
||||
)
|
||||
self.provider.jwks_sources.add(self.source)
|
||||
|
@ -19,7 +19,13 @@ from authentik.providers.oauth2.constants import (
|
||||
TOKEN_TYPE,
|
||||
)
|
||||
from authentik.providers.oauth2.errors import TokenError
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
ScopeMapping,
|
||||
)
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
|
||||
|
||||
@ -33,7 +39,7 @@ class TestTokenClientCredentialsStandard(OAuthTestCase):
|
||||
self.provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://testserver",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
signing_key=create_test_cert(),
|
||||
)
|
||||
self.provider.property_mappings.set(ScopeMapping.objects.all())
|
||||
@ -107,6 +113,48 @@ class TestTokenClientCredentialsStandard(OAuthTestCase):
|
||||
{"error": "invalid_grant", "error_description": TokenError.errors["invalid_grant"]},
|
||||
)
|
||||
|
||||
def test_incorrect_scopes(self):
|
||||
"""test scope that isn't configured"""
|
||||
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} extra_scope",
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["token_type"], TOKEN_TYPE)
|
||||
token = AccessToken.objects.filter(
|
||||
provider=self.provider, token=body["access_token"]
|
||||
).first()
|
||||
self.assertSetEqual(
|
||||
set(token.scope), {SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE}
|
||||
)
|
||||
_, alg = self.provider.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)"
|
||||
)
|
||||
self.assertEqual(jwt["preferred_username"], "ak-test-client_credentials")
|
||||
jwt = decode(
|
||||
body["id_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)"
|
||||
)
|
||||
self.assertEqual(jwt["preferred_username"], "ak-test-client_credentials")
|
||||
|
||||
def test_successful(self):
|
||||
"""test successful"""
|
||||
response = self.client.post(
|
||||
|
@ -20,7 +20,12 @@ from authentik.providers.oauth2.constants import (
|
||||
TOKEN_TYPE,
|
||||
)
|
||||
from authentik.providers.oauth2.errors import TokenError
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
|
||||
from authentik.providers.oauth2.models import (
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
ScopeMapping,
|
||||
)
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
|
||||
|
||||
@ -34,7 +39,7 @@ class TestTokenClientCredentialsStandardCompat(OAuthTestCase):
|
||||
self.provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://testserver",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
signing_key=create_test_cert(),
|
||||
)
|
||||
self.provider.property_mappings.set(ScopeMapping.objects.all())
|
||||
|
@ -19,7 +19,12 @@ from authentik.providers.oauth2.constants import (
|
||||
TOKEN_TYPE,
|
||||
)
|
||||
from authentik.providers.oauth2.errors import TokenError
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
|
||||
from authentik.providers.oauth2.models import (
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
ScopeMapping,
|
||||
)
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
|
||||
|
||||
@ -33,7 +38,7 @@ class TestTokenClientCredentialsUserNamePassword(OAuthTestCase):
|
||||
self.provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://testserver",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
signing_key=create_test_cert(),
|
||||
)
|
||||
self.provider.property_mappings.set(ScopeMapping.objects.all())
|
||||
|
@ -9,8 +9,19 @@ from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||
from authentik.lib.generators import generate_code_fixed_length, generate_id
|
||||
from authentik.providers.oauth2.constants import GRANT_TYPE_DEVICE_CODE
|
||||
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider, ScopeMapping
|
||||
from authentik.providers.oauth2.constants import (
|
||||
GRANT_TYPE_DEVICE_CODE,
|
||||
SCOPE_OPENID,
|
||||
SCOPE_OPENID_EMAIL,
|
||||
)
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
DeviceToken,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
ScopeMapping,
|
||||
)
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
|
||||
|
||||
@ -24,7 +35,7 @@ class TestTokenDeviceCode(OAuthTestCase):
|
||||
self.provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://testserver",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
signing_key=create_test_cert(),
|
||||
)
|
||||
self.provider.property_mappings.set(ScopeMapping.objects.all())
|
||||
@ -80,3 +91,28 @@ class TestTokenDeviceCode(OAuthTestCase):
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_code_mismatched_scope(self):
|
||||
"""Test code with user (mismatched scopes)"""
|
||||
device_token = DeviceToken.objects.create(
|
||||
provider=self.provider,
|
||||
user_code=generate_code_fixed_length(),
|
||||
device_code=generate_id(),
|
||||
user=self.user,
|
||||
scope=[SCOPE_OPENID, SCOPE_OPENID_EMAIL],
|
||||
)
|
||||
res = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
data={
|
||||
"client_id": self.provider.client_id,
|
||||
"grant_type": GRANT_TYPE_DEVICE_CODE,
|
||||
"device_code": device_token.device_code,
|
||||
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} invalid",
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
body = loads(res.content)
|
||||
token = AccessToken.objects.filter(
|
||||
provider=self.provider, token=body["access_token"]
|
||||
).first()
|
||||
self.assertSetEqual(set(token.scope), {SCOPE_OPENID, SCOPE_OPENID_EMAIL})
|
||||
|
@ -10,7 +10,12 @@ from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.constants import GRANT_TYPE_AUTHORIZATION_CODE
|
||||
from authentik.providers.oauth2.models import AuthorizationCode, OAuth2Provider
|
||||
from authentik.providers.oauth2.models import (
|
||||
AuthorizationCode,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
)
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
|
||||
|
||||
@ -30,7 +35,7 @@ class TestTokenPKCE(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=flow,
|
||||
redirect_uris="foo://localhost",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
|
||||
access_code_validity="seconds=100",
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
@ -93,7 +98,7 @@ class TestTokenPKCE(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=flow,
|
||||
redirect_uris="foo://localhost",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
|
||||
access_code_validity="seconds=100",
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
@ -154,7 +159,7 @@ class TestTokenPKCE(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=flow,
|
||||
redirect_uris="foo://localhost",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
|
||||
access_code_validity="seconds=100",
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
@ -210,7 +215,7 @@ class TestTokenPKCE(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=flow,
|
||||
redirect_uris="foo://localhost",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
|
||||
access_code_validity="seconds=100",
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
|
@ -11,7 +11,14 @@ from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.models import AccessToken, IDToken, OAuth2Provider, ScopeMapping
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
IDToken,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
ScopeMapping,
|
||||
)
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
|
||||
|
||||
@ -25,7 +32,7 @@ class TestUserinfo(OAuthTestCase):
|
||||
self.provider: OAuth2Provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "")],
|
||||
signing_key=create_test_cert(),
|
||||
)
|
||||
self.provider.property_mappings.set(ScopeMapping.objects.all())
|
||||
|
@ -56,6 +56,8 @@ from authentik.providers.oauth2.models import (
|
||||
AuthorizationCode,
|
||||
GrantTypes,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
ResponseMode,
|
||||
ResponseTypes,
|
||||
ScopeMapping,
|
||||
@ -187,40 +189,39 @@ class OAuthAuthorizationParams:
|
||||
|
||||
def check_redirect_uri(self):
|
||||
"""Redirect URI validation."""
|
||||
allowed_redirect_urls = self.provider.redirect_uris.split()
|
||||
allowed_redirect_urls = self.provider.redirect_uris
|
||||
if not self.redirect_uri:
|
||||
LOGGER.warning("Missing redirect uri.")
|
||||
raise RedirectUriError("", allowed_redirect_urls)
|
||||
|
||||
if self.provider.redirect_uris == "":
|
||||
if len(allowed_redirect_urls) < 1:
|
||||
LOGGER.info("Setting redirect for blank redirect_uris", redirect=self.redirect_uri)
|
||||
self.provider.redirect_uris = self.redirect_uri
|
||||
self.provider.redirect_uris = [
|
||||
RedirectURI(RedirectURIMatchingMode.STRICT, self.redirect_uri)
|
||||
]
|
||||
self.provider.save()
|
||||
allowed_redirect_urls = self.provider.redirect_uris.split()
|
||||
allowed_redirect_urls = self.provider.redirect_uris
|
||||
|
||||
if self.provider.redirect_uris == "*":
|
||||
LOGGER.info("Converting redirect_uris to regex", redirect=self.redirect_uri)
|
||||
self.provider.redirect_uris = ".*"
|
||||
self.provider.save()
|
||||
allowed_redirect_urls = self.provider.redirect_uris.split()
|
||||
|
||||
try:
|
||||
if not any(fullmatch(x, self.redirect_uri) for x in allowed_redirect_urls):
|
||||
LOGGER.warning(
|
||||
"Invalid redirect uri (regex comparison)",
|
||||
redirect_uri_given=self.redirect_uri,
|
||||
redirect_uri_expected=allowed_redirect_urls,
|
||||
)
|
||||
raise RedirectUriError(self.redirect_uri, allowed_redirect_urls)
|
||||
except RegexError as exc:
|
||||
LOGGER.info("Failed to parse regular expression, checking directly", exc=exc)
|
||||
if not any(x == self.redirect_uri for x in allowed_redirect_urls):
|
||||
LOGGER.warning(
|
||||
"Invalid redirect uri (strict comparison)",
|
||||
redirect_uri_given=self.redirect_uri,
|
||||
redirect_uri_expected=allowed_redirect_urls,
|
||||
)
|
||||
raise RedirectUriError(self.redirect_uri, allowed_redirect_urls) from None
|
||||
match_found = False
|
||||
for allowed in allowed_redirect_urls:
|
||||
if allowed.matching_mode == RedirectURIMatchingMode.STRICT:
|
||||
if self.redirect_uri == allowed.url:
|
||||
match_found = True
|
||||
break
|
||||
if allowed.matching_mode == RedirectURIMatchingMode.REGEX:
|
||||
try:
|
||||
if fullmatch(allowed.url, self.redirect_uri):
|
||||
match_found = True
|
||||
break
|
||||
except RegexError as exc:
|
||||
LOGGER.warning(
|
||||
"Failed to parse regular expression",
|
||||
exc=exc,
|
||||
url=allowed.url,
|
||||
provider=self.provider,
|
||||
)
|
||||
if not match_found:
|
||||
raise RedirectUriError(self.redirect_uri, allowed_redirect_urls)
|
||||
# Check against forbidden schemes
|
||||
if urlparse(self.redirect_uri).scheme in FORBIDDEN_URI_SCHEMES:
|
||||
raise RedirectUriError(self.redirect_uri, allowed_redirect_urls)
|
||||
|
@ -5,7 +5,7 @@ from typing import Any
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import CharField, IntegerField
|
||||
from rest_framework.fields import CharField
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.brands.models import Brand
|
||||
@ -47,6 +47,9 @@ class CodeValidatorView(PolicyAccessView):
|
||||
self.provider = self.token.provider
|
||||
self.application = self.token.provider.application
|
||||
|
||||
def post(self, request: HttpRequest, *args, **kwargs):
|
||||
return self.get(request, *args, **kwargs)
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs):
|
||||
scope_descriptions = UserInfoView().get_scope_descriptions(self.token.scope, self.provider)
|
||||
planner = FlowPlanner(self.provider.authorization_flow)
|
||||
@ -122,7 +125,7 @@ class OAuthDeviceCodeChallenge(Challenge):
|
||||
class OAuthDeviceCodeChallengeResponse(ChallengeResponse):
|
||||
"""Response that includes the user-entered device code"""
|
||||
|
||||
code = IntegerField()
|
||||
code = CharField()
|
||||
component = CharField(default="ak-provider-oauth2-device-code")
|
||||
|
||||
def validate_code(self, code: int) -> HttpResponse | None:
|
||||
|
@ -162,5 +162,5 @@ class ProviderInfoView(View):
|
||||
OAuth2Provider, pk=application.provider_id
|
||||
)
|
||||
response = super().dispatch(request, *args, **kwargs)
|
||||
cors_allow(request, response, *self.provider.redirect_uris.split("\n"))
|
||||
cors_allow(request, response, *[x.url for x in self.provider.redirect_uris])
|
||||
return response
|
||||
|
@ -58,7 +58,9 @@ from authentik.providers.oauth2.models import (
|
||||
ClientTypes,
|
||||
DeviceToken,
|
||||
OAuth2Provider,
|
||||
RedirectURIMatchingMode,
|
||||
RefreshToken,
|
||||
ScopeMapping,
|
||||
)
|
||||
from authentik.providers.oauth2.utils import TokenResponse, cors_allow, extract_client_auth
|
||||
from authentik.providers.oauth2.views.authorize import FORBIDDEN_URI_SCHEMES
|
||||
@ -77,7 +79,7 @@ class TokenParams:
|
||||
redirect_uri: str
|
||||
grant_type: str
|
||||
state: str
|
||||
scope: list[str]
|
||||
scope: set[str]
|
||||
|
||||
provider: OAuth2Provider
|
||||
|
||||
@ -112,11 +114,26 @@ class TokenParams:
|
||||
redirect_uri=request.POST.get("redirect_uri", ""),
|
||||
grant_type=request.POST.get("grant_type", ""),
|
||||
state=request.POST.get("state", ""),
|
||||
scope=request.POST.get("scope", "").split(),
|
||||
scope=set(request.POST.get("scope", "").split()),
|
||||
# PKCE parameter.
|
||||
code_verifier=request.POST.get("code_verifier"),
|
||||
)
|
||||
|
||||
def __check_scopes(self):
|
||||
allowed_scope_names = set(
|
||||
ScopeMapping.objects.filter(provider__in=[self.provider]).values_list(
|
||||
"scope_name", flat=True
|
||||
)
|
||||
)
|
||||
scopes_to_check = self.scope
|
||||
if not scopes_to_check.issubset(allowed_scope_names):
|
||||
LOGGER.info(
|
||||
"Application requested scopes not configured, setting to overlap",
|
||||
scope_allowed=allowed_scope_names,
|
||||
scope_given=self.scope,
|
||||
)
|
||||
self.scope = self.scope.intersection(allowed_scope_names)
|
||||
|
||||
def __check_policy_access(self, app: Application, request: HttpRequest, **kwargs):
|
||||
with start_span(
|
||||
op="authentik.providers.oauth2.token.policy",
|
||||
@ -149,7 +166,7 @@ class TokenParams:
|
||||
client_id=self.provider.client_id,
|
||||
)
|
||||
raise TokenError("invalid_client")
|
||||
|
||||
self.__check_scopes()
|
||||
if self.grant_type == GRANT_TYPE_AUTHORIZATION_CODE:
|
||||
with start_span(
|
||||
op="authentik.providers.oauth2.post.parse.code",
|
||||
@ -179,42 +196,7 @@ class TokenParams:
|
||||
LOGGER.warning("Missing authorization code")
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
allowed_redirect_urls = self.provider.redirect_uris.split()
|
||||
# At this point, no provider should have a blank redirect_uri, in case they do
|
||||
# this will check an empty array and raise an error
|
||||
try:
|
||||
if not any(fullmatch(x, self.redirect_uri) for x in allowed_redirect_urls):
|
||||
LOGGER.warning(
|
||||
"Invalid redirect uri (regex comparison)",
|
||||
redirect_uri=self.redirect_uri,
|
||||
expected=allowed_redirect_urls,
|
||||
)
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message="Invalid redirect URI used by provider",
|
||||
provider=self.provider,
|
||||
redirect_uri=self.redirect_uri,
|
||||
expected=allowed_redirect_urls,
|
||||
).from_http(request)
|
||||
raise TokenError("invalid_client")
|
||||
except RegexError as exc:
|
||||
LOGGER.info("Failed to parse regular expression, checking directly", exc=exc)
|
||||
if not any(x == self.redirect_uri for x in allowed_redirect_urls):
|
||||
LOGGER.warning(
|
||||
"Invalid redirect uri (strict comparison)",
|
||||
redirect_uri=self.redirect_uri,
|
||||
expected=allowed_redirect_urls,
|
||||
)
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message="Invalid redirect_uri configured",
|
||||
provider=self.provider,
|
||||
).from_http(request)
|
||||
raise TokenError("invalid_client") from None
|
||||
|
||||
# Check against forbidden schemes
|
||||
if urlparse(self.redirect_uri).scheme in FORBIDDEN_URI_SCHEMES:
|
||||
raise TokenError("invalid_request")
|
||||
self.__check_redirect_uri(request)
|
||||
|
||||
self.authorization_code = AuthorizationCode.objects.filter(code=raw_code).first()
|
||||
if not self.authorization_code:
|
||||
@ -254,6 +236,48 @@ class TokenParams:
|
||||
if not self.authorization_code.code_challenge and self.code_verifier:
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
def __check_redirect_uri(self, request: HttpRequest):
|
||||
allowed_redirect_urls = self.provider.redirect_uris
|
||||
# At this point, no provider should have a blank redirect_uri, in case they do
|
||||
# this will check an empty array and raise an error
|
||||
|
||||
match_found = False
|
||||
for allowed in allowed_redirect_urls:
|
||||
if allowed.matching_mode == RedirectURIMatchingMode.STRICT:
|
||||
if self.redirect_uri == allowed.url:
|
||||
match_found = True
|
||||
break
|
||||
if allowed.matching_mode == RedirectURIMatchingMode.REGEX:
|
||||
try:
|
||||
if fullmatch(allowed.url, self.redirect_uri):
|
||||
match_found = True
|
||||
break
|
||||
except RegexError as exc:
|
||||
LOGGER.warning(
|
||||
"Failed to parse regular expression",
|
||||
exc=exc,
|
||||
url=allowed.url,
|
||||
provider=self.provider,
|
||||
)
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message="Invalid redirect_uri configured",
|
||||
provider=self.provider,
|
||||
).from_http(request)
|
||||
if not match_found:
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message="Invalid redirect URI used by provider",
|
||||
provider=self.provider,
|
||||
redirect_uri=self.redirect_uri,
|
||||
expected=allowed_redirect_urls,
|
||||
).from_http(request)
|
||||
raise TokenError("invalid_client")
|
||||
|
||||
# Check against forbidden schemes
|
||||
if urlparse(self.redirect_uri).scheme in FORBIDDEN_URI_SCHEMES:
|
||||
raise TokenError("invalid_request")
|
||||
|
||||
def __post_init_refresh(self, raw_token: str, request: HttpRequest):
|
||||
if not raw_token:
|
||||
LOGGER.warning("Missing refresh token")
|
||||
@ -497,7 +521,7 @@ class TokenView(View):
|
||||
response = super().dispatch(request, *args, **kwargs)
|
||||
allowed_origins = []
|
||||
if self.provider:
|
||||
allowed_origins = self.provider.redirect_uris.split("\n")
|
||||
allowed_origins = [x.url for x in self.provider.redirect_uris]
|
||||
cors_allow(self.request, response, *allowed_origins)
|
||||
return response
|
||||
|
||||
@ -710,7 +734,7 @@ class TokenView(View):
|
||||
"id_token": access_token.id_token.to_jwt(self.provider),
|
||||
}
|
||||
|
||||
if SCOPE_OFFLINE_ACCESS in self.params.scope:
|
||||
if SCOPE_OFFLINE_ACCESS in self.params.device_code.scope:
|
||||
refresh_token_expiry = now + timedelta_from_string(self.provider.refresh_token_validity)
|
||||
refresh_token = RefreshToken(
|
||||
user=self.params.device_code.user,
|
||||
|
@ -108,7 +108,7 @@ class UserInfoView(View):
|
||||
response = super().dispatch(request, *args, **kwargs)
|
||||
allowed_origins = []
|
||||
if self.token:
|
||||
allowed_origins = self.token.provider.redirect_uris.split("\n")
|
||||
allowed_origins = [x.url for x in self.token.provider.redirect_uris]
|
||||
cors_allow(self.request, response, *allowed_origins)
|
||||
return response
|
||||
|
||||
|
@ -121,7 +121,6 @@ class ProxyProviderViewSet(UsedByMixin, ModelViewSet):
|
||||
"basic_auth_password_attribute": ["iexact"],
|
||||
"basic_auth_user_attribute": ["iexact"],
|
||||
"mode": ["iexact"],
|
||||
"redirect_uris": ["iexact"],
|
||||
"cookie_domain": ["iexact"],
|
||||
}
|
||||
search_fields = ["name"]
|
||||
|
@ -13,7 +13,13 @@ from rest_framework.serializers import Serializer
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.lib.models import DomainlessURLValidator
|
||||
from authentik.outposts.models import OutpostModel
|
||||
from authentik.providers.oauth2.models import ClientTypes, OAuth2Provider, ScopeMapping
|
||||
from authentik.providers.oauth2.models import (
|
||||
ClientTypes,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
ScopeMapping,
|
||||
)
|
||||
|
||||
SCOPE_AK_PROXY = "ak_proxy"
|
||||
OUTPOST_CALLBACK_SIGNATURE = "X-authentik-auth-callback"
|
||||
@ -24,14 +30,15 @@ def get_cookie_secret():
|
||||
return "".join(SystemRandom().choice(string.ascii_uppercase + string.digits) for _ in range(32))
|
||||
|
||||
|
||||
def _get_callback_url(uri: str) -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
def _get_callback_url(uri: str) -> list[RedirectURI]:
|
||||
return [
|
||||
RedirectURI(
|
||||
RedirectURIMatchingMode.STRICT,
|
||||
urljoin(uri, "outpost.goauthentik.io/callback")
|
||||
+ f"\\?{OUTPOST_CALLBACK_SIGNATURE}=true",
|
||||
uri + f"\\?{OUTPOST_CALLBACK_SIGNATURE}=true",
|
||||
]
|
||||
)
|
||||
),
|
||||
RedirectURI(RedirectURIMatchingMode.STRICT, uri + f"\\?{OUTPOST_CALLBACK_SIGNATURE}=true"),
|
||||
]
|
||||
|
||||
|
||||
class ProxyMode(models.TextChoices):
|
||||
|
@ -19,6 +19,7 @@ SCIM_GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group"
|
||||
class User(BaseUser):
|
||||
"""Modified User schema with added externalId field"""
|
||||
|
||||
id: str | int | None = None
|
||||
schemas: list[str] = [SCIM_USER_SCHEMA]
|
||||
externalId: str | None = None
|
||||
meta: dict | None = None
|
||||
@ -27,6 +28,7 @@ class User(BaseUser):
|
||||
class Group(BaseGroup):
|
||||
"""Modified Group schema with added externalId field"""
|
||||
|
||||
id: str | int | None = None
|
||||
schemas: list[str] = [SCIM_GROUP_SCHEMA]
|
||||
externalId: str | None = None
|
||||
meta: dict | None = None
|
||||
|
@ -53,7 +53,7 @@ class ExtraRoleObjectPermissionSerializer(RoleObjectPermissionSerializer):
|
||||
except LookupError:
|
||||
return None
|
||||
objects = get_objects_for_group(instance.group, f"{app_label}.view_{model}", model_class)
|
||||
obj = objects.first()
|
||||
obj = objects.filter(pk=instance.object_pk).first()
|
||||
if not obj:
|
||||
return None
|
||||
return str(obj)
|
||||
|
@ -53,7 +53,7 @@ class ExtraUserObjectPermissionSerializer(UserObjectPermissionSerializer):
|
||||
except LookupError:
|
||||
return None
|
||||
objects = get_objects_for_user(instance.user, f"{app_label}.view_{model}", model_class)
|
||||
obj = objects.first()
|
||||
obj = objects.filter(pk=instance.object_pk).first()
|
||||
if not obj:
|
||||
return None
|
||||
return str(obj)
|
||||
|
@ -1,6 +1,8 @@
|
||||
"""Metrics view"""
|
||||
|
||||
from base64 import b64encode
|
||||
from hmac import compare_digest
|
||||
from pathlib import Path
|
||||
from tempfile import gettempdir
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import connections
|
||||
@ -16,22 +18,21 @@ monitoring_set = Signal()
|
||||
|
||||
|
||||
class MetricsView(View):
|
||||
"""Wrapper around ExportToDjangoView, using http-basic auth"""
|
||||
"""Wrapper around ExportToDjangoView with authentication, accessed by the authentik router"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
_tmp = Path(gettempdir())
|
||||
with open(_tmp / "authentik-core-metrics.key") as _f:
|
||||
self.monitoring_key = _f.read()
|
||||
|
||||
def get(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Check for HTTP-Basic auth"""
|
||||
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
|
||||
auth_type, _, given_credentials = auth_header.partition(" ")
|
||||
credentials = f"monitor:{settings.SECRET_KEY}"
|
||||
expected = b64encode(str.encode(credentials)).decode()
|
||||
authed = auth_type == "Basic" and given_credentials == expected
|
||||
authed = auth_type == "Bearer" and compare_digest(given_credentials, self.monitoring_key)
|
||||
if not authed and not settings.DEBUG:
|
||||
response = HttpResponse(status=401)
|
||||
response["WWW-Authenticate"] = 'Basic realm="authentik-monitoring"'
|
||||
return response
|
||||
|
||||
return HttpResponse(status=401)
|
||||
monitoring_set.send_robust(self)
|
||||
|
||||
return ExportToDjangoView(request)
|
||||
|
||||
|
||||
|
@ -1,8 +1,9 @@
|
||||
"""root tests"""
|
||||
|
||||
from base64 import b64encode
|
||||
from pathlib import Path
|
||||
from secrets import token_urlsafe
|
||||
from tempfile import gettempdir
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
@ -10,6 +11,16 @@ from django.urls import reverse
|
||||
class TestRoot(TestCase):
|
||||
"""Test root application"""
|
||||
|
||||
def setUp(self):
|
||||
_tmp = Path(gettempdir())
|
||||
self.token = token_urlsafe(32)
|
||||
with open(_tmp / "authentik-core-metrics.key", "w") as _f:
|
||||
_f.write(self.token)
|
||||
|
||||
def tearDown(self):
|
||||
_tmp = Path(gettempdir())
|
||||
(_tmp / "authentik-core-metrics.key").unlink()
|
||||
|
||||
def test_monitoring_error(self):
|
||||
"""Test monitoring without any credentials"""
|
||||
response = self.client.get(reverse("metrics"))
|
||||
@ -17,8 +28,7 @@ class TestRoot(TestCase):
|
||||
|
||||
def test_monitoring_ok(self):
|
||||
"""Test monitoring with credentials"""
|
||||
creds = "Basic " + b64encode(f"monitor:{settings.SECRET_KEY}".encode()).decode("utf-8")
|
||||
auth_headers = {"HTTP_AUTHORIZATION": creds}
|
||||
auth_headers = {"HTTP_AUTHORIZATION": f"Bearer {self.token}"}
|
||||
response = self.client.get(reverse("metrics"), **auth_headers)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
@ -17,6 +17,7 @@ class CaptchaStageSerializer(StageSerializer):
|
||||
"private_key",
|
||||
"js_url",
|
||||
"api_url",
|
||||
"interactive",
|
||||
"score_min_threshold",
|
||||
"score_max_threshold",
|
||||
"error_on_invalid_score",
|
||||
|
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.9 on 2024-10-30 14:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_stages_captcha", "0003_captchastage_error_on_invalid_score_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="captchastage",
|
||||
name="interactive",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
@ -9,11 +9,13 @@ from authentik.flows.models import Stage
|
||||
|
||||
|
||||
class CaptchaStage(Stage):
|
||||
"""Verify the user is human using Google's reCaptcha."""
|
||||
"""Verify the user is human using Google's reCaptcha/other compatible CAPTCHA solutions."""
|
||||
|
||||
public_key = models.TextField(help_text=_("Public key, acquired your captcha Provider."))
|
||||
private_key = models.TextField(help_text=_("Private key, acquired your captcha Provider."))
|
||||
|
||||
interactive = models.BooleanField(default=False)
|
||||
|
||||
score_min_threshold = models.FloatField(default=0.5) # Default values for reCaptcha
|
||||
score_max_threshold = models.FloatField(default=1.0) # Default values for reCaptcha
|
||||
|
||||
|
@ -3,7 +3,7 @@
|
||||
from django.http.response import HttpResponse
|
||||
from django.utils.translation import gettext as _
|
||||
from requests import RequestException
|
||||
from rest_framework.fields import CharField
|
||||
from rest_framework.fields import BooleanField, CharField
|
||||
from rest_framework.serializers import ValidationError
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
@ -24,10 +24,12 @@ PLAN_CONTEXT_CAPTCHA = "captcha"
|
||||
class CaptchaChallenge(WithUserInfoChallenge):
|
||||
"""Site public key"""
|
||||
|
||||
site_key = CharField()
|
||||
js_url = CharField()
|
||||
component = CharField(default="ak-stage-captcha")
|
||||
|
||||
site_key = CharField(required=True)
|
||||
js_url = CharField(required=True)
|
||||
interactive = BooleanField(required=True)
|
||||
|
||||
|
||||
def verify_captcha_token(stage: CaptchaStage, token: str, remote_ip: str):
|
||||
"""Validate captcha token"""
|
||||
@ -103,6 +105,7 @@ class CaptchaStageView(ChallengeStageView):
|
||||
data={
|
||||
"js_url": self.executor.current_stage.js_url,
|
||||
"site_key": self.executor.current_stage.public_key,
|
||||
"interactive": self.executor.current_stage.interactive,
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -223,6 +223,7 @@ class IdentificationStageView(ChallengeStageView):
|
||||
{
|
||||
"js_url": current_stage.captcha_stage.js_url,
|
||||
"site_key": current_stage.captcha_stage.public_key,
|
||||
"interactive": current_stage.captcha_stage.interactive,
|
||||
}
|
||||
if current_stage.captcha_stage
|
||||
else None
|
||||
|
@ -21,7 +21,7 @@ from authentik.flows.challenge import (
|
||||
WithUserInfoChallenge,
|
||||
)
|
||||
from authentik.flows.exceptions import StageInvalidException
|
||||
from authentik.flows.models import Flow, FlowDesignation, Stage
|
||||
from authentik.flows.models import Flow, Stage
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||
from authentik.flows.stage import ChallengeStageView
|
||||
from authentik.lib.utils.reflection import path_to_class
|
||||
@ -141,11 +141,11 @@ class PasswordStageView(ChallengeStageView):
|
||||
"allow_show_password": self.executor.current_stage.allow_show_password,
|
||||
}
|
||||
)
|
||||
recovery_flow = Flow.objects.filter(designation=FlowDesignation.RECOVERY)
|
||||
if recovery_flow.exists():
|
||||
recovery_flow: Flow | None = self.request.brand.flow_recovery
|
||||
if recovery_flow:
|
||||
recover_url = reverse(
|
||||
"authentik_core:if-flow",
|
||||
kwargs={"flow_slug": recovery_flow.first().slug},
|
||||
kwargs={"flow_slug": recovery_flow.slug},
|
||||
)
|
||||
challenge.initial_data["recovery_url"] = self.request.build_absolute_uri(recover_url)
|
||||
return challenge
|
||||
|
@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.urls import reverse
|
||||
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow
|
||||
from authentik.flows.markers import StageMarker
|
||||
from authentik.flows.models import FlowDesignation, FlowStageBinding
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
|
||||
@ -57,6 +57,9 @@ class TestPasswordStage(FlowTestCase):
|
||||
def test_recovery_flow_link(self):
|
||||
"""Test link to the default recovery flow"""
|
||||
flow = create_test_flow(designation=FlowDesignation.RECOVERY)
|
||||
brand = create_test_brand()
|
||||
brand.flow_recovery = flow
|
||||
brand.save()
|
||||
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
session = self.client.session
|
||||
|
@ -101,7 +101,6 @@ entries:
|
||||
- !KeyOf prompt-field-email
|
||||
- !KeyOf prompt-field-password
|
||||
- !KeyOf prompt-field-password-repeat
|
||||
validation_policies: []
|
||||
id: stage-default-oobe-password
|
||||
identifiers:
|
||||
name: stage-default-oobe-password
|
||||
|
@ -2,6 +2,17 @@ version: 1
|
||||
metadata:
|
||||
name: Default - Password change flow
|
||||
entries:
|
||||
- attrs:
|
||||
check_static_rules: true
|
||||
check_zxcvbn: true
|
||||
length_min: 8
|
||||
password_field: password
|
||||
zxcvbn_score_threshold: 2
|
||||
error_message: Password needs to be 8 characters or longer.
|
||||
identifiers:
|
||||
name: default-password-change-password-policy
|
||||
model: authentik_policies_password.passwordpolicy
|
||||
id: default-password-change-password-policy
|
||||
- attrs:
|
||||
designation: stage_configuration
|
||||
name: Change Password
|
||||
@ -39,6 +50,8 @@ entries:
|
||||
fields:
|
||||
- !KeyOf prompt-field-password
|
||||
- !KeyOf prompt-field-password-repeat
|
||||
validation_policies:
|
||||
- !KeyOf default-password-change-password-policy
|
||||
identifiers:
|
||||
name: default-password-change-prompt
|
||||
id: default-password-change-prompt
|
||||
|
@ -2,7 +2,7 @@
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "https://goauthentik.io/blueprints/schema.json",
|
||||
"type": "object",
|
||||
"title": "authentik 2024.10.1 Blueprint schema",
|
||||
"title": "authentik 2024.10.3 Blueprint schema",
|
||||
"required": [
|
||||
"version",
|
||||
"entries"
|
||||
@ -5570,9 +5570,30 @@
|
||||
"description": "Key used to encrypt the tokens. When set, tokens will be encrypted and returned as JWEs."
|
||||
},
|
||||
"redirect_uris": {
|
||||
"type": "string",
|
||||
"title": "Redirect URIs",
|
||||
"description": "Enter each URI on a new line."
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"matching_mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"strict",
|
||||
"regex"
|
||||
],
|
||||
"title": "Matching mode"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"title": "Url"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"matching_mode",
|
||||
"url"
|
||||
]
|
||||
},
|
||||
"title": "Redirect uris"
|
||||
},
|
||||
"sub_mode": {
|
||||
"type": "string",
|
||||
@ -9781,6 +9802,10 @@
|
||||
"minLength": 1,
|
||||
"title": "Api url"
|
||||
},
|
||||
"interactive": {
|
||||
"type": "boolean",
|
||||
"title": "Interactive"
|
||||
},
|
||||
"score_min_threshold": {
|
||||
"type": "number",
|
||||
"title": "Score min threshold"
|
||||
|
@ -31,7 +31,7 @@ services:
|
||||
volumes:
|
||||
- redis:/data
|
||||
server:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.10.1}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.10.3}
|
||||
restart: unless-stopped
|
||||
command: server
|
||||
environment:
|
||||
@ -52,7 +52,7 @@ services:
|
||||
- postgresql
|
||||
- redis
|
||||
worker:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.10.1}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.10.3}
|
||||
restart: unless-stopped
|
||||
command: worker
|
||||
environment:
|
||||
|
@ -29,4 +29,4 @@ func UserAgent() string {
|
||||
return fmt.Sprintf("authentik@%s", FullVersion())
|
||||
}
|
||||
|
||||
const VERSION = "2024.10.1"
|
||||
const VERSION = "2024.10.3"
|
||||
|
@ -65,7 +65,7 @@ func (ls *LDAPServer) StartLDAPServer() error {
|
||||
ls.log.WithField("listen", listen).WithError(err).Warning("Failed to listen (SSL)")
|
||||
return err
|
||||
}
|
||||
proxyListener := &proxyproto.Listener{Listener: ln}
|
||||
proxyListener := &proxyproto.Listener{Listener: ln, ConnPolicy: utils.GetProxyConnectionPolicy()}
|
||||
defer proxyListener.Close()
|
||||
|
||||
ls.log.WithField("listen", listen).Info("Starting LDAP server")
|
||||
|
@ -48,7 +48,7 @@ func (ls *LDAPServer) StartLDAPTLSServer() error {
|
||||
return err
|
||||
}
|
||||
|
||||
proxyListener := &proxyproto.Listener{Listener: ln}
|
||||
proxyListener := &proxyproto.Listener{Listener: ln, ConnPolicy: utils.GetProxyConnectionPolicy()}
|
||||
defer proxyListener.Close()
|
||||
|
||||
tln := tls.NewListener(proxyListener, tlsConfig)
|
||||
|
@ -23,6 +23,7 @@ import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"goauthentik.io/api/v3"
|
||||
"goauthentik.io/internal/config"
|
||||
"goauthentik.io/internal/outpost/ak"
|
||||
"goauthentik.io/internal/outpost/proxyv2/constants"
|
||||
"goauthentik.io/internal/outpost/proxyv2/hs256"
|
||||
@ -121,6 +122,14 @@ func NewApplication(p api.ProxyOutpostConfig, c *http.Client, server Server, old
|
||||
bs := string(h.Sum([]byte(*p.ClientId)))
|
||||
sessionName := fmt.Sprintf("authentik_proxy_%s", bs[:8])
|
||||
|
||||
// When HOST_BROWSER is set, use that as Host header for token requests to make the issuer match
|
||||
// otherwise we use the internally configured authentik_host
|
||||
tokenEndpointHost := server.API().Outpost.Config["authentik_host"].(string)
|
||||
if config.Get().AuthentikHostBrowser != "" {
|
||||
tokenEndpointHost = config.Get().AuthentikHostBrowser
|
||||
}
|
||||
publicHTTPClient := web.NewHostInterceptor(c, tokenEndpointHost)
|
||||
|
||||
a := &Application{
|
||||
Host: externalHost.Host,
|
||||
log: muxLogger,
|
||||
@ -131,7 +140,7 @@ func NewApplication(p api.ProxyOutpostConfig, c *http.Client, server Server, old
|
||||
tokenVerifier: verifier,
|
||||
proxyConfig: p,
|
||||
httpClient: c,
|
||||
publicHostHTTPClient: web.NewHostInterceptor(c, server.API().Outpost.Config["authentik_host"].(string)),
|
||||
publicHostHTTPClient: publicHTTPClient,
|
||||
mux: mux,
|
||||
errorTemplates: templates.GetTemplates(),
|
||||
ak: server.API(),
|
||||
|
@ -129,7 +129,7 @@ func (ps *ProxyServer) ServeHTTP() {
|
||||
ps.log.WithField("listen", listenAddress).WithError(err).Warning("Failed to listen")
|
||||
return
|
||||
}
|
||||
proxyListener := &proxyproto.Listener{Listener: listener}
|
||||
proxyListener := &proxyproto.Listener{Listener: listener, ConnPolicy: utils.GetProxyConnectionPolicy()}
|
||||
defer proxyListener.Close()
|
||||
|
||||
ps.log.WithField("listen", listenAddress).Info("Starting HTTP server")
|
||||
@ -148,7 +148,7 @@ func (ps *ProxyServer) ServeHTTPS() {
|
||||
ps.log.WithError(err).Warning("Failed to listen (TLS)")
|
||||
return
|
||||
}
|
||||
proxyListener := &proxyproto.Listener{Listener: web.TCPKeepAliveListener{TCPListener: ln.(*net.TCPListener)}}
|
||||
proxyListener := &proxyproto.Listener{Listener: web.TCPKeepAliveListener{TCPListener: ln.(*net.TCPListener)}, ConnPolicy: utils.GetProxyConnectionPolicy()}
|
||||
defer proxyListener.Close()
|
||||
|
||||
tlsListener := tls.NewListener(proxyListener, tlsConfig)
|
||||
|
34
internal/utils/proxy.go
Normal file
34
internal/utils/proxy.go
Normal file
@ -0,0 +1,34 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
"github.com/pires/go-proxyproto"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"goauthentik.io/internal/config"
|
||||
)
|
||||
|
||||
func GetProxyConnectionPolicy() proxyproto.ConnPolicyFunc {
|
||||
nets := []*net.IPNet{}
|
||||
for _, rn := range config.Get().Listen.TrustedProxyCIDRs {
|
||||
_, cidr, err := net.ParseCIDR(rn)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
nets = append(nets, cidr)
|
||||
}
|
||||
return func(connPolicyOptions proxyproto.ConnPolicyOptions) (proxyproto.Policy, error) {
|
||||
host, _, err := net.SplitHostPort(connPolicyOptions.Upstream.String())
|
||||
if err == nil {
|
||||
// remoteAddr will be nil if the IP cannot be parsed
|
||||
remoteAddr := net.ParseIP(host)
|
||||
for _, allowedCidr := range nets {
|
||||
if remoteAddr != nil && allowedCidr.Contains(remoteAddr) {
|
||||
log.WithField("remoteAddr", remoteAddr).WithField("cidr", allowedCidr.String()).Trace("Using remote IP from proxy protocol")
|
||||
return proxyproto.USE, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return proxyproto.SKIP, nil
|
||||
}
|
||||
}
|
@ -14,8 +14,10 @@ type hostInterceptor struct {
|
||||
}
|
||||
|
||||
func (t hostInterceptor) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
r.Host = t.host
|
||||
r.Header.Set("X-Forwarded-Proto", t.scheme)
|
||||
if r.Host != t.host {
|
||||
r.Host = t.host
|
||||
r.Header.Set("X-Forwarded-Proto", t.scheme)
|
||||
}
|
||||
return t.inner.RoundTrip(r)
|
||||
}
|
||||
|
||||
|
@ -1,11 +1,15 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/securecookie"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
@ -14,14 +18,25 @@ import (
|
||||
"goauthentik.io/internal/utils/sentry"
|
||||
)
|
||||
|
||||
const MetricsKeyFile = "authentik-core-metrics.key"
|
||||
|
||||
var Requests = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "authentik_main_request_duration_seconds",
|
||||
Help: "API request latencies in seconds",
|
||||
}, []string{"dest"})
|
||||
|
||||
func (ws *WebServer) runMetricsServer() {
|
||||
m := mux.NewRouter()
|
||||
l := log.WithField("logger", "authentik.router.metrics")
|
||||
tmp := os.TempDir()
|
||||
key := base64.StdEncoding.EncodeToString(securecookie.GenerateRandomKey(64))
|
||||
keyPath := path.Join(tmp, MetricsKeyFile)
|
||||
err := os.WriteFile(keyPath, []byte(key), 0o600)
|
||||
if err != nil {
|
||||
l.WithError(err).Warning("failed to save metrics key")
|
||||
return
|
||||
}
|
||||
|
||||
m := mux.NewRouter()
|
||||
m.Use(sentry.SentryNoSampleMiddleware)
|
||||
m.Path("/metrics").HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
promhttp.InstrumentMetricHandler(
|
||||
@ -36,7 +51,7 @@ func (ws *WebServer) runMetricsServer() {
|
||||
l.WithError(err).Warning("failed to get upstream metrics")
|
||||
return
|
||||
}
|
||||
re.SetBasicAuth("monitor", config.Get().SecretKey)
|
||||
re.Header.Set("Authorization", fmt.Sprintf("Bearer %s", key))
|
||||
res, err := ws.upstreamHttpClient().Do(re)
|
||||
if err != nil {
|
||||
l.WithError(err).Warning("failed to get upstream metrics")
|
||||
@ -49,9 +64,13 @@ func (ws *WebServer) runMetricsServer() {
|
||||
}
|
||||
})
|
||||
l.WithField("listen", config.Get().Listen.Metrics).Info("Starting Metrics server")
|
||||
err := http.ListenAndServe(config.Get().Listen.Metrics, m)
|
||||
err = http.ListenAndServe(config.Get().Listen.Metrics, m)
|
||||
if err != nil {
|
||||
l.WithError(err).Warning("Failed to start metrics server")
|
||||
}
|
||||
l.WithField("listen", config.Get().Listen.Metrics).Info("Stopping Metrics server")
|
||||
err = os.Remove(keyPath)
|
||||
if err != nil {
|
||||
l.WithError(err).Warning("failed to remove metrics key file")
|
||||
}
|
||||
}
|
||||
|
@ -42,8 +42,11 @@ func (ws *WebServer) configureStatic() {
|
||||
|
||||
// Media files, if backend is file
|
||||
if config.Get().Storage.Media.Backend == "file" {
|
||||
fsMedia := http.FileServer(http.Dir(config.Get().Storage.Media.File.Path))
|
||||
staticRouter.PathPrefix("/media/").Handler(http.StripPrefix("/media", fsMedia))
|
||||
fsMedia := http.StripPrefix("/media", http.FileServer(http.Dir(config.Get().Storage.Media.File.Path)))
|
||||
staticRouter.PathPrefix("/media/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
|
||||
fsMedia.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
staticRouter.PathPrefix("/if/help/").Handler(http.StripPrefix("/if/help/", http.FileServer(http.Dir("./website/help/"))))
|
||||
|
@ -19,6 +19,7 @@ import (
|
||||
"goauthentik.io/internal/config"
|
||||
"goauthentik.io/internal/gounicorn"
|
||||
"goauthentik.io/internal/outpost/proxyv2"
|
||||
"goauthentik.io/internal/utils"
|
||||
"goauthentik.io/internal/utils/web"
|
||||
"goauthentik.io/internal/web/brand_tls"
|
||||
)
|
||||
@ -52,7 +53,7 @@ func NewWebServer() *WebServer {
|
||||
loggingHandler.Use(web.NewLoggingHandler(l, nil))
|
||||
|
||||
tmp := os.TempDir()
|
||||
socketPath := path.Join(tmp, "authentik-core.sock")
|
||||
socketPath := path.Join(tmp, UnixSocketName)
|
||||
|
||||
// create http client to talk to backend, normal client if we're in debug more
|
||||
// and a client that connects to our socket when in non debug mode
|
||||
@ -149,7 +150,7 @@ func (ws *WebServer) listenPlain() {
|
||||
ws.log.WithError(err).Warning("failed to listen")
|
||||
return
|
||||
}
|
||||
proxyListener := &proxyproto.Listener{Listener: ln}
|
||||
proxyListener := &proxyproto.Listener{Listener: ln, ConnPolicy: utils.GetProxyConnectionPolicy()}
|
||||
defer proxyListener.Close()
|
||||
|
||||
ws.log.WithField("listen", config.Get().Listen.HTTP).Info("Starting HTTP server")
|
||||
|
@ -45,7 +45,7 @@ func (ws *WebServer) listenTLS() {
|
||||
ws.log.WithError(err).Warning("failed to listen (TLS)")
|
||||
return
|
||||
}
|
||||
proxyListener := &proxyproto.Listener{Listener: web.TCPKeepAliveListener{TCPListener: ln.(*net.TCPListener)}}
|
||||
proxyListener := &proxyproto.Listener{Listener: web.TCPKeepAliveListener{TCPListener: ln.(*net.TCPListener)}, ConnPolicy: utils.GetProxyConnectionPolicy()}
|
||||
defer proxyListener.Close()
|
||||
|
||||
tlsListener := tls.NewListener(proxyListener, tlsConfig)
|
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@goauthentik/authentik",
|
||||
"version": "2024.10.1",
|
||||
"version": "2024.10.3",
|
||||
"private": true
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "authentik"
|
||||
version = "2024.10.1"
|
||||
version = "2024.10.3"
|
||||
description = ""
|
||||
authors = ["authentik Team <hello@goauthentik.io>"]
|
||||
|
||||
|
67
schema.yml
67
schema.yml
@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: authentik
|
||||
version: 2024.10.1
|
||||
version: 2024.10.3
|
||||
description: Making authentication simple.
|
||||
contact:
|
||||
email: hello@goauthentik.io
|
||||
@ -20218,10 +20218,6 @@ paths:
|
||||
format: uuid
|
||||
explode: true
|
||||
style: form
|
||||
- in: query
|
||||
name: redirect_uris
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: refresh_token_validity
|
||||
schema:
|
||||
@ -20637,10 +20633,6 @@ paths:
|
||||
format: uuid
|
||||
explode: true
|
||||
style: form
|
||||
- in: query
|
||||
name: redirect_uris__iexact
|
||||
schema:
|
||||
type: string
|
||||
- name: search
|
||||
required: false
|
||||
in: query
|
||||
@ -39220,7 +39212,10 @@ components:
|
||||
type: string
|
||||
js_url:
|
||||
type: string
|
||||
interactive:
|
||||
type: boolean
|
||||
required:
|
||||
- interactive
|
||||
- js_url
|
||||
- pending_user
|
||||
- pending_user_avatar
|
||||
@ -39276,6 +39271,8 @@ components:
|
||||
type: string
|
||||
api_url:
|
||||
type: string
|
||||
interactive:
|
||||
type: boolean
|
||||
score_min_threshold:
|
||||
type: number
|
||||
format: double
|
||||
@ -39322,6 +39319,8 @@ components:
|
||||
api_url:
|
||||
type: string
|
||||
minLength: 1
|
||||
interactive:
|
||||
type: boolean
|
||||
score_min_threshold:
|
||||
type: number
|
||||
format: double
|
||||
@ -44053,6 +44052,11 @@ components:
|
||||
required:
|
||||
- challenge
|
||||
- name
|
||||
MatchingModeEnum:
|
||||
enum:
|
||||
- strict
|
||||
- regex
|
||||
type: string
|
||||
Metadata:
|
||||
type: object
|
||||
description: Serializer for blueprint metadata
|
||||
@ -44755,8 +44759,9 @@ components:
|
||||
description: Key used to encrypt the tokens. When set, tokens will be encrypted
|
||||
and returned as JWEs.
|
||||
redirect_uris:
|
||||
type: string
|
||||
description: Enter each URI on a new line.
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RedirectURI'
|
||||
sub_mode:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SubModeEnum'
|
||||
@ -44785,6 +44790,7 @@ components:
|
||||
- meta_model_name
|
||||
- name
|
||||
- pk
|
||||
- redirect_uris
|
||||
- verbose_name
|
||||
- verbose_name_plural
|
||||
OAuth2ProviderRequest:
|
||||
@ -44856,8 +44862,9 @@ components:
|
||||
description: Key used to encrypt the tokens. When set, tokens will be encrypted
|
||||
and returned as JWEs.
|
||||
redirect_uris:
|
||||
type: string
|
||||
description: Enter each URI on a new line.
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RedirectURIRequest'
|
||||
sub_mode:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SubModeEnum'
|
||||
@ -44879,6 +44886,7 @@ components:
|
||||
- authorization_flow
|
||||
- invalidation_flow
|
||||
- name
|
||||
- redirect_uris
|
||||
OAuth2ProviderSetupURLs:
|
||||
type: object
|
||||
description: OAuth2 Provider Metadata serializer
|
||||
@ -44936,7 +44944,8 @@ components:
|
||||
minLength: 1
|
||||
default: ak-provider-oauth2-device-code
|
||||
code:
|
||||
type: integer
|
||||
type: string
|
||||
minLength: 1
|
||||
required:
|
||||
- code
|
||||
OAuthDeviceCodeFinishChallenge:
|
||||
@ -47732,6 +47741,8 @@ components:
|
||||
api_url:
|
||||
type: string
|
||||
minLength: 1
|
||||
interactive:
|
||||
type: boolean
|
||||
score_min_threshold:
|
||||
type: number
|
||||
format: double
|
||||
@ -48874,8 +48885,9 @@ components:
|
||||
description: Key used to encrypt the tokens. When set, tokens will be encrypted
|
||||
and returned as JWEs.
|
||||
redirect_uris:
|
||||
type: string
|
||||
description: Enter each URI on a new line.
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RedirectURIRequest'
|
||||
sub_mode:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SubModeEnum'
|
||||
@ -52064,6 +52076,29 @@ components:
|
||||
type: string
|
||||
required:
|
||||
- to
|
||||
RedirectURI:
|
||||
type: object
|
||||
description: A single allowed redirect URI entry
|
||||
properties:
|
||||
matching_mode:
|
||||
$ref: '#/components/schemas/MatchingModeEnum'
|
||||
url:
|
||||
type: string
|
||||
required:
|
||||
- matching_mode
|
||||
- url
|
||||
RedirectURIRequest:
|
||||
type: object
|
||||
description: A single allowed redirect URI entry
|
||||
properties:
|
||||
matching_mode:
|
||||
$ref: '#/components/schemas/MatchingModeEnum'
|
||||
url:
|
||||
type: string
|
||||
minLength: 1
|
||||
required:
|
||||
- matching_mode
|
||||
- url
|
||||
Reputation:
|
||||
type: object
|
||||
description: Reputation Serializer
|
||||
|
@ -12,7 +12,12 @@ from authentik.flows.models import Flow
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.policies.expression.models import ExpressionPolicy
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.providers.oauth2.models import ClientTypes, OAuth2Provider
|
||||
from authentik.providers.oauth2.models import (
|
||||
ClientTypes,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
)
|
||||
from tests.e2e.utils import SeleniumTestCase, retry
|
||||
|
||||
|
||||
@ -73,7 +78,9 @@ class TestProviderOAuth2Github(SeleniumTestCase):
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
client_type=ClientTypes.CONFIDENTIAL,
|
||||
redirect_uris="http://localhost:3000/login/github",
|
||||
redirect_uris=[
|
||||
RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost:3000/login/github")
|
||||
],
|
||||
authorization_flow=authorization_flow,
|
||||
)
|
||||
Application.objects.create(
|
||||
@ -128,7 +135,9 @@ class TestProviderOAuth2Github(SeleniumTestCase):
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
client_type=ClientTypes.CONFIDENTIAL,
|
||||
redirect_uris="http://localhost:3000/login/github",
|
||||
redirect_uris=[
|
||||
RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost:3000/login/github")
|
||||
],
|
||||
authorization_flow=authorization_flow,
|
||||
)
|
||||
app = Application.objects.create(
|
||||
@ -199,7 +208,9 @@ class TestProviderOAuth2Github(SeleniumTestCase):
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
client_type=ClientTypes.CONFIDENTIAL,
|
||||
redirect_uris="http://localhost:3000/login/github",
|
||||
redirect_uris=[
|
||||
RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost:3000/login/github")
|
||||
],
|
||||
authorization_flow=authorization_flow,
|
||||
)
|
||||
app = Application.objects.create(
|
||||
|
@ -19,7 +19,13 @@ from authentik.providers.oauth2.constants import (
|
||||
SCOPE_OPENID_EMAIL,
|
||||
SCOPE_OPENID_PROFILE,
|
||||
)
|
||||
from authentik.providers.oauth2.models import ClientTypes, OAuth2Provider, ScopeMapping
|
||||
from authentik.providers.oauth2.models import (
|
||||
ClientTypes,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
ScopeMapping,
|
||||
)
|
||||
from tests.e2e.utils import SeleniumTestCase, retry
|
||||
|
||||
|
||||
@ -82,7 +88,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
signing_key=create_test_cert(),
|
||||
redirect_uris="http://localhost:3000/",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost:3000/")],
|
||||
authorization_flow=authorization_flow,
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
@ -131,7 +137,11 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
signing_key=create_test_cert(),
|
||||
redirect_uris="http://localhost:3000/login/generic_oauth",
|
||||
redirect_uris=[
|
||||
RedirectURI(
|
||||
RedirectURIMatchingMode.STRICT, "http://localhost:3000/login/generic_oauth"
|
||||
)
|
||||
],
|
||||
authorization_flow=authorization_flow,
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
@ -200,7 +210,11 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
signing_key=create_test_cert(),
|
||||
redirect_uris="http://localhost:3000/login/generic_oauth",
|
||||
redirect_uris=[
|
||||
RedirectURI(
|
||||
RedirectURIMatchingMode.STRICT, "http://localhost:3000/login/generic_oauth"
|
||||
)
|
||||
],
|
||||
authorization_flow=authorization_flow,
|
||||
invalidation_flow=invalidation_flow,
|
||||
)
|
||||
@ -275,7 +289,11 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
signing_key=create_test_cert(),
|
||||
redirect_uris="http://localhost:3000/login/generic_oauth",
|
||||
redirect_uris=[
|
||||
RedirectURI(
|
||||
RedirectURIMatchingMode.STRICT, "http://localhost:3000/login/generic_oauth"
|
||||
)
|
||||
],
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
ScopeMapping.objects.filter(
|
||||
@ -355,7 +373,11 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
signing_key=create_test_cert(),
|
||||
redirect_uris="http://localhost:3000/login/generic_oauth",
|
||||
redirect_uris=[
|
||||
RedirectURI(
|
||||
RedirectURIMatchingMode.STRICT, "http://localhost:3000/login/generic_oauth"
|
||||
)
|
||||
],
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
ScopeMapping.objects.filter(
|
||||
|
@ -19,7 +19,13 @@ from authentik.providers.oauth2.constants import (
|
||||
SCOPE_OPENID_EMAIL,
|
||||
SCOPE_OPENID_PROFILE,
|
||||
)
|
||||
from authentik.providers.oauth2.models import ClientTypes, OAuth2Provider, ScopeMapping
|
||||
from authentik.providers.oauth2.models import (
|
||||
ClientTypes,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
ScopeMapping,
|
||||
)
|
||||
from tests.e2e.utils import SeleniumTestCase, retry
|
||||
|
||||
|
||||
@ -67,7 +73,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
signing_key=create_test_cert(),
|
||||
redirect_uris="http://localhost:9009/",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost:9009/")],
|
||||
authorization_flow=authorization_flow,
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
@ -116,7 +122,9 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
signing_key=create_test_cert(),
|
||||
redirect_uris="http://localhost:9009/auth/callback",
|
||||
redirect_uris=[
|
||||
RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost:9009/auth/callback")
|
||||
],
|
||||
authorization_flow=authorization_flow,
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
@ -188,7 +196,9 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
signing_key=create_test_cert(),
|
||||
redirect_uris="http://localhost:9009/auth/callback",
|
||||
redirect_uris=[
|
||||
RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost:9009/auth/callback")
|
||||
],
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
ScopeMapping.objects.filter(
|
||||
@ -259,7 +269,9 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
signing_key=create_test_cert(),
|
||||
redirect_uris="http://localhost:9009/auth/callback",
|
||||
redirect_uris=[
|
||||
RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost:9009/auth/callback")
|
||||
],
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
ScopeMapping.objects.filter(
|
||||
|
@ -19,7 +19,13 @@ from authentik.providers.oauth2.constants import (
|
||||
SCOPE_OPENID_EMAIL,
|
||||
SCOPE_OPENID_PROFILE,
|
||||
)
|
||||
from authentik.providers.oauth2.models import ClientTypes, OAuth2Provider, ScopeMapping
|
||||
from authentik.providers.oauth2.models import (
|
||||
ClientTypes,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
ScopeMapping,
|
||||
)
|
||||
from tests.e2e.utils import SeleniumTestCase, retry
|
||||
|
||||
|
||||
@ -68,7 +74,7 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase):
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
signing_key=create_test_cert(),
|
||||
redirect_uris="http://localhost:9009/",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost:9009/")],
|
||||
authorization_flow=authorization_flow,
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
@ -117,7 +123,9 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase):
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
signing_key=create_test_cert(),
|
||||
redirect_uris="http://localhost:9009/implicit/",
|
||||
redirect_uris=[
|
||||
RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost:9009/implicit/")
|
||||
],
|
||||
authorization_flow=authorization_flow,
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
@ -170,7 +178,9 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase):
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
signing_key=create_test_cert(),
|
||||
redirect_uris="http://localhost:9009/implicit/",
|
||||
redirect_uris=[
|
||||
RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost:9009/implicit/")
|
||||
],
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
ScopeMapping.objects.filter(
|
||||
@ -238,7 +248,9 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase):
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
signing_key=create_test_cert(),
|
||||
redirect_uris="http://localhost:9009/implicit/",
|
||||
redirect_uris=[
|
||||
RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost:9009/implicit/")
|
||||
],
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
ScopeMapping.objects.filter(
|
||||
|
54
web/package-lock.json
generated
54
web/package-lock.json
generated
@ -23,7 +23,7 @@
|
||||
"@floating-ui/dom": "^1.6.11",
|
||||
"@formatjs/intl-listformat": "^7.5.7",
|
||||
"@fortawesome/fontawesome-free": "^6.6.0",
|
||||
"@goauthentik/api": "^2024.8.3-1729836831",
|
||||
"@goauthentik/api": "^2024.10.2-1732196831",
|
||||
"@lit-labs/ssr": "^3.2.2",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@lit/localize": "^0.12.2",
|
||||
@ -84,7 +84,7 @@
|
||||
"@wdio/cli": "^9.1.2",
|
||||
"@wdio/spec-reporter": "^9.1.2",
|
||||
"chokidar": "^4.0.1",
|
||||
"chromedriver": "^129.0.2",
|
||||
"chromedriver": "^130.0.4",
|
||||
"esbuild": "^0.24.0",
|
||||
"eslint": "^9.11.1",
|
||||
"eslint-plugin-lit": "^1.15.0",
|
||||
@ -1775,9 +1775,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@goauthentik/api": {
|
||||
"version": "2024.8.3-1729836831",
|
||||
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2024.8.3-1729836831.tgz",
|
||||
"integrity": "sha512-nOgvjYQiK+HhWuiZ635h/aSsq7Mfj5cDrIyBJt+IJRQuJFtnnHx8nscRXKK/8sBl9obH2zMCoZgeqytK8145bg=="
|
||||
"version": "2024.10.2-1732196831",
|
||||
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2024.10.2-1732196831.tgz",
|
||||
"integrity": "sha512-+JU9XrgCjAp3wPC9elENVGLaUm0V2jPGV1ewhCYQnkKRIFQmJY9t/SIvbM7nPCCXJwgmpPTMaLT5McrE8ybWOg=="
|
||||
},
|
||||
"node_modules/@goauthentik/web": {
|
||||
"resolved": "",
|
||||
@ -3517,6 +3517,12 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@scarf/scarf": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
|
||||
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
|
||||
"hasInstallScript": true
|
||||
},
|
||||
"node_modules/@sec-ant/readable-stream": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz",
|
||||
@ -8693,9 +8699,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/chromedriver": {
|
||||
"version": "129.0.2",
|
||||
"resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-129.0.2.tgz",
|
||||
"integrity": "sha512-rUEFCJAmAwOdFfaDFtveT97fFeA7NOxlkgyPyN+G09Ws4qGW39aLDxMQBbS9cxQQHhTihqZZobgF5CLVYXnmGA==",
|
||||
"version": "130.0.4",
|
||||
"resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-130.0.4.tgz",
|
||||
"integrity": "sha512-lpR+PWXszij1k4Ig3t338Zvll9HtCTiwoLM7n4pCCswALHxzmgwaaIFBh3rt9+5wRk9D07oFblrazrBxwaYYAQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
@ -9052,9 +9058,10 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
||||
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@ -11404,9 +11411,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz",
|
||||
"integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==",
|
||||
"version": "4.21.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz",
|
||||
"integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
@ -11414,7 +11421,7 @@
|
||||
"body-parser": "1.20.3",
|
||||
"content-disposition": "0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "0.6.0",
|
||||
"cookie": "0.7.1",
|
||||
"cookie-signature": "1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
@ -19537,17 +19544,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/swagger-client": {
|
||||
"version": "3.29.3",
|
||||
"resolved": "https://registry.npmjs.org/swagger-client/-/swagger-client-3.29.3.tgz",
|
||||
"integrity": "sha512-OhhMAO2dwDEaxtUNDxwaqzw75uiZY5lX/2vx+U6eKCYZYhXWQ5mylU/0qfk/xMR20VyitsnzRc6KcFFjRoCS7A==",
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/swagger-client/-/swagger-client-3.31.0.tgz",
|
||||
"integrity": "sha512-hVYift5XB8nOgNJVl6cbNtVTVPT2Fdx2wCOcIvuAFcyq0Mwe6+70ezoZ5WfiaIAzzwWfq72jyaLeg8TViGNSmw==",
|
||||
"dependencies": {
|
||||
"@babel/runtime-corejs3": "^7.22.15",
|
||||
"@scarf/scarf": "=1.4.0",
|
||||
"@swagger-api/apidom-core": ">=1.0.0-alpha.9 <1.0.0-beta.0",
|
||||
"@swagger-api/apidom-error": ">=1.0.0-alpha.9 <1.0.0-beta.0",
|
||||
"@swagger-api/apidom-json-pointer": ">=1.0.0-alpha.9 <1.0.0-beta.0",
|
||||
"@swagger-api/apidom-ns-openapi-3-1": ">=1.0.0-alpha.9 <1.0.0-beta.0",
|
||||
"@swagger-api/apidom-reference": ">=1.0.0-alpha.9 <1.0.0-beta.0",
|
||||
"cookie": "~0.6.0",
|
||||
"cookie": "~0.7.2",
|
||||
"deepmerge": "~4.3.0",
|
||||
"fast-json-patch": "^3.0.0-1",
|
||||
"js-yaml": "^4.1.0",
|
||||
@ -19560,6 +19568,14 @@
|
||||
"ramda-adjunct": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/swagger-client/node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/syncpack": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/syncpack/-/syncpack-13.0.0.tgz",
|
||||
|
@ -11,7 +11,7 @@
|
||||
"@floating-ui/dom": "^1.6.11",
|
||||
"@formatjs/intl-listformat": "^7.5.7",
|
||||
"@fortawesome/fontawesome-free": "^6.6.0",
|
||||
"@goauthentik/api": "^2024.8.3-1729836831",
|
||||
"@goauthentik/api": "^2024.10.2-1732196831",
|
||||
"@lit-labs/ssr": "^3.2.2",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@lit/localize": "^0.12.2",
|
||||
@ -72,7 +72,7 @@
|
||||
"@wdio/cli": "^9.1.2",
|
||||
"@wdio/spec-reporter": "^9.1.2",
|
||||
"chokidar": "^4.0.1",
|
||||
"chromedriver": "^129.0.2",
|
||||
"chromedriver": "^130.0.4",
|
||||
"esbuild": "^0.24.0",
|
||||
"eslint": "^9.11.1",
|
||||
"eslint-plugin-lit": "^1.15.0",
|
||||
@ -328,12 +328,18 @@
|
||||
},
|
||||
"test:e2e:watch": {
|
||||
"command": "wdio run ./tests/wdio.conf.ts",
|
||||
"dependencies": [
|
||||
"build"
|
||||
],
|
||||
"env": {
|
||||
"TS_NODE_PROJECT": "./tests/tsconfig.test.json"
|
||||
}
|
||||
},
|
||||
"test:watch": {
|
||||
"command": "wdio run ./wdio.conf.ts",
|
||||
"dependencies": [
|
||||
"build"
|
||||
],
|
||||
"env": {
|
||||
"TS_NODE_PROJECT": "tsconfig.test.json"
|
||||
}
|
||||
|
100
web/src/admin/admin-settings/AdminSettingsFooterLinks.ts
Normal file
100
web/src/admin/admin-settings/AdminSettingsFooterLinks.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { AkControlElement } from "@goauthentik/elements/AkControlElement.js";
|
||||
import { type Spread } from "@goauthentik/elements/types";
|
||||
import { spread } from "@open-wc/lit-helpers";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, html } from "lit";
|
||||
import { customElement, property, queryAll } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
|
||||
import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import { FooterLink } from "@goauthentik/api";
|
||||
|
||||
export interface IFooterLinkInput {
|
||||
footerLink: FooterLink;
|
||||
}
|
||||
|
||||
const LEGAL_SCHEMES = ["http://", "https://", "mailto:"];
|
||||
const hasLegalScheme = (url: string) =>
|
||||
LEGAL_SCHEMES.some((scheme) => url.substr(0, scheme.length).toLowerCase() === scheme);
|
||||
|
||||
@customElement("ak-admin-settings-footer-link")
|
||||
export class FooterLinkInput extends AkControlElement<FooterLink> {
|
||||
static get styles() {
|
||||
return [
|
||||
PFBase,
|
||||
PFInputGroup,
|
||||
PFFormControl,
|
||||
css`
|
||||
.pf-c-input-group input#linkname {
|
||||
flex-grow: 1;
|
||||
width: 8rem;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@property({ type: Object, attribute: false })
|
||||
footerLink: FooterLink = {
|
||||
name: "",
|
||||
href: "",
|
||||
};
|
||||
|
||||
@queryAll(".ak-form-control")
|
||||
controls?: HTMLInputElement[];
|
||||
|
||||
json() {
|
||||
return Object.fromEntries(
|
||||
Array.from(this.controls ?? []).map((control) => [control.name, control.value]),
|
||||
) as unknown as FooterLink;
|
||||
}
|
||||
|
||||
get isValid() {
|
||||
const href = this.json()?.href ?? "";
|
||||
return hasLegalScheme(href) && URL.canParse(href);
|
||||
}
|
||||
|
||||
render() {
|
||||
const onChange = () => {
|
||||
this.dispatchEvent(new Event("change", { composed: true, bubbles: true }));
|
||||
};
|
||||
|
||||
return html` <div class="pf-c-input-group">
|
||||
<input
|
||||
type="text"
|
||||
@change=${onChange}
|
||||
value=${this.footerLink.name}
|
||||
id="linkname"
|
||||
class="pf-c-form-control ak-form-control"
|
||||
name="name"
|
||||
placeholder=${msg("Link Title")}
|
||||
tabindex="1"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
@change=${onChange}
|
||||
value="${ifDefined(this.footerLink.href ?? undefined)}"
|
||||
class="pf-c-form-control ak-form-control"
|
||||
required
|
||||
placeholder=${msg("URL")}
|
||||
name="href"
|
||||
tabindex="1"
|
||||
/>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
export function akFooterLinkInput(properties: IFooterLinkInput) {
|
||||
return html`<ak-admin-settings-footer-link
|
||||
${spread(properties as unknown as Spread)}
|
||||
></ak-admin-settings-footer-link>`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ak-admin-settings-footer-link": FooterLinkInput;
|
||||
}
|
||||
}
|
@ -3,8 +3,7 @@ import { first } from "@goauthentik/common/utils";
|
||||
import "@goauthentik/components/ak-number-input";
|
||||
import "@goauthentik/components/ak-switch-input";
|
||||
import "@goauthentik/components/ak-text-input";
|
||||
import "@goauthentik/elements/CodeMirror";
|
||||
import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror";
|
||||
import "@goauthentik/elements/ak-array-input.js";
|
||||
import { Form } from "@goauthentik/elements/forms/Form";
|
||||
import "@goauthentik/elements/forms/FormGroup";
|
||||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||
@ -13,13 +12,16 @@ import "@goauthentik/elements/forms/SearchSelect";
|
||||
import "@goauthentik/elements/utils/TimeDeltaHelp";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { CSSResult, TemplateResult, html } from "lit";
|
||||
import { CSSResult, TemplateResult, css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
import PFList from "@patternfly/patternfly/components/List/list.css";
|
||||
|
||||
import { AdminApi, Settings, SettingsRequest } from "@goauthentik/api";
|
||||
import { AdminApi, FooterLink, Settings, SettingsRequest } from "@goauthentik/api";
|
||||
|
||||
import "./AdminSettingsFooterLinks.js";
|
||||
import { IFooterLinkInput, akFooterLinkInput } from "./AdminSettingsFooterLinks.js";
|
||||
|
||||
@customElement("ak-admin-settings-form")
|
||||
export class AdminSettingsForm extends Form<SettingsRequest> {
|
||||
@ -40,7 +42,14 @@ export class AdminSettingsForm extends Form<SettingsRequest> {
|
||||
private _settings?: Settings;
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return super.styles.concat(PFList);
|
||||
return super.styles.concat(
|
||||
PFList,
|
||||
css`
|
||||
ak-array-input {
|
||||
width: 100%;
|
||||
}
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
getSuccessMessage(): string {
|
||||
@ -166,15 +175,21 @@ export class AdminSettingsForm extends Form<SettingsRequest> {
|
||||
>
|
||||
</ak-text-input>
|
||||
<ak-form-element-horizontal label=${msg("Footer links")} name="footerLinks">
|
||||
<ak-codemirror
|
||||
mode=${CodeMirrorMode.YAML}
|
||||
.value="${first(this._settings?.footerLinks, [])}"
|
||||
></ak-codemirror>
|
||||
<ak-array-input
|
||||
.items=${this._settings?.footerLinks ?? []}
|
||||
.newItem=${() => ({ name: "", href: "" })}
|
||||
.row=${(f?: FooterLink) =>
|
||||
akFooterLinkInput({
|
||||
".footerLink": f,
|
||||
"style": "width: 100%",
|
||||
"name": "footer-link",
|
||||
} as unknown as IFooterLinkInput)}
|
||||
>
|
||||
</ak-array-input>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg(
|
||||
"This option configures the footer links on the flow executor pages. It must be a valid YAML or JSON list and can be used as follows:",
|
||||
"This option configures the footer links on the flow executor pages. The URL is limited to web and mail addresses. If the name is left blank, the URL will be shown.",
|
||||
)}
|
||||
<code>[{"name": "Link Name","href":"https://goauthentik.io"}]</code>
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-switch-input
|
||||
|
@ -0,0 +1,80 @@
|
||||
import "@goauthentik/elements/messages/MessageContainer";
|
||||
import { Meta, StoryObj, WebComponentsRenderer } from "@storybook/web-components";
|
||||
import { DecoratorFunction } from "storybook/internal/types";
|
||||
|
||||
import { html } from "lit";
|
||||
|
||||
import { FooterLinkInput } from "../AdminSettingsFooterLinks.js";
|
||||
import "../AdminSettingsFooterLinks.js";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type Decorator = DecoratorFunction<WebComponentsRenderer, any>;
|
||||
|
||||
const metadata: Meta<FooterLinkInput> = {
|
||||
title: "Components / Footer Link Input",
|
||||
component: "ak-admin-settings-footer-link",
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: "A stylized control for the footer links",
|
||||
},
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(story: Decorator) => {
|
||||
window.setTimeout(() => {
|
||||
const control = document.getElementById("footer-link");
|
||||
if (!control) {
|
||||
throw new Error("Test was not initialized correctly.");
|
||||
}
|
||||
const messages = document.getElementById("reported-value");
|
||||
control.addEventListener("change", (event: Event) => {
|
||||
if (!event.target) {
|
||||
return;
|
||||
}
|
||||
const target = event.target as FooterLinkInput;
|
||||
messages!.innerText = `${JSON.stringify(target.json(), null, 2)}\n\nValid: ${target.isValid ? "Yes" : "No"}`;
|
||||
});
|
||||
}, 250);
|
||||
|
||||
return html`<div
|
||||
style="background: #fff; padding: 2em; position: relative"
|
||||
id="the-main-event"
|
||||
>
|
||||
<style>
|
||||
li {
|
||||
display: block;
|
||||
}
|
||||
p {
|
||||
margin-top: 1em;
|
||||
}
|
||||
#the-answer-block {
|
||||
padding-top: 3em;
|
||||
}
|
||||
</style>
|
||||
<div>
|
||||
${
|
||||
// @ts-expect-error The types for web components are not well-defined }
|
||||
story()
|
||||
}
|
||||
</div>
|
||||
<div style="margin-top: 2rem">
|
||||
<p>Reported value:</p>
|
||||
<pre id="reported-value"></pre>
|
||||
</div>
|
||||
</div>`;
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default metadata;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () =>
|
||||
html` <ak-admin-settings-footer-link
|
||||
id="footer-link"
|
||||
name="the-footer"
|
||||
></ak-admin-settings-footer-link>`,
|
||||
};
|
@ -0,0 +1,68 @@
|
||||
import { render } from "@goauthentik/elements/tests/utils.js";
|
||||
import { $, expect } from "@wdio/globals";
|
||||
|
||||
import { html } from "lit";
|
||||
|
||||
import "../AdminSettingsFooterLinks.js";
|
||||
|
||||
describe("ak-admin-settings-footer-link", () => {
|
||||
afterEach(async () => {
|
||||
await browser.execute(async () => {
|
||||
await document.body.querySelector("ak-admin-settings-footer-link")?.remove();
|
||||
if (document.body["_$litPart$"]) {
|
||||
// @ts-expect-error expression of type '"_$litPart$"' is added by Lit
|
||||
await delete document.body["_$litPart$"];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should render an empty control", async () => {
|
||||
render(html`<ak-admin-settings-footer-link name="link"></ak-admin-settings-footer-link>`);
|
||||
const link = await $("ak-admin-settings-footer-link");
|
||||
await expect(await link.getProperty("isValid")).toStrictEqual(false);
|
||||
await expect(await link.getProperty("toJson")).toEqual({ name: "", href: "" });
|
||||
});
|
||||
|
||||
it("should not be valid if just a name is filled in", async () => {
|
||||
render(html`<ak-admin-settings-footer-link name="link"></ak-admin-settings-footer-link>`);
|
||||
const link = await $("ak-admin-settings-footer-link");
|
||||
await link.$('input[name="name"]').setValue("foo");
|
||||
await expect(await link.getProperty("isValid")).toStrictEqual(false);
|
||||
await expect(await link.getProperty("toJson")).toEqual({ name: "foo", href: "" });
|
||||
});
|
||||
|
||||
it("should be valid if just a URL is filled in", async () => {
|
||||
render(html`<ak-admin-settings-footer-link name="link"></ak-admin-settings-footer-link>`);
|
||||
const link = await $("ak-admin-settings-footer-link");
|
||||
await link.$('input[name="href"]').setValue("https://foo.com");
|
||||
await expect(await link.getProperty("isValid")).toStrictEqual(true);
|
||||
await expect(await link.getProperty("toJson")).toEqual({
|
||||
name: "",
|
||||
href: "https://foo.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("should be valid if both are filled in", async () => {
|
||||
render(html`<ak-admin-settings-footer-link name="link"></ak-admin-settings-footer-link>`);
|
||||
const link = await $("ak-admin-settings-footer-link");
|
||||
await link.$('input[name="name"]').setValue("foo");
|
||||
await link.$('input[name="href"]').setValue("https://foo.com");
|
||||
await expect(await link.getProperty("isValid")).toStrictEqual(true);
|
||||
await expect(await link.getProperty("toJson")).toEqual({
|
||||
name: "foo",
|
||||
href: "https://foo.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not be valid if the URL is not valid", async () => {
|
||||
render(html`<ak-admin-settings-footer-link name="link"></ak-admin-settings-footer-link>`);
|
||||
const link = await $("ak-admin-settings-footer-link");
|
||||
await link.$('input[name="name"]').setValue("foo");
|
||||
await link.$('input[name="href"]').setValue("never://foo.com");
|
||||
await expect(await link.getProperty("toJson")).toEqual({
|
||||
name: "foo",
|
||||
href: "never://foo.com",
|
||||
});
|
||||
await expect(await link.getProperty("isValid")).toStrictEqual(false);
|
||||
});
|
||||
});
|
@ -1,11 +1,16 @@
|
||||
import "@goauthentik/admin/common/ak-crypto-certificate-search";
|
||||
import "@goauthentik/admin/common/ak-flow-search/ak-flow-search";
|
||||
import { BaseProviderForm } from "@goauthentik/admin/providers/BaseProviderForm";
|
||||
import {
|
||||
IRedirectURIInput,
|
||||
akOAuthRedirectURIInput,
|
||||
} from "@goauthentik/admin/providers/oauth2/OAuth2ProviderRedirectURI";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { ascii_letters, digits, first, randomString } from "@goauthentik/common/utils";
|
||||
import "@goauthentik/components/ak-radio-input";
|
||||
import "@goauthentik/components/ak-text-input";
|
||||
import "@goauthentik/components/ak-textarea-input";
|
||||
import "@goauthentik/elements/ak-array-input.js";
|
||||
import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js";
|
||||
import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider.js";
|
||||
import "@goauthentik/elements/forms/FormGroup";
|
||||
@ -15,7 +20,7 @@ import "@goauthentik/elements/forms/SearchSelect";
|
||||
import "@goauthentik/elements/utils/TimeDeltaHelp";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { TemplateResult, html } from "lit";
|
||||
import { TemplateResult, css, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
@ -23,8 +28,10 @@ import {
|
||||
ClientTypeEnum,
|
||||
FlowsInstancesListDesignationEnum,
|
||||
IssuerModeEnum,
|
||||
MatchingModeEnum,
|
||||
OAuth2Provider,
|
||||
ProvidersApi,
|
||||
RedirectURI,
|
||||
SubModeEnum,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
@ -98,13 +105,13 @@ export const issuerModeOptions = [
|
||||
|
||||
const redirectUriHelpMessages = [
|
||||
msg(
|
||||
"Valid redirect URLs after a successful authorization flow. Also specify any origins here for Implicit flows.",
|
||||
"Valid redirect URIs after a successful authorization flow. Also specify any origins here for Implicit flows.",
|
||||
),
|
||||
msg(
|
||||
"If no explicit redirect URIs are specified, the first successfully used redirect URI will be saved.",
|
||||
),
|
||||
msg(
|
||||
'To allow any redirect URI, set this value to ".*". Be aware of the possible security implications this can have.',
|
||||
'To allow any redirect URI, set the mode to Regex and the value to ".*". Be aware of the possible security implications this can have.',
|
||||
),
|
||||
];
|
||||
|
||||
@ -124,11 +131,23 @@ export class OAuth2ProviderFormPage extends BaseProviderForm<OAuth2Provider> {
|
||||
@state()
|
||||
showClientSecret = true;
|
||||
|
||||
@state()
|
||||
redirectUris: RedirectURI[] = [];
|
||||
|
||||
static get styles() {
|
||||
return super.styles.concat(css`
|
||||
ak-array-input {
|
||||
width: 100%;
|
||||
}
|
||||
`);
|
||||
}
|
||||
|
||||
async loadInstance(pk: number): Promise<OAuth2Provider> {
|
||||
const provider = await new ProvidersApi(DEFAULT_CONFIG).providersOauth2Retrieve({
|
||||
id: pk,
|
||||
});
|
||||
this.showClientSecret = provider.clientType === ClientTypeEnum.Confidential;
|
||||
this.redirectUris = provider.redirectUris;
|
||||
return provider;
|
||||
}
|
||||
|
||||
@ -203,13 +222,23 @@ export class OAuth2ProviderFormPage extends BaseProviderForm<OAuth2Provider> {
|
||||
?hidden=${!this.showClientSecret}
|
||||
>
|
||||
</ak-text-input>
|
||||
<ak-textarea-input
|
||||
<ak-form-element-horizontal
|
||||
label=${msg("Redirect URIs/Origins")}
|
||||
required
|
||||
name="redirectUris"
|
||||
label=${msg("Redirect URIs/Origins (RegEx)")}
|
||||
.value=${provider?.redirectUris}
|
||||
.bighelp=${redirectUriHelp}
|
||||
>
|
||||
</ak-textarea-input>
|
||||
<ak-array-input
|
||||
.items=${this.instance?.redirectUris ?? []}
|
||||
.newItem=${() => ({ matchingMode: MatchingModeEnum.Strict, url: "" })}
|
||||
.row=${(f?: RedirectURI) =>
|
||||
akOAuthRedirectURIInput({
|
||||
".redirectURI": f,
|
||||
"style": "width: 100%",
|
||||
} as unknown as IRedirectURIInput)}
|
||||
>
|
||||
</ak-array-input>
|
||||
${redirectUriHelp}
|
||||
</ak-form-element-horizontal>
|
||||
|
||||
<ak-form-element-horizontal label=${msg("Signing Key")} name="signingKey">
|
||||
<!-- NOTE: 'null' cast to 'undefined' on signingKey to satisfy Lit requirements -->
|
||||
|
104
web/src/admin/providers/oauth2/OAuth2ProviderRedirectURI.ts
Normal file
104
web/src/admin/providers/oauth2/OAuth2ProviderRedirectURI.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import "@goauthentik/admin/providers/oauth2/OAuth2ProviderRedirectURI";
|
||||
import { AkControlElement } from "@goauthentik/elements/AkControlElement.js";
|
||||
import { type Spread } from "@goauthentik/elements/types";
|
||||
import { spread } from "@open-wc/lit-helpers";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, html } from "lit";
|
||||
import { customElement, property, queryAll } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
|
||||
import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import { MatchingModeEnum, RedirectURI } from "@goauthentik/api";
|
||||
|
||||
export interface IRedirectURIInput {
|
||||
redirectURI: RedirectURI;
|
||||
}
|
||||
|
||||
@customElement("ak-provider-oauth2-redirect-uri")
|
||||
export class OAuth2ProviderRedirectURI extends AkControlElement<RedirectURI> {
|
||||
static get styles() {
|
||||
return [
|
||||
PFBase,
|
||||
PFInputGroup,
|
||||
PFFormControl,
|
||||
css`
|
||||
.pf-c-input-group select {
|
||||
width: 10em;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@property({ type: Object, attribute: false })
|
||||
redirectURI: RedirectURI = {
|
||||
matchingMode: MatchingModeEnum.Strict,
|
||||
url: "",
|
||||
};
|
||||
|
||||
@queryAll(".ak-form-control")
|
||||
controls?: HTMLInputElement[];
|
||||
|
||||
json() {
|
||||
return Object.fromEntries(
|
||||
Array.from(this.controls ?? []).map((control) => [control.name, control.value]),
|
||||
) as unknown as RedirectURI;
|
||||
}
|
||||
|
||||
get isValid() {
|
||||
return true;
|
||||
}
|
||||
|
||||
render() {
|
||||
const onChange = () => {
|
||||
this.dispatchEvent(new Event("change", { composed: true, bubbles: true }));
|
||||
};
|
||||
|
||||
return html`<div class="pf-c-input-group">
|
||||
<select
|
||||
name="matchingMode"
|
||||
class="pf-c-form-control ak-form-control"
|
||||
@change=${onChange}
|
||||
>
|
||||
<option
|
||||
value="${MatchingModeEnum.Strict}"
|
||||
?selected=${this.redirectURI.matchingMode === MatchingModeEnum.Strict}
|
||||
>
|
||||
${msg("Strict")}
|
||||
</option>
|
||||
<option
|
||||
value="${MatchingModeEnum.Regex}"
|
||||
?selected=${this.redirectURI.matchingMode === MatchingModeEnum.Regex}
|
||||
>
|
||||
${msg("Regex")}
|
||||
</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
@change=${onChange}
|
||||
value="${ifDefined(this.redirectURI.url ?? undefined)}"
|
||||
class="pf-c-form-control ak-form-control"
|
||||
required
|
||||
id="url"
|
||||
placeholder=${msg("URL")}
|
||||
name="href"
|
||||
tabindex="1"
|
||||
/>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
export function akOAuthRedirectURIInput(properties: IRedirectURIInput) {
|
||||
return html`<ak-provider-oauth2-redirect-uri
|
||||
${spread(properties as unknown as Spread)}
|
||||
></ak-provider-oauth2-redirect-uri>`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ak-provider-oauth2-redirect-uri": OAuth2ProviderRedirectURI;
|
||||
}
|
||||
}
|
@ -2,6 +2,7 @@ import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { first } from "@goauthentik/common/utils";
|
||||
import "@goauthentik/components/ak-number-input";
|
||||
import "@goauthentik/components/ak-switch-input";
|
||||
import "@goauthentik/elements/forms/FormGroup";
|
||||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||
|
||||
@ -80,6 +81,15 @@ export class CaptchaStageForm extends BaseStageForm<CaptchaStage> {
|
||||
)}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-switch-input
|
||||
name="interactive"
|
||||
label=${msg("Interactive")}
|
||||
?checked="${this.instance?.interactive}"
|
||||
help=${msg(
|
||||
"Enable this flag if the configured captcha requires User-interaction. Required for reCAPTCHA v2, hCaptcha and Cloudflare Turnstile.",
|
||||
)}
|
||||
>
|
||||
</ak-switch-input>
|
||||
<ak-number-input
|
||||
label=${msg("Score minimum threshold")}
|
||||
required
|
||||
|
@ -3,7 +3,7 @@ export const SUCCESS_CLASS = "pf-m-success";
|
||||
export const ERROR_CLASS = "pf-m-danger";
|
||||
export const PROGRESS_CLASS = "pf-m-in-progress";
|
||||
export const CURRENT_CLASS = "pf-m-current";
|
||||
export const VERSION = "2024.10.1";
|
||||
export const VERSION = "2024.10.3";
|
||||
export const TITLE_DEFAULT = "authentik";
|
||||
export const ROUTE_SEPARATOR = ";";
|
||||
|
||||
|
@ -10,10 +10,14 @@ export const DOM_PURIFY_STRICT: DOMPurify.Config = {
|
||||
ALLOWED_TAGS: ["#text"],
|
||||
};
|
||||
|
||||
export async function renderStatic(input: TemplateResult): Promise<string> {
|
||||
return await collectResult(render(input));
|
||||
}
|
||||
|
||||
export function purify(input: TemplateResult): TemplateResult {
|
||||
return html`${until(
|
||||
(async () => {
|
||||
const rendered = await collectResult(render(input));
|
||||
const rendered = await renderStatic(input);
|
||||
const purified = DOMPurify.sanitize(rendered);
|
||||
return html`${unsafeHTML(purified)}`;
|
||||
})(),
|
||||
|
@ -8,13 +8,21 @@ import { AKElement } from "./Base";
|
||||
* extracting the value.
|
||||
*
|
||||
*/
|
||||
export class AkControlElement extends AKElement {
|
||||
export class AkControlElement<T = string | string[]> extends AKElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.dataset.akControl = "true";
|
||||
}
|
||||
|
||||
json() {
|
||||
json(): T {
|
||||
throw new Error("Controllers using this protocol must override this method");
|
||||
}
|
||||
|
||||
get toJson(): T {
|
||||
return this.json();
|
||||
}
|
||||
|
||||
get isValid(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
173
web/src/elements/ak-array-input.ts
Normal file
173
web/src/elements/ak-array-input.ts
Normal file
@ -0,0 +1,173 @@
|
||||
import { AkControlElement } from "@goauthentik/elements/AkControlElement";
|
||||
import { bound } from "@goauthentik/elements/decorators/bound";
|
||||
import { type Spread } from "@goauthentik/elements/types";
|
||||
import { randomId } from "@goauthentik/elements/utils/randomId.js";
|
||||
import { spread } from "@open-wc/lit-helpers";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { TemplateResult, css, html, nothing } from "lit";
|
||||
import { customElement, property, queryAll } from "lit/decorators.js";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
|
||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
|
||||
import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
type InputCell<T> = (el: T) => TemplateResult | typeof nothing;
|
||||
|
||||
export interface IArrayInput<T> {
|
||||
row: InputCell<T>;
|
||||
newItem: () => T;
|
||||
items: T[];
|
||||
validate?: boolean;
|
||||
validator?: (_: T[]) => boolean;
|
||||
}
|
||||
|
||||
type Keyed<T> = { key: string; item: T };
|
||||
|
||||
@customElement("ak-array-input")
|
||||
export class ArrayInput<T> extends AkControlElement<T[]> implements IArrayInput<T> {
|
||||
static get styles() {
|
||||
return [
|
||||
PFBase,
|
||||
PFButton,
|
||||
PFInputGroup,
|
||||
PFFormControl,
|
||||
css`
|
||||
select.pf-c-form-control {
|
||||
width: 100px;
|
||||
}
|
||||
.pf-c-input-group {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.ak-plus-button {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-direction: row;
|
||||
}
|
||||
.ak-input-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@property({ type: Boolean })
|
||||
validate = false;
|
||||
|
||||
@property({ type: Object, attribute: false })
|
||||
validator?: (_: T[]) => boolean;
|
||||
|
||||
@property({ type: Array, attribute: false })
|
||||
row!: InputCell<T>;
|
||||
|
||||
@property({ type: Object, attribute: false })
|
||||
newItem!: () => T;
|
||||
|
||||
_items: Keyed<T>[] = [];
|
||||
|
||||
// This magic creates a semi-reliable key on which Lit's `repeat` directive can control its
|
||||
// interaction. Without it, we get undefined behavior in the re-rendering of the array.
|
||||
@property({ type: Array, attribute: false })
|
||||
set items(items: T[]) {
|
||||
const olditems = new Map(
|
||||
(this._items ?? []).map((key, item) => [JSON.stringify(item), key]),
|
||||
);
|
||||
const newitems = items.map((item) => ({
|
||||
item,
|
||||
key: olditems.get(JSON.stringify(item))?.key ?? randomId(),
|
||||
}));
|
||||
this._items = newitems;
|
||||
}
|
||||
|
||||
get items() {
|
||||
return this._items.map(({ item }) => item);
|
||||
}
|
||||
|
||||
@queryAll("div.ak-input-group")
|
||||
inputGroups?: HTMLDivElement[];
|
||||
|
||||
json() {
|
||||
if (!this.inputGroups) {
|
||||
throw new Error("Could not find input group collection in ak-array-input");
|
||||
}
|
||||
return this.items;
|
||||
}
|
||||
|
||||
get isValid() {
|
||||
if (!this.validate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const oneIsValid = (g: HTMLDivElement) =>
|
||||
g.querySelector<HTMLInputElement & AkControlElement<T>>("[name]")?.isValid ?? true;
|
||||
const allAreValid = Array.from(this.inputGroups ?? []).every(oneIsValid);
|
||||
return allAreValid && (this.validator ? this.validator(this.items) : true);
|
||||
}
|
||||
|
||||
itemsFromDom(): T[] {
|
||||
return Array.from(this.inputGroups ?? [])
|
||||
.map(
|
||||
(group) =>
|
||||
group.querySelector<HTMLInputElement & AkControlElement<T>>("[name]")?.json() ??
|
||||
null,
|
||||
)
|
||||
.filter((i) => i !== null);
|
||||
}
|
||||
|
||||
sendChange() {
|
||||
this.dispatchEvent(new Event("change", { composed: true, bubbles: true }));
|
||||
}
|
||||
|
||||
@bound
|
||||
onChange() {
|
||||
this.items = this.itemsFromDom();
|
||||
this.sendChange();
|
||||
}
|
||||
|
||||
@bound
|
||||
addNewGroup() {
|
||||
this.items = [...this.itemsFromDom(), this.newItem()];
|
||||
this.sendChange();
|
||||
}
|
||||
|
||||
renderDeleteButton(idx: number) {
|
||||
const deleteOneGroup = () => {
|
||||
this.items = [...this.items.slice(0, idx), ...this.items.slice(idx + 1)];
|
||||
this.sendChange();
|
||||
};
|
||||
|
||||
return html`<button class="pf-c-button pf-m-control" type="button" @click=${deleteOneGroup}>
|
||||
<i class="fas fa-minus" aria-hidden="true"></i>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html` <div class="pf-l-stack">
|
||||
${repeat(
|
||||
this._items,
|
||||
(item: Keyed<T>) => item.key,
|
||||
(item: Keyed<T>, idx) =>
|
||||
html` <div class="ak-input-group" @change=${() => this.onChange()}>
|
||||
${this.row(item.item)}${this.renderDeleteButton(idx)}
|
||||
</div>`,
|
||||
)}
|
||||
<button class="pf-c-button pf-m-link" type="button" @click=${this.addNewGroup}>
|
||||
<i class="fas fa-plus" aria-hidden="true"></i> ${msg("Add entry")}
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
export function akArrayInput<T>(properties: IArrayInput<T>) {
|
||||
return html`<ak-array-input ${spread(properties as unknown as Spread)}></ak-array-input>`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ak-array-input": ArrayInput<unknown>;
|
||||
}
|
||||
}
|
@ -35,7 +35,7 @@ export interface KeyUnknown {
|
||||
// Literally the only field `assignValue()` cares about.
|
||||
type HTMLNamedElement = Pick<HTMLInputElement, "name">;
|
||||
|
||||
type AkControlElement = HTMLInputElement & { json: () => string | string[] };
|
||||
export type AkControlElement<T = string | string[]> = HTMLInputElement & { json: () => T };
|
||||
|
||||
/**
|
||||
* Recursively assign `value` into `json` while interpreting the dot-path of `element.name`
|
||||
|
@ -2,7 +2,7 @@ import { convertToSlug } from "@goauthentik/common/utils";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import { FormGroup } from "@goauthentik/elements/forms/FormGroup";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { msg, str } from "@lit/localize";
|
||||
import { CSSResult, css } from "lit";
|
||||
import { TemplateResult, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
@ -33,7 +33,7 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
* where the field isn't available for the user to view unless they explicitly request to be able
|
||||
* to see the content; otherwise, a dead password field is shown. There are 10 uses of this
|
||||
* feature.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
const isAkControl = (el: unknown): boolean =>
|
||||
@ -86,7 +86,7 @@ export class HorizontalFormElement extends AKElement {
|
||||
writeOnlyActivated = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
errorMessages: string[] = [];
|
||||
errorMessages: string[] | string[][] = [];
|
||||
|
||||
@property({ type: Boolean })
|
||||
slugMode = false;
|
||||
@ -183,6 +183,16 @@ export class HorizontalFormElement extends AKElement {
|
||||
</p>`
|
||||
: html``}
|
||||
${this.errorMessages.map((message) => {
|
||||
if (message instanceof Object) {
|
||||
return html`${Object.entries(message).map(([field, errMsg]) => {
|
||||
return html`<p
|
||||
class="pf-c-form__helper-text pf-m-error"
|
||||
aria-live="polite"
|
||||
>
|
||||
${msg(str`${field}: ${errMsg}`)}
|
||||
</p>`;
|
||||
})}`;
|
||||
}
|
||||
return html`<p class="pf-c-form__helper-text pf-m-error" aria-live="polite">
|
||||
${message}
|
||||
</p>`;
|
||||
|
@ -4,7 +4,6 @@ import { groupBy } from "@goauthentik/common/utils";
|
||||
import { AkControlElement } from "@goauthentik/elements/AkControlElement.js";
|
||||
import { PreventFormSubmit } from "@goauthentik/elements/forms/helpers";
|
||||
import type { GroupedOptions, SelectGroup, SelectOption } from "@goauthentik/elements/types.js";
|
||||
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
|
||||
import { randomId } from "@goauthentik/elements/utils/randomId.js";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
@ -32,10 +31,7 @@ export interface ISearchSelectBase<T> {
|
||||
emptyOption: string;
|
||||
}
|
||||
|
||||
export class SearchSelectBase<T>
|
||||
extends CustomEmitterElement(AkControlElement)
|
||||
implements ISearchSelectBase<T>
|
||||
{
|
||||
export class SearchSelectBase<T> extends AkControlElement<string> implements ISearchSelectBase<T> {
|
||||
static get styles() {
|
||||
return [PFBase];
|
||||
}
|
||||
@ -54,7 +50,7 @@ export class SearchSelectBase<T>
|
||||
|
||||
// A function which returns the currently selected object's primary key, used for serialization
|
||||
// into forms.
|
||||
value!: (element: T | undefined) => unknown;
|
||||
value!: (element: T | undefined) => string;
|
||||
|
||||
// A function passed to this object that determines an object in the collection under search
|
||||
// should be automatically selected. Only used when the search itself is responsible for
|
||||
@ -105,7 +101,7 @@ export class SearchSelectBase<T>
|
||||
@state()
|
||||
error?: APIErrorTypes;
|
||||
|
||||
public toForm(): unknown {
|
||||
public toForm(): string {
|
||||
if (!this.objects) {
|
||||
throw new PreventFormSubmit(msg("Loading options..."));
|
||||
}
|
||||
@ -116,6 +112,16 @@ export class SearchSelectBase<T>
|
||||
return this.toForm();
|
||||
}
|
||||
|
||||
protected dispatchChangeEvent(value: T | undefined) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("ak-change", {
|
||||
composed: true,
|
||||
bubbles: true,
|
||||
detail: { value },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async updateData() {
|
||||
if (this.isFetchingData) {
|
||||
return Promise.resolve();
|
||||
@ -127,7 +133,7 @@ export class SearchSelectBase<T>
|
||||
objects.forEach((obj) => {
|
||||
if (this.selected && this.selected(obj, objects || [])) {
|
||||
this.selectedObject = obj;
|
||||
this.dispatchCustomEvent("ak-change", { value: this.selectedObject });
|
||||
this.dispatchChangeEvent(this.selectedObject);
|
||||
}
|
||||
});
|
||||
this.objects = objects;
|
||||
@ -165,7 +171,7 @@ export class SearchSelectBase<T>
|
||||
|
||||
this.query = value;
|
||||
this.updateData()?.then(() => {
|
||||
this.dispatchCustomEvent("ak-change", { value: this.selectedObject });
|
||||
this.dispatchChangeEvent(this.selectedObject);
|
||||
});
|
||||
}
|
||||
|
||||
@ -173,7 +179,7 @@ export class SearchSelectBase<T>
|
||||
const value = (event.target as SearchSelectView).value;
|
||||
if (value === undefined) {
|
||||
this.selectedObject = undefined;
|
||||
this.dispatchCustomEvent("ak-change", { value: undefined });
|
||||
this.dispatchChangeEvent(undefined);
|
||||
return;
|
||||
}
|
||||
const selected = (this.objects ?? []).find((obj) => `${this.value(obj)}` === value);
|
||||
@ -181,7 +187,7 @@ export class SearchSelectBase<T>
|
||||
console.warn(`ak-search-select: No corresponding object found for value (${value}`);
|
||||
}
|
||||
this.selectedObject = selected;
|
||||
this.dispatchCustomEvent("ak-change", { value: this.selectedObject });
|
||||
this.dispatchChangeEvent(this.selectedObject);
|
||||
}
|
||||
|
||||
private getGroupedItems(): GroupedOptions {
|
||||
|
@ -7,7 +7,7 @@ export interface ISearchSelectApi<T> {
|
||||
fetchObjects: (query?: string) => Promise<T[]>;
|
||||
renderElement: (element: T) => string;
|
||||
renderDescription?: (element: T) => string | TemplateResult;
|
||||
value: (element: T | undefined) => unknown;
|
||||
value: (element: T | undefined) => string;
|
||||
selected?: (element: T, elements: T[]) => boolean;
|
||||
groupBy?: (items: T[]) => [string, T[]][];
|
||||
}
|
||||
|
@ -9,7 +9,7 @@ export interface ISearchSelect<T> extends ISearchSelectBase<T> {
|
||||
fetchObjects: (query?: string) => Promise<T[]>;
|
||||
renderElement: (element: T) => string;
|
||||
renderDescription?: (element: T) => string | TemplateResult;
|
||||
value: (element: T | undefined) => unknown;
|
||||
value: (element: T | undefined) => string;
|
||||
selected?: (element: T, elements: T[]) => boolean;
|
||||
groupBy: (items: T[]) => [string, T[]][];
|
||||
}
|
||||
@ -69,7 +69,7 @@ export class SearchSelect<T> extends SearchSelectBase<T> implements ISearchSelec
|
||||
// A function which returns the currently selected object's primary key, used for serialization
|
||||
// into forms.
|
||||
@property({ attribute: false })
|
||||
value!: (element: T | undefined) => unknown;
|
||||
value!: (element: T | undefined) => string;
|
||||
|
||||
// A function passed to this object that determines an object in the collection under search
|
||||
// should be automatically selected. Only used when the search itself is responsible for
|
||||
|
@ -92,7 +92,7 @@ export const GroupedAndEz = () => {
|
||||
const config: ISearchSelectApi<Sample> = {
|
||||
fetchObjects: getSamples,
|
||||
renderElement: (sample: Sample) => sample.name,
|
||||
value: (sample: Sample | undefined) => sample?.pk,
|
||||
value: (sample: Sample | undefined) => sample?.pk ?? "",
|
||||
groupBy: (samples: Sample[]) =>
|
||||
groupBy(samples, (sample: Sample) => sample.season[0] ?? ""),
|
||||
};
|
||||
|
96
web/src/elements/stories/ak-array-input.stories.ts
Normal file
96
web/src/elements/stories/ak-array-input.stories.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import "@goauthentik/admin/admin-settings/AdminSettingsFooterLinks.js";
|
||||
import { FooterLinkInput } from "@goauthentik/admin/admin-settings/AdminSettingsFooterLinks.js";
|
||||
import "@goauthentik/elements/messages/MessageContainer";
|
||||
import { Meta, StoryObj, WebComponentsRenderer } from "@storybook/web-components";
|
||||
import { DecoratorFunction } from "storybook/internal/types";
|
||||
|
||||
import { html } from "lit";
|
||||
|
||||
import { FooterLink } from "@goauthentik/api";
|
||||
|
||||
import "../ak-array-input.js";
|
||||
import { IArrayInput } from "../ak-array-input.js";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type Decorator = DecoratorFunction<WebComponentsRenderer, any>;
|
||||
|
||||
const metadata: Meta<IArrayInput<unknown>> = {
|
||||
title: "Elements / Array Input",
|
||||
component: "ak-array-input",
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A table input object, in which multiple rows of related inputs can be grouped.",
|
||||
},
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(story: Decorator) => {
|
||||
window.setTimeout(() => {
|
||||
const menu = document.getElementById("ak-array-input");
|
||||
if (!menu) {
|
||||
throw new Error("Test was not initialized correctly.");
|
||||
}
|
||||
const messages = document.getElementById("reported-value");
|
||||
menu.addEventListener("change", (event: Event) => {
|
||||
if (!event?.target) {
|
||||
return;
|
||||
}
|
||||
const target = event.target as FooterLinkInput;
|
||||
messages!.innerText = `${JSON.stringify(target.json(), null, 2)}\n\nValid: ${target.isValid ? "Yes" : "No"}`;
|
||||
});
|
||||
}, 250);
|
||||
|
||||
return html`<div
|
||||
style="background: #fff; padding: 2em; position: relative"
|
||||
id="the-main-event"
|
||||
>
|
||||
<style>
|
||||
li {
|
||||
display: block;
|
||||
}
|
||||
p {
|
||||
margin-top: 1em;
|
||||
}
|
||||
#the-answer-block {
|
||||
padding-top: 3em;
|
||||
}
|
||||
</style>
|
||||
<div>
|
||||
<p>Story:</p>
|
||||
${
|
||||
// @ts-expect-error The types for web components are not well-defined in Storybook yet }
|
||||
story()
|
||||
}
|
||||
<div style="margin-top: 2rem">
|
||||
<p>Reported value:</p>
|
||||
<pre id="reported-value"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default metadata;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
const items: FooterLink[] = [
|
||||
{ name: "authentik", href: "https://goauthentik.io" },
|
||||
{ name: "authentik docs", href: "https://docs.goauthentik.io/docs/" },
|
||||
];
|
||||
|
||||
export const Default: Story = {
|
||||
render: () =>
|
||||
html` <ak-array-input
|
||||
id="ak-array-input"
|
||||
.items=${items}
|
||||
.newItem=${() => ({ name: "", href: "" })}
|
||||
.row=${(f?: FooterLink) =>
|
||||
html`<ak-admin-settings-footer-link name="footerLink" .footerLink=${f}>
|
||||
</ak-admin-settings-footer-link>`}
|
||||
validate
|
||||
></ak-array-input>`,
|
||||
};
|
55
web/src/elements/tests/ak-array-input.test.ts
Normal file
55
web/src/elements/tests/ak-array-input.test.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import "@goauthentik/admin/admin-settings/AdminSettingsFooterLinks.js";
|
||||
import { render } from "@goauthentik/elements/tests/utils.js";
|
||||
import { $, expect } from "@wdio/globals";
|
||||
|
||||
import { html } from "lit";
|
||||
|
||||
import { FooterLink } from "@goauthentik/api";
|
||||
|
||||
import "../ak-array-input.js";
|
||||
|
||||
const sampleItems: FooterLink[] = [
|
||||
{ name: "authentik", href: "https://goauthentik.io" },
|
||||
{ name: "authentik docs", href: "https://docs.goauthentik.io/docs/" },
|
||||
];
|
||||
|
||||
describe("ak-array-input", () => {
|
||||
afterEach(async () => {
|
||||
await browser.execute(async () => {
|
||||
await document.body.querySelector("ak-array-input")?.remove();
|
||||
if (document.body["_$litPart$"]) {
|
||||
// @ts-expect-error expression of type '"_$litPart$"' is added by Lit
|
||||
await delete document.body["_$litPart$"];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const component = (items: FooterLink[] = []) =>
|
||||
render(
|
||||
html` <ak-array-input
|
||||
id="ak-array-input"
|
||||
.items=${items}
|
||||
.newItem=${() => ({ name: "", href: "" })}
|
||||
.row=${(f?: FooterLink) =>
|
||||
html`<ak-admin-settings-footer-link name="footerLink" .footerLink=${f}>
|
||||
</ak-admin-settings-footer-link>`}
|
||||
validate
|
||||
></ak-array-input>`,
|
||||
);
|
||||
|
||||
it("should render an empty control", async () => {
|
||||
await component();
|
||||
const link = await $("ak-array-input");
|
||||
await browser.pause(500);
|
||||
await expect(await link.getProperty("isValid")).toStrictEqual(true);
|
||||
await expect(await link.getProperty("toJson")).toEqual([]);
|
||||
});
|
||||
|
||||
it("should render a populated component", async () => {
|
||||
await component(sampleItems);
|
||||
const link = await $("ak-array-input");
|
||||
await browser.pause(500);
|
||||
await expect(await link.getProperty("isValid")).toStrictEqual(true);
|
||||
await expect(await link.getProperty("toJson")).toEqual(sampleItems);
|
||||
});
|
||||
});
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user