Compare commits
9 Commits
version/20
...
version/20
| Author | SHA1 | Date | |
|---|---|---|---|
| c99a33baee | |||
| b17d482e50 | |||
| 524d46ad7c | |||
| f90d6bb3d9 | |||
| 2340bced63 | |||
| 0a51e1b696 | |||
| 13636c0efe | |||
| e7f49d97a8 | |||
| 736240f60d |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 2024.8.4
|
||||
current_version = 2024.8.5
|
||||
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.8.4"
|
||||
__version__ = "2024.8.5"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -31,6 +31,7 @@ class TestTransactionalApplicationsAPI(APITestCase):
|
||||
"provider": {
|
||||
"name": uid,
|
||||
"authorization_flow": str(authorization_flow.pk),
|
||||
"redirect_uris": [],
|
||||
},
|
||||
},
|
||||
)
|
||||
@ -56,6 +57,7 @@ class TestTransactionalApplicationsAPI(APITestCase):
|
||||
"provider": {
|
||||
"name": uid,
|
||||
"authorization_flow": "",
|
||||
"redirect_uris": [],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@ -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):
|
||||
@ -263,7 +263,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 +295,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(
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
"""authentik events signal listener"""
|
||||
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.signals import user_logged_in, user_logged_out
|
||||
from django.db.models.signals import post_save, pre_delete
|
||||
from django.dispatch import receiver
|
||||
from django.http import HttpRequest
|
||||
from rest_framework.request import Request
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.core.models import AuthenticatedSession, User
|
||||
from authentik.core.signals import login_failed, password_changed
|
||||
from authentik.events.apps import SYSTEM_TASK_STATUS
|
||||
from authentik.events.models import Event, EventAction, SystemTask
|
||||
@ -23,6 +26,7 @@ from authentik.stages.user_write.signals import user_write
|
||||
from authentik.tenants.utils import get_current_tenant
|
||||
|
||||
SESSION_LOGIN_EVENT = "login_event"
|
||||
_session_engine = import_module(settings.SESSION_ENGINE)
|
||||
|
||||
|
||||
@receiver(user_logged_in)
|
||||
@ -40,11 +44,20 @@ def on_user_logged_in(sender, request: HttpRequest, user: User, **_):
|
||||
kwargs[PLAN_CONTEXT_METHOD_ARGS] = flow_plan.context.get(PLAN_CONTEXT_METHOD_ARGS, {})
|
||||
event = Event.new(EventAction.LOGIN, **kwargs).from_http(request, user=user)
|
||||
request.session[SESSION_LOGIN_EVENT] = event
|
||||
request.session.save()
|
||||
|
||||
|
||||
def get_login_event(request: HttpRequest) -> Event | None:
|
||||
def get_login_event(request_or_session: HttpRequest | AuthenticatedSession | None) -> Event | None:
|
||||
"""Wrapper to get login event that can be mocked in tests"""
|
||||
return request.session.get(SESSION_LOGIN_EVENT, None)
|
||||
session = None
|
||||
if not request_or_session:
|
||||
return None
|
||||
if isinstance(request_or_session, HttpRequest | Request):
|
||||
session = request_or_session.session
|
||||
if isinstance(request_or_session, AuthenticatedSession):
|
||||
SessionStore = _session_engine.SessionStore
|
||||
session = SessionStore(request_or_session.session_key)
|
||||
return session.get(SESSION_LOGIN_EVENT, None)
|
||||
|
||||
|
||||
@receiver(user_logged_out)
|
||||
|
||||
@ -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 + [
|
||||
@ -78,7 +107,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
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"""id_token utils"""
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from hashlib import sha256
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from django.db import models
|
||||
@ -23,8 +24,13 @@ if TYPE_CHECKING:
|
||||
from authentik.providers.oauth2.models import BaseGrantModel, OAuth2Provider
|
||||
|
||||
|
||||
def hash_session_key(session_key: str) -> str:
|
||||
"""Hash the session key for inclusion in JWTs as `sid`"""
|
||||
return sha256(session_key.encode("ascii")).hexdigest()
|
||||
|
||||
|
||||
class SubModes(models.TextChoices):
|
||||
"""Mode after which 'sub' attribute is generateed, for compatibility reasons"""
|
||||
"""Mode after which 'sub' attribute is generated, for compatibility reasons"""
|
||||
|
||||
HASHED_USER_ID = "hashed_user_id", _("Based on the Hashed User ID")
|
||||
USER_ID = "user_id", _("Based on user ID")
|
||||
@ -51,7 +57,8 @@ class IDToken:
|
||||
and potentially other requested Claims. The ID Token is represented as a
|
||||
JSON Web Token (JWT) [JWT].
|
||||
|
||||
https://openid.net/specs/openid-connect-core-1_0.html#IDToken"""
|
||||
https://openid.net/specs/openid-connect-core-1_0.html#IDToken
|
||||
https://www.iana.org/assignments/jwt/jwt.xhtml"""
|
||||
|
||||
# Issuer, https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.1
|
||||
iss: str | None = None
|
||||
@ -79,6 +86,8 @@ class IDToken:
|
||||
nonce: str | None = None
|
||||
# Access Token hash value, http://openid.net/specs/openid-connect-core-1_0.html
|
||||
at_hash: str | None = None
|
||||
# Session ID, https://openid.net/specs/openid-connect-frontchannel-1_0.html#ClaimsContents
|
||||
sid: str | None = None
|
||||
|
||||
claims: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@ -116,9 +125,11 @@ class IDToken:
|
||||
now = timezone.now()
|
||||
id_token.iat = int(now.timestamp())
|
||||
id_token.auth_time = int(token.auth_time.timestamp())
|
||||
if token.session:
|
||||
id_token.sid = hash_session_key(token.session.session_key)
|
||||
|
||||
# We use the timestamp of the user's last successful login (EventAction.LOGIN) for auth_time
|
||||
auth_event = get_login_event(request)
|
||||
auth_event = get_login_event(token.session)
|
||||
if auth_event:
|
||||
# Also check which method was used for authentication
|
||||
method = auth_event.context.get(PLAN_CONTEXT_METHOD, "")
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import django.db.models.deletion
|
||||
from django.apps.registry import Apps
|
||||
from django.db import migrations, models
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
import authentik.lib.utils.time
|
||||
|
||||
@ -14,7 +15,7 @@ scope_uid_map = {
|
||||
}
|
||||
|
||||
|
||||
def set_managed_flag(apps: Apps, schema_editor):
|
||||
def set_managed_flag(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
ScopeMapping = apps.get_model("authentik_providers_oauth2", "ScopeMapping")
|
||||
db_alias = schema_editor.connection.alias
|
||||
for mapping in ScopeMapping.objects.using(db_alias).filter(name__startswith="Autogenerated "):
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
# Generated by Django 5.0.9 on 2024-09-26 16:25
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_providers_oauth2", "0018_alter_accesstoken_expires_and_more"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
# Original preserved
|
||||
# See https://github.com/goauthentik/authentik/issues/11874
|
||||
# operations = [
|
||||
# migrations.AddIndex(
|
||||
# model_name="accesstoken",
|
||||
# index=models.Index(fields=["token"], name="authentik_p_token_4bc870_idx"),
|
||||
# ),
|
||||
# migrations.AddIndex(
|
||||
# model_name="refreshtoken",
|
||||
# index=models.Index(fields=["token"], name="authentik_p_token_1a841f_idx"),
|
||||
# ),
|
||||
# ]
|
||||
operations = []
|
||||
@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.0.9 on 2024-09-27 14:50
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_providers_oauth2", "0019_accesstoken_authentik_p_token_4bc870_idx_and_more"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
# Original preserved
|
||||
# See https://github.com/goauthentik/authentik/issues/11874
|
||||
# operations = [
|
||||
# migrations.RemoveIndex(
|
||||
# model_name="accesstoken",
|
||||
# name="authentik_p_token_4bc870_idx",
|
||||
# ),
|
||||
# migrations.RemoveIndex(
|
||||
# model_name="refreshtoken",
|
||||
# name="authentik_p_token_1a841f_idx",
|
||||
# ),
|
||||
# migrations.AddIndex(
|
||||
# model_name="accesstoken",
|
||||
# index=models.Index(fields=["token", "provider"], name="authentik_p_token_f99422_idx"),
|
||||
# ),
|
||||
# migrations.AddIndex(
|
||||
# model_name="refreshtoken",
|
||||
# index=models.Index(fields=["token", "provider"], name="authentik_p_token_a1d921_idx"),
|
||||
# ),
|
||||
# ]
|
||||
operations = []
|
||||
@ -0,0 +1,42 @@
|
||||
# Generated by Django 5.0.9 on 2024-10-16 14:53
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_crypto", "0004_alter_certificatekeypair_name"),
|
||||
(
|
||||
"authentik_providers_oauth2",
|
||||
"0020_remove_accesstoken_authentik_p_token_4bc870_idx_and_more",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="oauth2provider",
|
||||
name="encryption_key",
|
||||
field=models.ForeignKey(
|
||||
help_text="Key used to encrypt the tokens. When set, tokens will be encrypted and returned as JWEs.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="oauth2provider_encryption_key_set",
|
||||
to="authentik_crypto.certificatekeypair",
|
||||
verbose_name="Encryption Key",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="oauth2provider",
|
||||
name="signing_key",
|
||||
field=models.ForeignKey(
|
||||
help_text="Key used to sign the tokens.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="oauth2provider_signing_key_set",
|
||||
to="authentik_crypto.certificatekeypair",
|
||||
verbose_name="Signing Key",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,113 @@
|
||||
# Generated by Django 5.0.9 on 2024-10-23 13:38
|
||||
|
||||
from hashlib import sha256
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
from django.apps.registry import Apps
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
from authentik.lib.migrations import progress_bar
|
||||
|
||||
|
||||
def migrate_session(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
AuthenticatedSession = apps.get_model("authentik_core", "authenticatedsession")
|
||||
AuthorizationCode = apps.get_model("authentik_providers_oauth2", "authorizationcode")
|
||||
AccessToken = apps.get_model("authentik_providers_oauth2", "accesstoken")
|
||||
RefreshToken = apps.get_model("authentik_providers_oauth2", "refreshtoken")
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
print(f"\nFetching session keys, this might take a couple of minutes...")
|
||||
session_ids = {}
|
||||
for session in progress_bar(AuthenticatedSession.objects.using(db_alias).all()):
|
||||
session_ids[sha256(session.session_key.encode("ascii")).hexdigest()] = session.session_key
|
||||
for model in [AuthorizationCode, AccessToken, RefreshToken]:
|
||||
print(
|
||||
f"\nAdding session to {model._meta.verbose_name}, this might take a couple of minutes..."
|
||||
)
|
||||
for code in progress_bar(model.objects.using(db_alias).all()):
|
||||
if code.session_id_old not in session_ids:
|
||||
continue
|
||||
code.session = (
|
||||
AuthenticatedSession.objects.using(db_alias)
|
||||
.filter(session_key=session_ids[code.session_id_old])
|
||||
.first()
|
||||
)
|
||||
code.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0039_source_group_matching_mode_alter_group_name_and_more"),
|
||||
("authentik_providers_oauth2", "0021_oauth2provider_encryption_key_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="accesstoken",
|
||||
old_name="session_id",
|
||||
new_name="session_id_old",
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="authorizationcode",
|
||||
old_name="session_id",
|
||||
new_name="session_id_old",
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="refreshtoken",
|
||||
old_name="session_id",
|
||||
new_name="session_id_old",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="accesstoken",
|
||||
name="session",
|
||||
field=models.ForeignKey(
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||
to="authentik_core.authenticatedsession",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="authorizationcode",
|
||||
name="session",
|
||||
field=models.ForeignKey(
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||
to="authentik_core.authenticatedsession",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="devicetoken",
|
||||
name="session",
|
||||
field=models.ForeignKey(
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||
to="authentik_core.authenticatedsession",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="refreshtoken",
|
||||
name="session",
|
||||
field=models.ForeignKey(
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||
to="authentik_core.authenticatedsession",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_session),
|
||||
migrations.RemoveField(
|
||||
model_name="accesstoken",
|
||||
name="session_id_old",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="authorizationcode",
|
||||
name="session_id_old",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="refreshtoken",
|
||||
name="session_id_old",
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.0.9 on 2024-10-31 14:28
|
||||
|
||||
import django.contrib.postgres.indexes
|
||||
from django.conf import settings
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0039_source_group_matching_mode_alter_group_name_and_more"),
|
||||
("authentik_providers_oauth2", "0022_remove_accesstoken_session_id_and_more"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunSQL("DROP INDEX IF EXISTS authentik_p_token_f99422_idx;"),
|
||||
migrations.RunSQL("DROP INDEX IF EXISTS authentik_p_token_a1d921_idx;"),
|
||||
migrations.AddIndex(
|
||||
model_name="accesstoken",
|
||||
index=django.contrib.postgres.indexes.HashIndex(
|
||||
fields=["token"], name="authentik_p_token_e00883_hash"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="refreshtoken",
|
||||
index=django.contrib.postgres.indexes.HashIndex(
|
||||
fields=["token"], name="authentik_p_token_32e2b7_hash"
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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.db import models
|
||||
from django.http import HttpRequest
|
||||
@ -23,7 +24,13 @@ from rest_framework.serializers import Serializer
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.brands.models import WebfingerProvider
|
||||
from authentik.core.models import ExpiringModel, PropertyMapping, Provider, User
|
||||
from authentik.core.models import (
|
||||
AuthenticatedSession,
|
||||
ExpiringModel,
|
||||
PropertyMapping,
|
||||
Provider,
|
||||
User,
|
||||
)
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.lib.generators import generate_code_fixed_length, generate_id, generate_key
|
||||
from authentik.lib.models import SerializerModel
|
||||
@ -67,11 +74,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."""
|
||||
|
||||
@ -146,11 +167,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(
|
||||
@ -251,12 +270,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)
|
||||
@ -320,7 +360,9 @@ class BaseGrantModel(models.Model):
|
||||
revoked = models.BooleanField(default=False)
|
||||
_scope = models.TextField(default="", verbose_name=_("Scopes"))
|
||||
auth_time = models.DateTimeField(verbose_name="Authentication time")
|
||||
session_id = models.CharField(default="", blank=True)
|
||||
session = models.ForeignKey(
|
||||
AuthenticatedSession, null=True, on_delete=models.SET_DEFAULT, default=None
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
@ -452,6 +494,9 @@ class DeviceToken(ExpiringModel):
|
||||
device_code = models.TextField(default=generate_key)
|
||||
user_code = models.TextField(default=generate_code_fixed_length)
|
||||
_scope = models.TextField(default="", verbose_name=_("Scopes"))
|
||||
session = models.ForeignKey(
|
||||
AuthenticatedSession, null=True, on_delete=models.SET_DEFAULT, default=None
|
||||
)
|
||||
|
||||
@property
|
||||
def scope(self) -> list[str]:
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
from hashlib import sha256
|
||||
|
||||
from django.contrib.auth.signals import user_logged_out
|
||||
from django.dispatch import receiver
|
||||
from django.http import HttpRequest
|
||||
@ -13,5 +11,4 @@ def user_logged_out_oauth_access_token(sender, request: HttpRequest, user: User,
|
||||
"""Revoke access tokens upon user logout"""
|
||||
if not request.session or not request.session.session_key:
|
||||
return
|
||||
hashed_session_key = sha256(request.session.session_key.encode("ascii")).hexdigest()
|
||||
AccessToken.objects.filter(user=user, session_id=hashed_session_key).delete()
|
||||
AccessToken.objects.filter(user=user, session__session_key=request.session.session_key).delete()
|
||||
|
||||
@ -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(
|
||||
@ -419,7 +421,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)
|
||||
@ -474,7 +476,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(
|
||||
@ -532,7 +534,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)
|
||||
|
||||
@ -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)
|
||||
@ -104,7 +104,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
|
||||
@ -158,7 +160,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(
|
||||
@ -220,7 +222,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(
|
||||
@ -278,7 +280,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())
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
from dataclasses import InitVar, dataclass, field
|
||||
from datetime import timedelta
|
||||
from hashlib import sha256
|
||||
from json import dumps
|
||||
from re import error as RegexError
|
||||
from re import fullmatch
|
||||
@ -16,7 +15,7 @@ from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.models import Application, AuthenticatedSession
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.events.signals import get_login_event
|
||||
from authentik.flows.challenge import (
|
||||
@ -57,6 +56,8 @@ from authentik.providers.oauth2.models import (
|
||||
AuthorizationCode,
|
||||
GrantTypes,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
ResponseMode,
|
||||
ResponseTypes,
|
||||
ScopeMapping,
|
||||
@ -188,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)
|
||||
@ -318,7 +318,9 @@ class OAuthAuthorizationParams:
|
||||
expires=now + timedelta_from_string(self.provider.access_code_validity),
|
||||
scope=self.scope,
|
||||
nonce=self.nonce,
|
||||
session_id=sha256(request.session.session_key.encode("ascii")).hexdigest(),
|
||||
session=AuthenticatedSession.objects.filter(
|
||||
session_key=request.session.session_key
|
||||
).first(),
|
||||
)
|
||||
|
||||
if self.code_challenge and self.code_challenge_method:
|
||||
@ -610,7 +612,9 @@ class OAuthFulfillmentStage(StageView):
|
||||
expires=access_token_expiry,
|
||||
provider=self.provider,
|
||||
auth_time=auth_event.created if auth_event else now,
|
||||
session_id=sha256(self.request.session.session_key.encode("ascii")).hexdigest(),
|
||||
session=AuthenticatedSession.objects.filter(
|
||||
session_key=self.request.session.session_key
|
||||
).first(),
|
||||
)
|
||||
|
||||
id_token = IDToken.new(self.provider, token, self.request)
|
||||
|
||||
@ -158,5 +158,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
|
||||
|
||||
@ -550,7 +574,7 @@ class TokenView(View):
|
||||
# Keep same scopes as previous token
|
||||
scope=self.params.authorization_code.scope,
|
||||
auth_time=self.params.authorization_code.auth_time,
|
||||
session_id=self.params.authorization_code.session_id,
|
||||
session=self.params.authorization_code.session,
|
||||
)
|
||||
access_id_token = IDToken.new(
|
||||
self.provider,
|
||||
@ -578,7 +602,7 @@ class TokenView(View):
|
||||
expires=refresh_token_expiry,
|
||||
provider=self.provider,
|
||||
auth_time=self.params.authorization_code.auth_time,
|
||||
session_id=self.params.authorization_code.session_id,
|
||||
session=self.params.authorization_code.session,
|
||||
)
|
||||
id_token = IDToken.new(
|
||||
self.provider,
|
||||
@ -611,7 +635,7 @@ class TokenView(View):
|
||||
# Keep same scopes as previous token
|
||||
scope=self.params.refresh_token.scope,
|
||||
auth_time=self.params.refresh_token.auth_time,
|
||||
session_id=self.params.refresh_token.session_id,
|
||||
session=self.params.refresh_token.session,
|
||||
)
|
||||
access_token.id_token = IDToken.new(
|
||||
self.provider,
|
||||
@ -627,7 +651,7 @@ class TokenView(View):
|
||||
expires=refresh_token_expiry,
|
||||
provider=self.provider,
|
||||
auth_time=self.params.refresh_token.auth_time,
|
||||
session_id=self.params.refresh_token.session_id,
|
||||
session=self.params.refresh_token.session,
|
||||
)
|
||||
id_token = IDToken.new(
|
||||
self.provider,
|
||||
@ -685,13 +709,14 @@ class TokenView(View):
|
||||
raise DeviceCodeError("authorization_pending")
|
||||
now = timezone.now()
|
||||
access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity)
|
||||
auth_event = get_login_event(self.request)
|
||||
auth_event = get_login_event(self.params.device_code.session)
|
||||
access_token = AccessToken(
|
||||
provider=self.provider,
|
||||
user=self.params.device_code.user,
|
||||
expires=access_token_expiry,
|
||||
scope=self.params.device_code.scope,
|
||||
auth_time=auth_event.created if auth_event else now,
|
||||
session=self.params.device_code.session,
|
||||
)
|
||||
access_token.id_token = IDToken.new(
|
||||
self.provider,
|
||||
@ -709,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):
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
"""proxy provider tasks"""
|
||||
|
||||
from hashlib import sha256
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from django.db import DatabaseError, InternalError, ProgrammingError
|
||||
|
||||
from authentik.outposts.consumer import OUTPOST_GROUP
|
||||
from authentik.outposts.models import Outpost, OutpostType
|
||||
from authentik.providers.oauth2.id_token import hash_session_key
|
||||
from authentik.providers.proxy.models import ProxyProvider
|
||||
from authentik.root.celery import CELERY_APP
|
||||
|
||||
@ -26,7 +25,7 @@ def proxy_set_defaults():
|
||||
def proxy_on_logout(session_id: str):
|
||||
"""Update outpost instances connected to a single outpost"""
|
||||
layer = get_channel_layer()
|
||||
hashed_session_id = sha256(session_id.encode("ascii")).hexdigest()
|
||||
hashed_session_id = hash_session_key(session_id)
|
||||
for outpost in Outpost.objects.filter(type=OutpostType.PROXY):
|
||||
group = OUTPOST_GROUP % {"outpost_pk": str(outpost.pk)}
|
||||
async_to_sync(layer.group_send)(
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "https://goauthentik.io/blueprints/schema.json",
|
||||
"type": "object",
|
||||
"title": "authentik 2024.8.4 Blueprint schema",
|
||||
"title": "authentik 2024.8.5 Blueprint schema",
|
||||
"required": [
|
||||
"version",
|
||||
"entries"
|
||||
@ -5345,9 +5345,30 @@
|
||||
"description": "Key used to sign the tokens. Only required when JWT Algorithm is set to RS256."
|
||||
},
|
||||
"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",
|
||||
|
||||
@ -14,11 +14,7 @@ entries:
|
||||
expression: |
|
||||
# This mapping is used by the authentik proxy. It passes extra user attributes,
|
||||
# which are used for example for the HTTP-Basic Authentication mapping.
|
||||
session_id = None
|
||||
if "token" in request.context:
|
||||
session_id = request.context.get("token").session_id
|
||||
return {
|
||||
"sid": session_id,
|
||||
"ak_proxy": {
|
||||
"user_attributes": request.user.group_attributes(request),
|
||||
"is_superuser": request.user.is_superuser,
|
||||
|
||||
@ -31,7 +31,7 @@ services:
|
||||
volumes:
|
||||
- redis:/data
|
||||
server:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.8.4}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.8.5}
|
||||
restart: unless-stopped
|
||||
command: server
|
||||
environment:
|
||||
@ -52,7 +52,7 @@ services:
|
||||
- postgresql
|
||||
- redis
|
||||
worker:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.8.4}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.8.5}
|
||||
restart: unless-stopped
|
||||
command: worker
|
||||
environment:
|
||||
|
||||
@ -29,4 +29,4 @@ func UserAgent() string {
|
||||
return fmt.Sprintf("authentik@%s", FullVersion())
|
||||
}
|
||||
|
||||
const VERSION = "2024.8.4"
|
||||
const VERSION = "2024.8.5"
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -52,7 +52,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
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@goauthentik/authentik",
|
||||
"version": "2024.8.4",
|
||||
"version": "2024.8.5",
|
||||
"private": true
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "authentik"
|
||||
version = "2024.8.4"
|
||||
version = "2024.8.5"
|
||||
description = ""
|
||||
authors = ["authentik Team <hello@goauthentik.io>"]
|
||||
|
||||
|
||||
55
schema.yml
55
schema.yml
@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: authentik
|
||||
version: 2024.8.4
|
||||
version: 2024.8.5
|
||||
description: Making authentication simple.
|
||||
contact:
|
||||
email: hello@goauthentik.io
|
||||
@ -19493,10 +19493,6 @@ paths:
|
||||
format: uuid
|
||||
explode: true
|
||||
style: form
|
||||
- in: query
|
||||
name: redirect_uris
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: refresh_token_validity
|
||||
schema:
|
||||
@ -19912,10 +19908,6 @@ paths:
|
||||
format: uuid
|
||||
explode: true
|
||||
style: form
|
||||
- in: query
|
||||
name: redirect_uris__iexact
|
||||
schema:
|
||||
type: string
|
||||
- name: search
|
||||
required: false
|
||||
in: query
|
||||
@ -41665,6 +41657,11 @@ components:
|
||||
required:
|
||||
- challenge
|
||||
- name
|
||||
MatchingModeEnum:
|
||||
enum:
|
||||
- strict
|
||||
- regex
|
||||
type: string
|
||||
Metadata:
|
||||
type: object
|
||||
description: Serializer for blueprint metadata
|
||||
@ -42353,8 +42350,9 @@ components:
|
||||
description: Key used to sign the tokens. Only required when JWT Algorithm
|
||||
is set to RS256.
|
||||
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'
|
||||
@ -42382,6 +42380,7 @@ components:
|
||||
- meta_model_name
|
||||
- name
|
||||
- pk
|
||||
- redirect_uris
|
||||
- verbose_name
|
||||
- verbose_name_plural
|
||||
OAuth2ProviderRequest:
|
||||
@ -42444,8 +42443,9 @@ components:
|
||||
description: Key used to sign the tokens. Only required when JWT Algorithm
|
||||
is set to RS256.
|
||||
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'
|
||||
@ -42466,6 +42466,7 @@ components:
|
||||
required:
|
||||
- authorization_flow
|
||||
- name
|
||||
- redirect_uris
|
||||
OAuth2ProviderSetupURLs:
|
||||
type: object
|
||||
description: OAuth2 Provider Metadata serializer
|
||||
@ -46220,8 +46221,9 @@ components:
|
||||
description: Key used to sign the tokens. Only required when JWT Algorithm
|
||||
is set to RS256.
|
||||
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'
|
||||
@ -49357,6 +49359,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
|
||||
|
||||
@ -13,7 +13,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
|
||||
|
||||
|
||||
@ -79,7 +84,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(
|
||||
@ -134,7 +141,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(
|
||||
@ -205,7 +214,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(
|
||||
|
||||
@ -20,7 +20,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
|
||||
|
||||
|
||||
@ -87,7 +93,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(
|
||||
@ -136,7 +142,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(
|
||||
@ -198,7 +208,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(
|
||||
@ -270,7 +284,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(
|
||||
@ -350,7 +368,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(
|
||||
|
||||
@ -21,7 +21,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
|
||||
|
||||
|
||||
@ -73,7 +79,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(
|
||||
@ -122,7 +128,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(
|
||||
@ -163,6 +171,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
|
||||
body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text)
|
||||
|
||||
self.assertEqual(body["IDTokenClaims"]["nickname"], self.user.username)
|
||||
self.assertEqual(body["IDTokenClaims"]["amr"], ["pwd"])
|
||||
self.assertEqual(body["UserInfo"]["nickname"], self.user.username)
|
||||
|
||||
self.assertEqual(body["IDTokenClaims"]["name"], self.user.name)
|
||||
@ -193,7 +202,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(
|
||||
@ -264,7 +275,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(
|
||||
|
||||
@ -21,7 +21,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
|
||||
|
||||
|
||||
@ -74,7 +80,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(
|
||||
@ -123,7 +129,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(
|
||||
@ -176,7 +184,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(
|
||||
@ -244,7 +254,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(
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
from base64 import b64encode
|
||||
from dataclasses import asdict
|
||||
from json import loads
|
||||
from sys import platform
|
||||
from time import sleep
|
||||
from typing import Any
|
||||
@ -10,6 +11,7 @@ from unittest.case import skip, skipUnless
|
||||
from channels.testing import ChannelsLiveServerTestCase
|
||||
from docker.client import DockerClient, from_env
|
||||
from docker.models.containers import Container
|
||||
from jwt import decode
|
||||
from selenium.webdriver.common.by import By
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint, reconcile_app
|
||||
@ -115,8 +117,15 @@ class TestProviderProxy(SeleniumTestCase):
|
||||
sleep(1)
|
||||
|
||||
full_body_text = self.driver.find_element(By.CSS_SELECTOR, "pre").text
|
||||
self.assertIn(f"X-Authentik-Username: {self.user.username}", full_body_text)
|
||||
self.assertIn("X-Foo: bar", full_body_text)
|
||||
body = loads(full_body_text)
|
||||
|
||||
self.assertEqual(body["headers"]["X-Authentik-Username"], [self.user.username])
|
||||
self.assertEqual(body["headers"]["X-Foo"], ["bar"])
|
||||
raw_jwt: str = body["headers"]["X-Authentik-Jwt"][0]
|
||||
jwt = decode(raw_jwt, options={"verify_signature": False})
|
||||
|
||||
self.assertIsNotNone(jwt["sid"])
|
||||
self.assertIsNotNone(jwt["ak_proxy"])
|
||||
|
||||
self.driver.get("http://localhost:9000/outpost.goauthentik.io/sign_out")
|
||||
sleep(2)
|
||||
|
||||
17593
web/package-lock.json
generated
17593
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
145
web/package.json
145
web/package.json
@ -8,18 +8,20 @@
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/legacy-modes": "^6.4.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"@floating-ui/dom": "^1.6.9",
|
||||
"@floating-ui/dom": "^1.6.11",
|
||||
"@formatjs/intl-listformat": "^7.5.7",
|
||||
"@fortawesome/fontawesome-free": "^6.6.0",
|
||||
"@goauthentik/api": "^2024.6.3-1724414734",
|
||||
"@goauthentik/api": "^2024.10.2-1732196831",
|
||||
"@lit-labs/ssr": "^3.2.2",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@lit/localize": "^0.12.2",
|
||||
"@lit/reactive-element": "^2.0.4",
|
||||
"@lit/task": "^1.0.1",
|
||||
"@open-wc/lit-helpers": "^0.7.0",
|
||||
"@patternfly/elements": "^4.0.0",
|
||||
"@patternfly/elements": "^4.0.2",
|
||||
"@patternfly/patternfly": "^4.224.2",
|
||||
"@sentry/browser": "^8.26.0",
|
||||
"@sentry/browser": "^8.32.0",
|
||||
"@spotlightjs/spotlight": "^2.4.2",
|
||||
"@webcomponents/webcomponentsjs": "^2.8.0",
|
||||
"base64-js": "^1.5.1",
|
||||
"chart.js": "^4.4.4",
|
||||
@ -28,100 +30,84 @@
|
||||
"construct-style-sheets-polyfill": "^3.1.0",
|
||||
"core-js": "^3.38.1",
|
||||
"country-flag-icons": "^1.5.13",
|
||||
"dompurify": "^3.1.7",
|
||||
"fuse.js": "^7.0.0",
|
||||
"guacamole-common-js": "^1.5.0",
|
||||
"lit": "^3.2.0",
|
||||
"md-front-matter": "^1.0.4",
|
||||
"mermaid": "^11.0.2",
|
||||
"rapidoc": "^9.3.4",
|
||||
"mermaid": "^11.2.1",
|
||||
"rapidoc": "^9.3.7",
|
||||
"showdown": "^2.1.0",
|
||||
"style-mod": "^4.1.2",
|
||||
"ts-pattern": "^5.3.1",
|
||||
"ts-pattern": "^5.4.0",
|
||||
"webcomponent-qr-code": "^1.2.0",
|
||||
"yaml": "^2.5.0"
|
||||
"yaml": "^2.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/plugin-proposal-class-properties": "^7.18.6",
|
||||
"@babel/plugin-proposal-decorators": "^7.24.7",
|
||||
"@babel/plugin-transform-private-methods": "^7.25.4",
|
||||
"@babel/plugin-transform-private-property-in-object": "^7.24.7",
|
||||
"@babel/plugin-transform-runtime": "^7.25.4",
|
||||
"@babel/preset-env": "^7.25.4",
|
||||
"@babel/preset-typescript": "^7.24.7",
|
||||
"@changesets/cli": "^2.27.5",
|
||||
"@custom-elements-manifest/analyzer": "^0.10.2",
|
||||
"@eslint/js": "^9.9.1",
|
||||
"@genesiscommunitysuccess/custom-elements-lsp": "^5.0.3",
|
||||
"@eslint/js": "^9.11.1",
|
||||
"@hcaptcha/types": "^1.0.4",
|
||||
"@jeysal/storybook-addon-css-user-preferences": "^0.2.0",
|
||||
"@lit/localize-tools": "^0.8.0",
|
||||
"@rollup/plugin-replace": "^5.0.7",
|
||||
"@spotlightjs/spotlight": "^2.3.0",
|
||||
"@storybook/addon-essentials": "^8.2.9",
|
||||
"@storybook/addon-links": "^8.2.9",
|
||||
"@rollup/plugin-replace": "^6.0.1",
|
||||
"@storybook/addon-essentials": "^8.3.4",
|
||||
"@storybook/addon-links": "^8.3.4",
|
||||
"@storybook/api": "^7.6.17",
|
||||
"@storybook/blocks": "^8.0.8",
|
||||
"@storybook/manager-api": "^8.2.9",
|
||||
"@storybook/web-components": "^8.2.9",
|
||||
"@storybook/web-components-vite": "^8.2.9",
|
||||
"@storybook/blocks": "^8.3.4",
|
||||
"@storybook/builder-vite": "^8.3.4",
|
||||
"@storybook/manager-api": "^8.3.4",
|
||||
"@storybook/web-components": "^8.3.4",
|
||||
"@storybook/web-components-vite": "^8.3.4",
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
||||
"@types/chart.js": "^2.9.41",
|
||||
"@types/codemirror": "5.60.15",
|
||||
"@types/codemirror": "^5.60.15",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
"@types/grecaptcha": "^3.0.9",
|
||||
"@types/guacamole-common-js": "1.5.2",
|
||||
"@types/guacamole-common-js": "^1.5.2",
|
||||
"@types/mocha": "^10.0.8",
|
||||
"@types/node": "^22.7.4",
|
||||
"@types/showdown": "^2.0.6",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.1",
|
||||
"@typescript-eslint/parser": "^8.0.1",
|
||||
"@wdio/browser-runner": "^8.40.2",
|
||||
"@wdio/cli": "^8.40.2",
|
||||
"@wdio/mocha-framework": "^8.40.2",
|
||||
"@wdio/spec-reporter": "^8.36.1",
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
"babel-plugin-tsconfig-paths": "^1.0.3",
|
||||
"chokidar": "^3.6.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"esbuild": "^0.23.1",
|
||||
"eslint": "^9.8.0",
|
||||
"eslint-plugin-lit": "^1.14.0",
|
||||
"eslint-plugin-sonarjs": "^1.0.4",
|
||||
"eslint-plugin-wc": "^2.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.8.0",
|
||||
"@typescript-eslint/parser": "^8.8.0",
|
||||
"@wdio/browser-runner": "^9.1.2",
|
||||
"@wdio/cli": "^9.1.2",
|
||||
"@wdio/spec-reporter": "^9.1.2",
|
||||
"chokidar": "^4.0.1",
|
||||
"chromedriver": "^130.0.4",
|
||||
"esbuild": "^0.24.0",
|
||||
"eslint": "^9.11.1",
|
||||
"eslint-plugin-lit": "^1.15.0",
|
||||
"eslint-plugin-wc": "^2.1.1",
|
||||
"github-slugger": "^2.0.0",
|
||||
"glob": "^11.0.0",
|
||||
"globals": "^15.9.0",
|
||||
"globals": "^15.10.0",
|
||||
"knip": "^5.30.6",
|
||||
"lit-analyzer": "^2.0.3",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^3.3.3",
|
||||
"pseudolocale": "^2.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"rollup-plugin-modify": "^3.0.0",
|
||||
"rollup-plugin-postcss-lit": "^2.1.0",
|
||||
"storybook": "^8.1.11",
|
||||
"storybook": "^8.3.4",
|
||||
"storybook-addon-mock": "^5.0.0",
|
||||
"syncpack": "^13.0.0",
|
||||
"ts-lit-plugin": "^2.0.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tslib": "^2.7.0",
|
||||
"turnstile-types": "^1.2.2",
|
||||
"typescript": "^5.5.4",
|
||||
"typescript-eslint": "^8.2.0",
|
||||
"turnstile-types": "^1.2.3",
|
||||
"typescript": "^5.6.2",
|
||||
"typescript-eslint": "^8.8.0",
|
||||
"vite-plugin-lit-css": "^2.0.0",
|
||||
"vite-tsconfig-paths": "^5.0.1",
|
||||
"wdio-wait-for": "^3.0.11",
|
||||
"wireit": "^0.14.8"
|
||||
"wireit": "^0.14.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"@esbuild/darwin-arm64": "^0.23.0",
|
||||
"@esbuild/darwin-arm64": "^0.24.0",
|
||||
"@esbuild/linux-amd64": "^0.18.11",
|
||||
"@esbuild/linux-arm64": "^0.23.0",
|
||||
"@rollup/rollup-darwin-arm64": "4.21.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.21.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.21.0"
|
||||
"@esbuild/linux-arm64": "^0.24.0",
|
||||
"@rollup/rollup-darwin-arm64": "4.23.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.23.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.23.0"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@ -134,6 +120,7 @@
|
||||
"extract-locales": "wireit",
|
||||
"format": "wireit",
|
||||
"lint": "wireit",
|
||||
"lint:imports": "wireit",
|
||||
"lint:lockfile": "wireit",
|
||||
"lint:nightmare": "wireit",
|
||||
"lint:package": "wireit",
|
||||
@ -148,8 +135,8 @@
|
||||
"storybook:build": "wireit",
|
||||
"storybook:build-import-map": "wireit",
|
||||
"test": "wireit",
|
||||
"test-view": "wireit",
|
||||
"test-watch": "npx wdio run ./wdio.conf.ts --autoCompileOpts.tsNodeOpts.project=tsconfig.test.json --watch",
|
||||
"test:e2e:watch": "wireit",
|
||||
"test:watch": "wireit",
|
||||
"tsc": "wireit",
|
||||
"watch": "run-s build-locales esbuild:watch"
|
||||
},
|
||||
@ -249,16 +236,23 @@
|
||||
"lint:components": {
|
||||
"command": "lit-analyzer src"
|
||||
},
|
||||
"lint:imports": {
|
||||
"command": "knip --config scripts/knip.config.ts"
|
||||
},
|
||||
"lint:types:tests": {
|
||||
"command": "tsc --noEmit -p ./tests"
|
||||
},
|
||||
"lint:types": {
|
||||
"command": "tsc --noEmit -p .",
|
||||
"dependencies": [
|
||||
"build-locales"
|
||||
"build-locales",
|
||||
"lint:types:tests"
|
||||
]
|
||||
},
|
||||
"lint:lockfile": {
|
||||
"__comment": "The lockfile-lint package does not have an option to ensure resolved hashes are set everywhere",
|
||||
"shell": true,
|
||||
"command": "[ -z \"$(jq -r '.packages | to_entries[] | select((.key | contains(\"node_modules\")) and (.value | has(\"resolved\") | not)) | .key' < package-lock.json)\" ]"
|
||||
"command": "sh ./scripts/lint-lockfile.sh package-lock.json"
|
||||
},
|
||||
"lint:lockfiles": {
|
||||
"dependencies": [
|
||||
@ -298,6 +292,7 @@
|
||||
"lint:types",
|
||||
"lint:components",
|
||||
"lint:spelling",
|
||||
"lint:package",
|
||||
"lint:lockfile",
|
||||
"lint:lockfiles",
|
||||
"lint:precommit",
|
||||
@ -325,14 +320,26 @@
|
||||
"command": "node scripts/build-storybook-import-maps.mjs"
|
||||
},
|
||||
"test": {
|
||||
"command": "wdio run ./wdio.conf.ts --logLevel=warn",
|
||||
"command": "wdio ./wdio.conf.ts --logLevel=warn",
|
||||
"env": {
|
||||
"CI": "true",
|
||||
"TS_NODE_PROJECT": "tsconfig.test.json"
|
||||
}
|
||||
},
|
||||
"test-view": {
|
||||
"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;
|
||||
}
|
||||
|
||||
@ -216,13 +235,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;
|
||||
}
|
||||
}
|
||||
@ -236,8 +236,8 @@ export class IdentificationStageForm extends BaseStageForm<IdentificationStage>
|
||||
<ak-dual-select-dynamic-selected
|
||||
.provider=${sourcesProvider}
|
||||
.selector=${makeSourcesSelector(this.instance?.sources)}
|
||||
available-label="${msg("Available Stages")}"
|
||||
selected-label="${msg("Selected Stages")}"
|
||||
available-label="${msg("Available Sources")}"
|
||||
selected-label="${msg("Selected Sources")}"
|
||||
></ak-dual-select-dynamic-selected>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg(
|
||||
|
||||
@ -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.8.4";
|
||||
export const VERSION = "2024.8.5";
|
||||
export const TITLE_DEFAULT = "authentik";
|
||||
export const ROUTE_SEPARATOR = ";";
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
55
web/src/elements/ak-array-input.test.ts
Normal file
55
web/src/elements/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);
|
||||
});
|
||||
});
|
||||
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[]][];
|
||||
}
|
||||
@ -76,7 +76,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] ?? ""),
|
||||
};
|
||||
|
||||
@ -34,7 +34,7 @@ export class UserOAuthAccessTokenList extends Table<TokenModel> {
|
||||
}
|
||||
|
||||
checkbox = true;
|
||||
order = "expires";
|
||||
order = "-expires";
|
||||
|
||||
columns(): TableColumn[] {
|
||||
return [
|
||||
|
||||
@ -35,7 +35,7 @@ export class UserOAuthRefreshTokenList extends Table<TokenModel> {
|
||||
|
||||
checkbox = true;
|
||||
clearOnRefresh = true;
|
||||
order = "expires";
|
||||
order = "-expires";
|
||||
|
||||
columns(): TableColumn[] {
|
||||
return [
|
||||
|
||||
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>`,
|
||||
};
|
||||
16
web/tests/blueprints/test-admin-user.yaml
Normal file
16
web/tests/blueprints/test-admin-user.yaml
Normal file
@ -0,0 +1,16 @@
|
||||
version: 1
|
||||
entries:
|
||||
- attrs:
|
||||
email: test-admin@goauthentik.io
|
||||
is_active: true
|
||||
name: authentik Default Admin
|
||||
password: test-runner
|
||||
path: users
|
||||
type: internal
|
||||
groups:
|
||||
- !Find [authentik_core.group, [name, "authentik Admins"]]
|
||||
conditions: []
|
||||
identifiers:
|
||||
username: akadmin
|
||||
model: authentik_core.user
|
||||
state: present
|
||||
@ -78,7 +78,7 @@ Short summary of the issue
|
||||
|
||||
### Patches
|
||||
|
||||
authentik x, y and z fix this issue, for other versions the workaround can be used.
|
||||
authentik x, y and z fix this issue, for other versions the workaround below can be used.
|
||||
|
||||
### Impact
|
||||
|
||||
@ -96,7 +96,7 @@ Describe a workaround if possible
|
||||
|
||||
If you have any questions or comments about this advisory:
|
||||
|
||||
- Email us at [security@goauthentik.io](mailto:security@goauthentik.io)
|
||||
- Email us at [security@goauthentik.io](mailto:security@goauthentik.io).
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
27
website/docs/security/CVE-2024-52287.md
Normal file
27
website/docs/security/CVE-2024-52287.md
Normal file
@ -0,0 +1,27 @@
|
||||
# CVE-2024-52287
|
||||
|
||||
_Reported by [@matt1097](https://github.com/matt1097)_
|
||||
|
||||
## Insufficient validation of OAuth scopes for client_credentials and device_code grants
|
||||
|
||||
### Summary
|
||||
|
||||
When using the `client_credentials` or `device_code` OAuth grants, it was possible for an attacker to get a token from authentik with scopes that haven't been configured in authentik.
|
||||
|
||||
### Details
|
||||
|
||||
With the `device_code` grant, it was possible to have a user authorize a set of permitted scopes, and then acquire a token with a different set of scopes, including scopes not configured. This token could potentially be used to send requests to another system which trusts tokens signed by authentik and execute malicious actions on behalf of the user.
|
||||
|
||||
With the `client_credentials` grant, because there is no user authorization process, authentik would not validate the scopes requested for the token, allowing tokens to be issued with scopes not configured in authentik. These could similarly be used to execute malicious actions in other systems.
|
||||
|
||||
There is no workaround for this issue; however this issue could only be exploited if an attacker possesses a valid set of OAuth2 `client_id` and `client_secret` credentials, and has the knowledge of another system that trusts tokens issued by authentik and what scopes it checks for.
|
||||
|
||||
### Patches
|
||||
|
||||
authentik 2024.8.5 and 2024.10.3 fix this issue.
|
||||
|
||||
### For more information
|
||||
|
||||
If you have any questions or comments about this advisory:
|
||||
|
||||
- Email us at [security@goauthentik.io](mailto:security@goauthentik.io)
|
||||
30
website/docs/security/CVE-2024-52289.md
Normal file
30
website/docs/security/CVE-2024-52289.md
Normal file
@ -0,0 +1,30 @@
|
||||
# CVE-2024-52289
|
||||
|
||||
_Reported by [@PontusHanssen](https://github.com/PontusHanssen)_
|
||||
|
||||
## Insecure default configuration for OAuth2 Redirect URIs
|
||||
|
||||
### Summary
|
||||
|
||||
Redirect URIs in the OAuth2 provider in authentik are checked by RegEx comparison.
|
||||
When no Redirect URIs are configured in a provider, authentik will automatically use the first `redirect_uri` value received as an allowed redirect URI, without escaping characters that have a special meaning in RegEx. Similarly, the documentation did not take this into consideration either.
|
||||
|
||||
Given a provider with the Redirect URIs set to `https://foo.example.com`, an attacker can register a domain `fooaexample.com`, and it will correctly pass validation.
|
||||
|
||||
### Patches
|
||||
|
||||
authentik 2024.8.5 and 2024.10.3 fix this issue.
|
||||
|
||||
The patched versions remedy this issue by changing the format that the Redirect URIs are saved in, allowing for the explicit configuration if the URL should be checked strictly or as a RegEx. This means that these patches include a backwards-incompatible database change and API change.
|
||||
|
||||
Manual action _is required_ if any provider is intended to use RegEx for Redirect URIs because the migration will set the comparison type to strict for every Redirect URI.
|
||||
|
||||
### Workarounds
|
||||
|
||||
When configuring OAuth2 providers, make sure to escape any wildcard characters that are not intended to function as a wildcard, for example replace `.` with `\.`.
|
||||
|
||||
### For more information
|
||||
|
||||
If you have any questions or comments about this advisory:
|
||||
|
||||
- Email us at [security@goauthentik.io](mailto:security@goauthentik.io)
|
||||
36
website/docs/security/CVE-2024-52307.md
Normal file
36
website/docs/security/CVE-2024-52307.md
Normal file
@ -0,0 +1,36 @@
|
||||
# CVE-2024-52307
|
||||
|
||||
_Reported by [@mgerstner](https://github.com/mgerstner)_
|
||||
|
||||
## Timing attack due to a lack of constant time comparison for metrics view
|
||||
|
||||
### Summary
|
||||
|
||||
Due to the usage of a non-constant time comparison for the `/-/metrics/` endpoint it was possible to brute-force the `SECRET_KEY`, which is used to authenticate the endpoint. The `/-/metrics/` endpoint returns Prometheus metrics and is not intended to be accessed directly, as the Go proxy running in the authentik server container fetches data from this endpoint and serves it on a separate port (9300 by default), which can be scraped by Prometheus without being exposed publicly.
|
||||
|
||||
### Patches
|
||||
|
||||
authentik 2024.8.5 and 2024.10.3 fix this issue, for other versions the workaround below can be used.
|
||||
|
||||
### Impact
|
||||
|
||||
With enough attempts the `SECRET_KEY` of the authentik installation can be brute-forced, which can be used to sign new or modify existing cookies.
|
||||
|
||||
### Workarounds
|
||||
|
||||
Since the `/-/metrics/` endpoint is not intended to be accessed publicly, requests to the endpoint can be blocked by the reverse proxy/load balancer used in conjunction with authentik.
|
||||
|
||||
For example for nginx:
|
||||
|
||||
```
|
||||
location /-/metrics/ {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
```
|
||||
|
||||
### For more information
|
||||
|
||||
If you have any questions or comments about this advisory:
|
||||
|
||||
- Email us at [security@goauthentik.io](mailto:security@goauthentik.io).
|
||||
@ -521,6 +521,9 @@ const docsSidebar = {
|
||||
items: [
|
||||
"security/security-hardening",
|
||||
"security/policy",
|
||||
"security/CVE-2024-52307",
|
||||
"security/CVE-2024-52289",
|
||||
"security/CVE-2024-52287",
|
||||
"security/CVE-2024-47077",
|
||||
"security/CVE-2024-47070",
|
||||
"security/CVE-2024-42490",
|
||||
|
||||
Reference in New Issue
Block a user