mirror of
https://github.com/goauthentik/authentik.git
synced 2026-08-30 18:51:39 -07:00
alpha4
This commit is contained in:
@@ -38,6 +38,7 @@ 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, RedirectURI, RedirectURIMatchingMode
|
||||
from authentik.secrets.tests.utils import create_test_secret
|
||||
|
||||
|
||||
class TestCrypto(APITestCase):
|
||||
@@ -341,7 +342,7 @@ class TestCrypto(APITestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
secret=create_test_secret(generate_key()),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
|
||||
signing_key=keypair,
|
||||
@@ -373,7 +374,7 @@ class TestCrypto(APITestCase):
|
||||
OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
secret=create_test_secret(generate_key()),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
|
||||
signing_key=keypair,
|
||||
|
||||
@@ -15,13 +15,13 @@ class FleetConnectorSerializer(EnterpriseRequiredMixin, ConnectorSerializer):
|
||||
model = FleetConnector
|
||||
fields = ConnectorSerializer.Meta.fields + [
|
||||
"url",
|
||||
"token",
|
||||
"secret",
|
||||
"headers_mapping",
|
||||
"map_users",
|
||||
"map_teams_access_group",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"token": {"write_only": True},
|
||||
"secret": {"required": True, "allow_null": False},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ class FleetController(BaseController[DBC]):
|
||||
base_url = base_url[:-1]
|
||||
self._base_url = base_url
|
||||
self._session = get_http_session()
|
||||
self._session.headers["Authorization"] = f"Bearer {self.connector.token}"
|
||||
self._session.headers["Authorization"] = f"Bearer {self.connector.secret.get_value()}"
|
||||
if self.connector.headers_mapping:
|
||||
self._session.headers.update(
|
||||
sanitize_item(
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# Generated by Django 5.2.17 on 2026-08-30 21:31
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def migrate_token(apps, schema_editor):
|
||||
"""Move each connector's token into a Secret object"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
FleetConnector = apps.get_model("authentik_endpoints_connectors_fleet", "FleetConnector")
|
||||
Secret = apps.get_model("authentik_secrets", "Secret")
|
||||
names = set(Secret.objects.using(db_alias).values_list("name", flat=True))
|
||||
for connector in FleetConnector.objects.using(db_alias).exclude(_token=""):
|
||||
base = f"{connector.name} Fleet API token"
|
||||
name, idx = base, 2
|
||||
while name in names:
|
||||
name = f"{base} ({idx})"
|
||||
idx += 1
|
||||
names.add(name)
|
||||
connector.secret = Secret.objects.using(db_alias).create(
|
||||
name=name,
|
||||
value=connector._token,
|
||||
type="multiline" if "\n" in connector._token else "text",
|
||||
)
|
||||
connector.save(update_fields=["secret"])
|
||||
|
||||
|
||||
def rollback_token(apps, schema_editor):
|
||||
"""Copy secret values back into the legacy column, so a downgrade keeps values
|
||||
set or rotated after the upgrade"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
FleetConnector = apps.get_model("authentik_endpoints_connectors_fleet", "FleetConnector")
|
||||
for connector in (
|
||||
FleetConnector.objects.using(db_alias).exclude(secret=None).select_related("secret")
|
||||
):
|
||||
connector._token = connector.secret.value
|
||||
connector.save(update_fields=["_token"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_endpoints_connectors_fleet", "0001_initial"),
|
||||
("authentik_secrets", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="fleetconnector",
|
||||
name="token",
|
||||
field=models.TextField(db_column="token"),
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="fleetconnector",
|
||||
old_name="token",
|
||||
new_name="_token",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="fleetconnector",
|
||||
name="secret",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="fleet_connectors",
|
||||
to="authentik_secrets.secret",
|
||||
verbose_name="Fleet API Token",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_token, rollback_token),
|
||||
]
|
||||
@@ -15,7 +15,17 @@ class FleetConnector(Connector):
|
||||
"""Ingest device data and policy compliance from a Fleet instance."""
|
||||
|
||||
url = models.URLField()
|
||||
token = models.TextField()
|
||||
secret = models.ForeignKey(
|
||||
"authentik_secrets.Secret",
|
||||
verbose_name=_("Fleet API Token"),
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
default=None,
|
||||
related_name="fleet_connectors",
|
||||
)
|
||||
_token = models.TextField(db_column="token")
|
||||
|
||||
headers_mapping = models.ForeignKey(
|
||||
"authentik_events.NotificationWebhookMapping",
|
||||
on_delete=models.SET_DEFAULT,
|
||||
|
||||
@@ -9,6 +9,7 @@ from authentik.enterprise.endpoints.connectors.fleet.models import FleetConnecto
|
||||
from authentik.events.models import NotificationWebhookMapping
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.tests.utils import load_fixture
|
||||
from authentik.secrets.tests.utils import create_test_secret
|
||||
|
||||
TEST_HOST_UBUNTU = loads(load_fixture("fixtures/host_ubuntu.json"))
|
||||
TEST_HOST_FEDORA = loads(load_fixture("fixtures/host_fedora.json"))
|
||||
@@ -23,7 +24,7 @@ class TestFleetConnector(APITestCase):
|
||||
self.connector = FleetConnector.objects.create(
|
||||
name=generate_id(),
|
||||
url="http://localhost",
|
||||
token=generate_id(),
|
||||
secret=create_test_secret(generate_id()),
|
||||
map_teams_access_group=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -15,13 +15,14 @@ from authentik.flows.planner import PLAN_CONTEXT_DEVICE
|
||||
from authentik.flows.tests import FlowTestCase
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.tests.utils import load_fixture
|
||||
from authentik.secrets.tests.utils import create_test_secret
|
||||
|
||||
|
||||
class FleetConnectorStageTests(FlowTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.connector = FleetConnector.objects.create(
|
||||
name=generate_id(), url="http://localhost", token=generate_id()
|
||||
name=generate_id(), url="http://localhost", secret=create_test_secret(generate_id())
|
||||
)
|
||||
|
||||
controller = self.connector.controller(self.connector)
|
||||
|
||||
@@ -30,7 +30,7 @@ class MicrosoftEntraProviderSerializer(EnterpriseRequiredMixin, ProviderSerializ
|
||||
"verbose_name_plural",
|
||||
"meta_model_name",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"secret",
|
||||
"tenant_id",
|
||||
"exclude_users_service_account",
|
||||
"filter_group",
|
||||
@@ -41,7 +41,9 @@ class MicrosoftEntraProviderSerializer(EnterpriseRequiredMixin, ProviderSerializ
|
||||
"sync_page_timeout",
|
||||
"dry_run",
|
||||
]
|
||||
extra_kwargs = {}
|
||||
extra_kwargs = {
|
||||
"secret": {"required": True, "allow_null": False},
|
||||
}
|
||||
|
||||
|
||||
class MicrosoftEntraProviderViewSet(OutgoingSyncProviderStatusMixin, UsedByMixin, ModelViewSet):
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Generated by Django 5.2.17 on 2026-08-30 21:31
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def migrate_client_secret(apps, schema_editor):
|
||||
"""Move each provider's client_secret into a Secret object"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
MicrosoftEntraProvider = apps.get_model(
|
||||
"authentik_providers_microsoft_entra", "MicrosoftEntraProvider"
|
||||
)
|
||||
Secret = apps.get_model("authentik_secrets", "Secret")
|
||||
names = set(Secret.objects.using(db_alias).values_list("name", flat=True))
|
||||
for provider in MicrosoftEntraProvider.objects.using(db_alias).exclude(_client_secret=""):
|
||||
base = f"{provider.name} client secret"
|
||||
name, idx = base, 2
|
||||
while name in names:
|
||||
name = f"{base} ({idx})"
|
||||
idx += 1
|
||||
names.add(name)
|
||||
provider.secret = Secret.objects.using(db_alias).create(
|
||||
name=name,
|
||||
value=provider._client_secret,
|
||||
type="multiline" if "\n" in provider._client_secret else "text",
|
||||
)
|
||||
provider.save(update_fields=["secret"])
|
||||
|
||||
|
||||
def rollback_client_secret(apps, schema_editor):
|
||||
"""Copy secret values back into the legacy column, so a downgrade keeps values
|
||||
set or rotated after the upgrade"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
MicrosoftEntraProvider = apps.get_model(
|
||||
"authentik_providers_microsoft_entra", "MicrosoftEntraProvider"
|
||||
)
|
||||
for provider in (
|
||||
MicrosoftEntraProvider.objects.using(db_alias).exclude(secret=None).select_related("secret")
|
||||
):
|
||||
provider._client_secret = provider.secret.value
|
||||
provider.save(update_fields=["_client_secret"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_providers_microsoft_entra", "0006_microsoftentraprovider_discovery_enabled"),
|
||||
("authentik_secrets", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="microsoftentraprovider",
|
||||
name="client_secret",
|
||||
field=models.TextField(db_column="client_secret"),
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="microsoftentraprovider",
|
||||
old_name="client_secret",
|
||||
new_name="_client_secret",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="microsoftentraprovider",
|
||||
name="secret",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="microsoft_entra_providers",
|
||||
to="authentik_secrets.secret",
|
||||
verbose_name="Client Secret",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_client_secret, rollback_client_secret),
|
||||
]
|
||||
@@ -79,7 +79,16 @@ class MicrosoftEntraProvider(OutgoingSyncProvider, BackchannelProvider):
|
||||
"""Sync users from authentik into Microsoft Entra."""
|
||||
|
||||
client_id = models.TextField()
|
||||
client_secret = models.TextField()
|
||||
secret = models.ForeignKey(
|
||||
"authentik_secrets.Secret",
|
||||
verbose_name=_("Client Secret"),
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
default=None,
|
||||
related_name="microsoft_entra_providers",
|
||||
)
|
||||
_client_secret = models.TextField(db_column="client_secret")
|
||||
tenant_id = models.TextField()
|
||||
|
||||
exclude_users_service_account = models.BooleanField(default=False)
|
||||
@@ -157,7 +166,7 @@ class MicrosoftEntraProvider(OutgoingSyncProvider, BackchannelProvider):
|
||||
def microsoft_credentials(self):
|
||||
return {
|
||||
"credentials": ClientSecretCredential(
|
||||
self.tenant_id, self.client_id, self.client_secret
|
||||
self.tenant_id, self.client_id, self.secret.get_value()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from authentik.enterprise.providers.microsoft_entra.tasks import microsoft_entra
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction
|
||||
from authentik.secrets.tests.utils import create_test_secret
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
|
||||
@@ -41,7 +42,7 @@ class MicrosoftEntraGroupTests(TestCase):
|
||||
self.provider: MicrosoftEntraProvider = MicrosoftEntraProvider.objects.create(
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_id(),
|
||||
secret=create_test_secret(generate_id()),
|
||||
tenant_id=generate_id(),
|
||||
exclude_users_service_account=True,
|
||||
)
|
||||
|
||||
@@ -24,6 +24,7 @@ from authentik.enterprise.providers.microsoft_entra.tasks import microsoft_entra
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction
|
||||
from authentik.secrets.tests.utils import create_test_secret
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
|
||||
@@ -40,7 +41,7 @@ class MicrosoftEntraUserTests(APITestCase):
|
||||
self.provider: MicrosoftEntraProvider = MicrosoftEntraProvider.objects.create(
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_id(),
|
||||
secret=create_test_secret(generate_id()),
|
||||
tenant_id=generate_id(),
|
||||
exclude_users_service_account=True,
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.enterprise.models import License
|
||||
from authentik.enterprise.tests import enterprise_test
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.secrets.tests.utils import create_test_secret
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
|
||||
|
||||
@@ -21,7 +22,7 @@ class TestSCIMOAuthAPI(APITestCase):
|
||||
slug=generate_id(),
|
||||
access_token_url="http://localhost/token", # nosec
|
||||
consumer_key=generate_id(),
|
||||
consumer_secret=generate_id(),
|
||||
secret=create_test_secret(generate_id()),
|
||||
provider_type="openidconnect",
|
||||
)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.models import Application, Group, User
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.scim.models import SCIMAuthenticationMode, SCIMMapping, SCIMProvider
|
||||
from authentik.secrets.tests.utils import create_test_secret
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
@@ -26,7 +27,7 @@ class TestSCIMOAuthAuth(APITestCase):
|
||||
slug=generate_id(),
|
||||
access_token_url="http://localhost/token", # nosec
|
||||
consumer_key=generate_id(),
|
||||
consumer_secret=generate_id(),
|
||||
secret=create_test_secret(generate_id()),
|
||||
provider_type="openidconnect",
|
||||
)
|
||||
self.provider = SCIMProvider.objects.create(
|
||||
|
||||
@@ -13,6 +13,7 @@ from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.models import Application, Group, User
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.scim.models import SCIMAuthenticationMode, SCIMMapping, SCIMProvider
|
||||
from authentik.secrets.tests.utils import create_test_secret
|
||||
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
|
||||
from authentik.tenants.models import Tenant
|
||||
from tests.live import create_test_admin_user
|
||||
@@ -33,7 +34,7 @@ class TestSCIMOAuthToken(APITestCase):
|
||||
slug=generate_id(),
|
||||
access_token_url="http://localhost/token", # nosec
|
||||
consumer_key=generate_id(),
|
||||
consumer_secret=generate_id(),
|
||||
secret=create_test_secret(generate_id()),
|
||||
provider_type="openidconnect",
|
||||
)
|
||||
self.provider = SCIMProvider.objects.create(
|
||||
@@ -73,7 +74,9 @@ class TestSCIMOAuthToken(APITestCase):
|
||||
self.assertTrue(conn.is_valid)
|
||||
auth = (
|
||||
b64encode(
|
||||
b":".join((self.source.consumer_key.encode(), self.source.consumer_secret.encode()))
|
||||
b":".join(
|
||||
(self.source.consumer_key.encode(), self.source.secret.get_value().encode())
|
||||
)
|
||||
)
|
||||
.strip()
|
||||
.decode()
|
||||
@@ -109,7 +112,9 @@ class TestSCIMOAuthToken(APITestCase):
|
||||
self.assertTrue(conn.is_valid)
|
||||
auth = (
|
||||
b64encode(
|
||||
b":".join((self.source.consumer_key.encode(), self.source.consumer_secret.encode()))
|
||||
b":".join(
|
||||
(self.source.consumer_key.encode(), self.source.secret.get_value().encode())
|
||||
)
|
||||
)
|
||||
.strip()
|
||||
.decode()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from rest_framework.decorators import action
|
||||
@@ -22,6 +23,7 @@ from authentik.events.models import (
|
||||
TransportMode,
|
||||
)
|
||||
from authentik.events.utils import get_user
|
||||
from authentik.lib.models import DomainlessURLValidator
|
||||
from authentik.rbac.decorators import permission_required
|
||||
from authentik.stages.email.models import get_template_choices
|
||||
|
||||
@@ -49,10 +51,15 @@ class NotificationTransportSerializer(ModelSerializer):
|
||||
|
||||
def validate(self, attrs: dict[Any, str]) -> dict[Any, str]:
|
||||
"""Ensure the required fields are set."""
|
||||
mode = attrs.get("mode")
|
||||
mode = attrs.get("mode", getattr(self.instance, "mode", None))
|
||||
if mode in [TransportMode.WEBHOOK, TransportMode.WEBHOOK_SLACK]:
|
||||
if "webhook_url" not in attrs or attrs.get("webhook_url", "") == "":
|
||||
raise ValidationError({"webhook_url": "Webhook URL may not be empty."})
|
||||
secret = attrs.get("secret", getattr(self.instance, "secret", None))
|
||||
if not secret:
|
||||
raise ValidationError({"secret": "Webhook URL may not be empty."})
|
||||
try:
|
||||
DomainlessURLValidator()(secret.get_value())
|
||||
except DjangoValidationError as exc:
|
||||
raise ValidationError({"secret": exc.messages}) from exc
|
||||
return attrs
|
||||
|
||||
class Meta:
|
||||
@@ -62,7 +69,7 @@ class NotificationTransportSerializer(ModelSerializer):
|
||||
"name",
|
||||
"mode",
|
||||
"mode_verbose",
|
||||
"webhook_url",
|
||||
"secret",
|
||||
"webhook_ca",
|
||||
"webhook_mapping_body",
|
||||
"webhook_mapping_headers",
|
||||
@@ -83,8 +90,8 @@ class NotificationTransportViewSet(UsedByMixin, ModelViewSet):
|
||||
|
||||
queryset = NotificationTransport.objects.all()
|
||||
serializer_class = NotificationTransportSerializer
|
||||
filterset_fields = ["name", "mode", "webhook_url", "send_once"]
|
||||
search_fields = ["name", "mode", "webhook_url"]
|
||||
filterset_fields = ["name", "mode", "send_once"]
|
||||
search_fields = ["name", "mode"]
|
||||
ordering = ["name"]
|
||||
|
||||
@permission_required("authentik_events.change_notificationtransport")
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Generated by Django 5.2.17 on 2026-08-30 21:31
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import authentik.lib.models
|
||||
|
||||
|
||||
def migrate_webhook_url(apps, schema_editor):
|
||||
"""Move each transport's webhook_url into a Secret object"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
NotificationTransport = apps.get_model("authentik_events", "NotificationTransport")
|
||||
Secret = apps.get_model("authentik_secrets", "Secret")
|
||||
names = set(Secret.objects.using(db_alias).values_list("name", flat=True))
|
||||
for transport in NotificationTransport.objects.using(db_alias).exclude(_webhook_url=""):
|
||||
base = f"{transport.name} webhook URL"
|
||||
name, idx = base, 2
|
||||
while name in names:
|
||||
name = f"{base} ({idx})"
|
||||
idx += 1
|
||||
names.add(name)
|
||||
transport.secret = Secret.objects.using(db_alias).create(
|
||||
name=name,
|
||||
value=transport._webhook_url,
|
||||
type="multiline" if "\n" in transport._webhook_url else "text",
|
||||
)
|
||||
transport.save(update_fields=["secret"])
|
||||
|
||||
|
||||
def rollback_webhook_url(apps, schema_editor):
|
||||
"""Copy secret values back into the legacy column, so a downgrade keeps values
|
||||
set or rotated after the upgrade"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
NotificationTransport = apps.get_model("authentik_events", "NotificationTransport")
|
||||
for transport in (
|
||||
NotificationTransport.objects.using(db_alias).exclude(secret=None).select_related("secret")
|
||||
):
|
||||
transport._webhook_url = transport.secret.value
|
||||
transport.save(update_fields=["_webhook_url"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_events", "0020_alter_event_action"),
|
||||
("authentik_secrets", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="notificationtransport",
|
||||
name="webhook_url",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
db_column="webhook_url",
|
||||
validators=[authentik.lib.models.DomainlessURLValidator()],
|
||||
),
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="notificationtransport",
|
||||
old_name="webhook_url",
|
||||
new_name="_webhook_url",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="notificationtransport",
|
||||
name="secret",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="notification_transports",
|
||||
to="authentik_secrets.secret",
|
||||
verbose_name="Webhook URL",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_webhook_url, rollback_webhook_url),
|
||||
]
|
||||
@@ -370,7 +370,19 @@ class NotificationTransport(TasksModel, SerializerModel):
|
||||
email_subject_prefix = models.TextField(default="authentik Notification: ", blank=True)
|
||||
email_template = models.TextField(default=EmailTemplates.EVENT_NOTIFICATION)
|
||||
|
||||
webhook_url = models.TextField(blank=True, validators=[DomainlessURLValidator()])
|
||||
secret = models.ForeignKey(
|
||||
"authentik_secrets.Secret",
|
||||
verbose_name=_("Webhook URL"),
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
default=None,
|
||||
related_name="notification_transports",
|
||||
)
|
||||
_webhook_url = models.TextField(
|
||||
blank=True, validators=[DomainlessURLValidator()], db_column="webhook_url"
|
||||
)
|
||||
|
||||
webhook_ca = models.ForeignKey(
|
||||
CertificateKeyPair,
|
||||
null=True,
|
||||
@@ -468,7 +480,7 @@ class NotificationTransport(TasksModel, SerializerModel):
|
||||
def send(**kwargs):
|
||||
try:
|
||||
response = get_http_session().post(
|
||||
self.webhook_url,
|
||||
self.secret.get_value(),
|
||||
json=default_body,
|
||||
headers=headers,
|
||||
**kwargs,
|
||||
@@ -555,7 +567,7 @@ class NotificationTransport(TasksModel, SerializerModel):
|
||||
)
|
||||
try:
|
||||
response = get_http_session().post(
|
||||
self.webhook_url,
|
||||
self.secret.get_value(),
|
||||
json=body,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
@@ -15,11 +15,13 @@ from authentik.events.models import (
|
||||
EventAction,
|
||||
Notification,
|
||||
NotificationSeverity,
|
||||
NotificationTransport,
|
||||
TransportMode,
|
||||
)
|
||||
from authentik.events.utils import model_to_dict
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.models import OAuth2Provider
|
||||
from authentik.secrets.models import Secret
|
||||
|
||||
|
||||
class TestEventsAPI(APITestCase):
|
||||
@@ -124,15 +126,17 @@ class TestEventsAPI(APITestCase):
|
||||
|
||||
def test_transport(self):
|
||||
"""Test transport API"""
|
||||
secret = Secret.objects.create(name=generate_id(), value="http://foo.com")
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:notificationtransport-list"),
|
||||
data={
|
||||
"name": "foo-with",
|
||||
"mode": TransportMode.WEBHOOK,
|
||||
"webhook_url": "http://foo.com",
|
||||
"secret": secret.pk,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
transport = NotificationTransport.objects.get(name="foo-with")
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:notificationtransport-list"),
|
||||
data={
|
||||
@@ -141,6 +145,17 @@ class TestEventsAPI(APITestCase):
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
invalid_secret = Secret.objects.create(name=generate_id(), value="not a URL")
|
||||
response = self.client.patch(
|
||||
reverse("authentik_api:notificationtransport-detail", kwargs={"pk": transport.pk}),
|
||||
data={"secret": invalid_secret.pk},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
response = self.client.patch(
|
||||
reverse("authentik_api:notificationtransport-detail", kwargs={"pk": transport.pk}),
|
||||
data={"mode": TransportMode.WEBHOOK, "send_once": True},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
|
||||
def test_volume(self):
|
||||
Event.objects.all().delete()
|
||||
|
||||
@@ -21,6 +21,7 @@ from authentik.events.models import (
|
||||
TransportMode,
|
||||
)
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.secrets.tests.utils import create_test_secret
|
||||
from authentik.stages.email.models import get_template_choices
|
||||
|
||||
|
||||
@@ -43,7 +44,7 @@ class TestEventTransports(TestCase):
|
||||
transport: NotificationTransport = NotificationTransport.objects.create(
|
||||
name=generate_id(),
|
||||
mode=TransportMode.WEBHOOK,
|
||||
webhook_url="http://localhost:1234/test",
|
||||
secret=create_test_secret("http://localhost:1234/test"),
|
||||
)
|
||||
with Mocker() as mocker:
|
||||
mocker.post("http://localhost:1234/test")
|
||||
@@ -67,7 +68,7 @@ class TestEventTransports(TestCase):
|
||||
transport: NotificationTransport = NotificationTransport.objects.create(
|
||||
name=generate_id(),
|
||||
mode=TransportMode.WEBHOOK,
|
||||
webhook_url="https://localhost:1234/test",
|
||||
secret=create_test_secret("https://localhost:1234/test"),
|
||||
)
|
||||
with Mocker() as mocker:
|
||||
mocker.post("https://localhost:1234/test")
|
||||
@@ -84,7 +85,7 @@ class TestEventTransports(TestCase):
|
||||
transport: NotificationTransport = NotificationTransport.objects.create(
|
||||
name=generate_id(),
|
||||
mode=TransportMode.WEBHOOK,
|
||||
webhook_url="https://localhost:1234/test",
|
||||
secret=create_test_secret("https://localhost:1234/test"),
|
||||
webhook_ca=kp,
|
||||
)
|
||||
with Mocker() as mocker:
|
||||
@@ -104,7 +105,7 @@ class TestEventTransports(TestCase):
|
||||
transport: NotificationTransport = NotificationTransport.objects.create(
|
||||
name=generate_id(),
|
||||
mode=TransportMode.WEBHOOK,
|
||||
webhook_url="http://localhost:1234/test",
|
||||
secret=create_test_secret("http://localhost:1234/test"),
|
||||
webhook_mapping_body=mapping_body,
|
||||
webhook_mapping_headers=mapping_headers,
|
||||
)
|
||||
@@ -130,7 +131,7 @@ class TestEventTransports(TestCase):
|
||||
transport: NotificationTransport = NotificationTransport.objects.create(
|
||||
name=generate_id(),
|
||||
mode=TransportMode.WEBHOOK_SLACK,
|
||||
webhook_url="http://localhost:1234/test",
|
||||
secret=create_test_secret("http://localhost:1234/test"),
|
||||
webhook_mapping_body=mapping_body,
|
||||
webhook_mapping_headers=mapping_headers,
|
||||
)
|
||||
@@ -150,7 +151,7 @@ class TestEventTransports(TestCase):
|
||||
transport: NotificationTransport = NotificationTransport.objects.create(
|
||||
name=generate_id(),
|
||||
mode=TransportMode.WEBHOOK_SLACK,
|
||||
webhook_url="http://localhost:1234/test",
|
||||
secret=create_test_secret("http://localhost:1234/test"),
|
||||
)
|
||||
with Mocker() as mocker:
|
||||
mocker.post("http://localhost:1234/test")
|
||||
|
||||
@@ -89,7 +89,7 @@ class TestEvaluator(TestCase):
|
||||
"return ak_create_jwt(user, provider, ['openid', 'email', 'profile'])"
|
||||
)
|
||||
decoded = decode(
|
||||
jwt, provider.client_secret, algorithms=["HS256"], audience=provider.client_id
|
||||
jwt, provider.secret.get_value(), algorithms=["HS256"], audience=provider.client_id
|
||||
)
|
||||
self.assertEqual(decoded["preferred_username"], user.username)
|
||||
|
||||
@@ -109,7 +109,7 @@ class TestEvaluator(TestCase):
|
||||
}
|
||||
jwt = evaluator.evaluate("return ak_create_jwt_raw(provider, foo='bar')")
|
||||
decoded = decode(
|
||||
jwt, provider.client_secret, algorithms=["HS256"], audience=provider.client_id
|
||||
jwt, provider.secret.get_value(), algorithms=["HS256"], audience=provider.client_id
|
||||
)
|
||||
self.assertEqual(decoded["foo"], "bar")
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
"""authentik outpost signals"""
|
||||
|
||||
from functools import partial
|
||||
from uuid import UUID
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.db.models.signals import m2m_changed, post_save, pre_delete, pre_save
|
||||
from django.dispatch import receiver
|
||||
from structlog.stdlib import get_logger
|
||||
@@ -15,6 +20,7 @@ from authentik.outposts.tasks import (
|
||||
outpost_send_update,
|
||||
outpost_session_end,
|
||||
)
|
||||
from authentik.secrets.models import Secret, secret_value_changed
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@@ -148,6 +154,33 @@ post_save.connect(outpost_reverse_related_post_save, sender=Brand, weak=False)
|
||||
post_save.connect(outpost_reverse_related_post_save, sender=CertificateKeyPair, weak=False)
|
||||
|
||||
|
||||
@receiver(secret_value_changed, sender=Secret)
|
||||
def outpost_secret_value_changed(sender, secret: Secret, **_):
|
||||
"""Send new credentials to affected outposts after the database commit."""
|
||||
transaction.on_commit(partial(_send_outpost_updates_for_secret, secret))
|
||||
|
||||
|
||||
def _send_outpost_updates_for_secret(secret: Secret) -> None:
|
||||
"""Find outpost providers with direct or inherited references to a Secret."""
|
||||
outpost_ids: set[UUID] = set()
|
||||
for provider_model in OutpostModel.__subclasses__():
|
||||
query = Q()
|
||||
for field in provider_model._meta.concrete_fields:
|
||||
if field.many_to_one and field.related_model is Secret:
|
||||
query |= Q(**{field.name: secret})
|
||||
if not query:
|
||||
continue
|
||||
for provider in provider_model.objects.filter(query):
|
||||
outpost_ids.update(provider.outpost_set.values_list("pk", flat=True))
|
||||
|
||||
for outpost in Outpost.objects.filter(pk__in=outpost_ids):
|
||||
outpost_send_update.send_with_options(
|
||||
args=(outpost.pk,),
|
||||
rel_obj=outpost,
|
||||
uid=outpost.name,
|
||||
)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=Outpost)
|
||||
def outpost_pre_delete_cleanup(sender, instance: Outpost, **_):
|
||||
"""Ensure that Outpost's user is deleted (which will delete the token through cascade)"""
|
||||
|
||||
@@ -32,6 +32,7 @@ from authentik.providers.oauth2.models import (
|
||||
)
|
||||
from authentik.providers.oauth2.utils import is_all_vschar
|
||||
from authentik.rbac.decorators import permission_required
|
||||
from authentik.secrets.models import Secret
|
||||
|
||||
|
||||
class RedirectURISerializer(PassiveSerializer):
|
||||
@@ -54,8 +55,8 @@ class OAuth2ProviderSerializer(ProviderSerializer):
|
||||
raise ValidationError("Client ID must consist of only ASCII characters.")
|
||||
return secret
|
||||
|
||||
def validate_client_secret(self, secret: str) -> str:
|
||||
if not is_all_vschar(secret):
|
||||
def validate_secret(self, secret: Secret) -> Secret:
|
||||
if not is_all_vschar(secret.get_value()):
|
||||
raise ValidationError("Client secret must consist of only ASCII characters.")
|
||||
return secret
|
||||
|
||||
@@ -78,7 +79,7 @@ class OAuth2ProviderSerializer(ProviderSerializer):
|
||||
"client_type",
|
||||
"grant_types",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"secret",
|
||||
"access_code_validity",
|
||||
"access_token_validity",
|
||||
"refresh_token_validity",
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# Generated by Django 5.2.17 on 2026-08-30 21:12
|
||||
|
||||
import authentik.providers.oauth2.models
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def migrate_client_secret(apps, schema_editor):
|
||||
"""Move each provider's client_secret into a Secret object"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
OAuth2Provider = apps.get_model("authentik_providers_oauth2", "OAuth2Provider")
|
||||
Secret = apps.get_model("authentik_secrets", "Secret")
|
||||
names = set(Secret.objects.using(db_alias).values_list("name", flat=True))
|
||||
for provider in OAuth2Provider.objects.using(db_alias).exclude(_client_secret=""):
|
||||
base = f"{provider.name} client secret"
|
||||
name, idx = base, 2
|
||||
while name in names:
|
||||
name = f"{base} ({idx})"
|
||||
idx += 1
|
||||
names.add(name)
|
||||
provider.secret = Secret.objects.using(db_alias).create(
|
||||
name=name, value=provider._client_secret
|
||||
)
|
||||
provider.save(update_fields=["secret"])
|
||||
|
||||
|
||||
def rollback_client_secret(apps, schema_editor):
|
||||
"""Copy secret values back into the legacy column, so a downgrade keeps values
|
||||
set or rotated after the upgrade"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
OAuth2Provider = apps.get_model("authentik_providers_oauth2", "OAuth2Provider")
|
||||
for provider in (
|
||||
OAuth2Provider.objects.using(db_alias).exclude(secret=None).select_related("secret")
|
||||
):
|
||||
provider._client_secret = provider.secret.value
|
||||
provider.save(update_fields=["_client_secret"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_providers_oauth2", "0037_accesstoken_actor_authorizationcode_actor_and_more"),
|
||||
("authentik_secrets", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="oauth2provider",
|
||||
name="client_secret",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
db_column="client_secret",
|
||||
default=authentik.providers.oauth2.models.generate_client_secret,
|
||||
max_length=255,
|
||||
verbose_name="Client Secret",
|
||||
),
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="oauth2provider",
|
||||
old_name="client_secret",
|
||||
new_name="_client_secret",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="oauth2provider",
|
||||
name="secret",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="oauth2_providers",
|
||||
to="authentik_secrets.secret",
|
||||
verbose_name="Client Secret",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_client_secret, rollback_client_secret),
|
||||
]
|
||||
@@ -23,7 +23,7 @@ from dacite import Config
|
||||
from dacite.core import from_dict
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.contrib.postgres.indexes import HashIndex
|
||||
from django.db import models
|
||||
from django.db import models, transaction
|
||||
from django.http import HttpRequest
|
||||
from django.templatetags.static import static
|
||||
from django.urls import reverse
|
||||
@@ -64,6 +64,7 @@ from authentik.lib.models import (
|
||||
)
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
from authentik.policies.models import PolicyBindingModel
|
||||
from authentik.secrets.models import create_named_secret
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -236,11 +237,21 @@ class OAuth2Provider(WebfingerProvider, Provider):
|
||||
verbose_name=_("Client ID"),
|
||||
default=generate_id,
|
||||
)
|
||||
client_secret = models.CharField(
|
||||
secret = models.ForeignKey(
|
||||
"authentik_secrets.Secret",
|
||||
verbose_name=_("Client Secret"),
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
default=None,
|
||||
related_name="oauth2_providers",
|
||||
)
|
||||
_client_secret = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
verbose_name=_("Client Secret"),
|
||||
default=generate_client_secret,
|
||||
db_column="client_secret",
|
||||
)
|
||||
_redirect_uris = models.JSONField(
|
||||
default=list,
|
||||
@@ -357,11 +368,19 @@ class OAuth2Provider(WebfingerProvider, Provider):
|
||||
"""Get either the configured certificate or the client secret"""
|
||||
if not self.signing_key:
|
||||
# No Certificate at all, assume HS256
|
||||
return self.client_secret, JWTAlgorithms.HS256
|
||||
return self.secret.get_value(), JWTAlgorithms.HS256
|
||||
key: CertificateKeyPair = self.signing_key
|
||||
private_key = key.private_key
|
||||
return private_key, JWTAlgorithms.from_private_key(private_key)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
with transaction.atomic():
|
||||
if not self.secret_id:
|
||||
self.secret = create_named_secret(f"{self.name} client secret")
|
||||
if (update_fields := kwargs.get("update_fields")) is not None:
|
||||
kwargs["update_fields"] = set(update_fields) | {"secret"}
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def get_issuer(self, request: HttpRequest) -> str | None:
|
||||
"""Get issuer, based on request"""
|
||||
if self.issuer_mode == IssuerMode.GLOBAL:
|
||||
|
||||
@@ -83,23 +83,6 @@ class TestAPI(APITestCase):
|
||||
{"client_id": ["Client ID must consist of only ASCII characters."]},
|
||||
)
|
||||
|
||||
def test_validate_client_secret(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,
|
||||
"client_secret": "ú",
|
||||
"redirect_uris": [],
|
||||
},
|
||||
)
|
||||
self.assertJSONEqual(
|
||||
response.content,
|
||||
{"client_secret": ["Client secret must consist of only ASCII characters."]},
|
||||
)
|
||||
|
||||
def test_validate_redirect_uris(self):
|
||||
"""Test redirect_uris API"""
|
||||
response = self.client.post(
|
||||
|
||||
@@ -72,7 +72,7 @@ class TestOAuth2DeviceFlow(OAuthTestCase):
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
data={
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
"grant_type": GRANT_TYPE_DEVICE_CODE,
|
||||
"device_code": device_code,
|
||||
},
|
||||
|
||||
@@ -39,7 +39,7 @@ class TesOAuth2Introspection(OAuthTestCase):
|
||||
)
|
||||
self.user = create_test_admin_user()
|
||||
self.auth = b64encode(
|
||||
f"{self.provider.client_id}:{self.provider.client_secret}".encode()
|
||||
f"{self.provider.client_id}:{self.provider.secret.get_value()}".encode()
|
||||
).decode()
|
||||
|
||||
def test_introspect_refresh(self):
|
||||
@@ -129,7 +129,7 @@ class TesOAuth2Introspection(OAuthTestCase):
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "")],
|
||||
signing_key=create_test_cert(),
|
||||
)
|
||||
auth = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
auth = b64encode(f"{provider.client_id}:{provider.secret.get_value()}".encode()).decode()
|
||||
|
||||
token = AccessToken.objects.create(
|
||||
provider=self.provider,
|
||||
|
||||
@@ -69,7 +69,7 @@ class TestKeyBindingAuthCode(OAuthTestCase):
|
||||
)
|
||||
c_s256 = pkce_s256_challenge("foobar")
|
||||
header = b64encode(
|
||||
f"{self.provider.client_id}:{self.provider.client_secret}".encode()
|
||||
f"{self.provider.client_id}:{self.provider.secret.get_value()}".encode()
|
||||
).decode()
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
@@ -115,7 +115,7 @@ class TestKeyBindingAuthCode(OAuthTestCase):
|
||||
dpop_jkt=None,
|
||||
)
|
||||
header = b64encode(
|
||||
f"{self.provider.client_id}:{self.provider.client_secret}".encode()
|
||||
f"{self.provider.client_id}:{self.provider.secret.get_value()}".encode()
|
||||
).decode()
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
@@ -149,7 +149,7 @@ class TestKeyBindingAuthCode(OAuthTestCase):
|
||||
dpop_jkt=self.dpop_builder.jkt,
|
||||
)
|
||||
header = b64encode(
|
||||
f"{self.provider.client_id}:{self.provider.client_secret}".encode()
|
||||
f"{self.provider.client_id}:{self.provider.secret.get_value()}".encode()
|
||||
).decode()
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
@@ -175,7 +175,7 @@ class TestKeyBindingAuthCode(OAuthTestCase):
|
||||
dpop_jkt=self.dpop_builder.jkt,
|
||||
)
|
||||
header = b64encode(
|
||||
f"{self.provider.client_id}:{self.provider.client_secret}".encode()
|
||||
f"{self.provider.client_id}:{self.provider.secret.get_value()}".encode()
|
||||
).decode()
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
@@ -204,7 +204,7 @@ class TestKeyBindingAuthCode(OAuthTestCase):
|
||||
_id_token=dumps({}),
|
||||
)
|
||||
header = b64encode(
|
||||
f"{self.provider.client_id}:{self.provider.client_secret}".encode()
|
||||
f"{self.provider.client_id}:{self.provider.secret.get_value()}".encode()
|
||||
).decode()
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
@@ -238,7 +238,7 @@ class TestKeyBindingAuthCode(OAuthTestCase):
|
||||
_id_token=dumps({}),
|
||||
)
|
||||
header = b64encode(
|
||||
f"{self.provider.client_id}:{self.provider.client_secret}".encode()
|
||||
f"{self.provider.client_id}:{self.provider.secret.get_value()}".encode()
|
||||
).decode()
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
@@ -267,7 +267,7 @@ class TestKeyBindingAuthCode(OAuthTestCase):
|
||||
_id_token=dumps({}),
|
||||
)
|
||||
header = b64encode(
|
||||
f"{self.provider.client_id}:{self.provider.client_secret}".encode()
|
||||
f"{self.provider.client_id}:{self.provider.secret.get_value()}".encode()
|
||||
).decode()
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
@@ -295,7 +295,7 @@ class TestKeyBindingAuthCode(OAuthTestCase):
|
||||
_id_token=dumps({}),
|
||||
)
|
||||
header = b64encode(
|
||||
f"{self.provider.client_id}:{self.provider.client_secret}".encode()
|
||||
f"{self.provider.client_id}:{self.provider.secret.get_value()}".encode()
|
||||
).decode()
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
|
||||
@@ -70,7 +70,7 @@ class TestKeyBindingDevice(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_DEVICE_CODE,
|
||||
"device_code": device_token.device_code,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
},
|
||||
HTTP_DPOP=self.dpop_builder.make_header(self.token_url, c_s256),
|
||||
)
|
||||
@@ -109,7 +109,7 @@ class TestKeyBindingDevice(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_DEVICE_CODE,
|
||||
"device_code": device_token.device_code,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
},
|
||||
HTTP_DPOP=self.dpop_builder.make_header(self.token_url, c_s256="wrong-hash"),
|
||||
)
|
||||
@@ -131,7 +131,7 @@ class TestKeyBindingDevice(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_DEVICE_CODE,
|
||||
"device_code": device_token.device_code,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
@@ -150,7 +150,7 @@ class TestKeyBindingDevice(OAuthTestCase):
|
||||
_id_token=dumps({}),
|
||||
)
|
||||
header = b64encode(
|
||||
f"{self.provider.client_id}:{self.provider.client_secret}".encode()
|
||||
f"{self.provider.client_id}:{self.provider.secret.get_value()}".encode()
|
||||
).decode()
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
|
||||
@@ -42,7 +42,7 @@ class TesOAuth2Revoke(OAuthTestCase):
|
||||
self.app.save()
|
||||
self.user = create_test_admin_user()
|
||||
self.auth = b64encode(
|
||||
f"{self.provider.client_id}:{self.provider.client_secret}".encode()
|
||||
f"{self.provider.client_id}:{self.provider.secret.get_value()}".encode()
|
||||
).decode()
|
||||
|
||||
def test_revoke_refresh(self):
|
||||
|
||||
94
authentik/providers/oauth2/tests/test_secret.py
Normal file
94
authentik/providers/oauth2/tests/test_secret.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Test OAuth2 provider secret handling"""
|
||||
|
||||
from base64 import b64encode
|
||||
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.models import ClientType, OAuth2Provider
|
||||
from authentik.secrets.models import Secret
|
||||
|
||||
|
||||
class TestProviderSecret(APITestCase):
|
||||
"""Test OAuth2 provider secret handling"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.user = create_test_admin_user()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_auto_create(self):
|
||||
"""Creating a provider without a secret creates one with a generated value"""
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(), authorization_flow=create_test_flow()
|
||||
)
|
||||
self.assertIsNotNone(provider.secret)
|
||||
self.assertNotEqual(provider.secret.get_value(), "")
|
||||
self.assertEqual(provider.secret.value, provider.secret.get_value())
|
||||
|
||||
def test_rotation_applies(self):
|
||||
"""Rotating the secret changes what the provider authenticates with"""
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(), authorization_flow=create_test_flow()
|
||||
)
|
||||
old_value = provider.secret.get_value()
|
||||
provider.secret.rotate()
|
||||
provider.refresh_from_db()
|
||||
self.assertNotEqual(provider.secret.get_value(), old_value)
|
||||
|
||||
def test_detached_secret_fails_closed(self):
|
||||
"""A confidential provider whose secret is detached must reject empty-secret auth"""
|
||||
from django.test import RequestFactory
|
||||
|
||||
from authentik.providers.oauth2.utils import authenticate_provider
|
||||
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
client_type=ClientType.CONFIDENTIAL,
|
||||
authorization_flow=create_test_flow(),
|
||||
)
|
||||
OAuth2Provider.objects.filter(pk=provider.pk).update(secret=None)
|
||||
provider.refresh_from_db()
|
||||
self.assertIsNone(provider.secret)
|
||||
auth = b64encode(f"{provider.client_id}:".encode()).decode()
|
||||
request = RequestFactory().get("/", HTTP_AUTHORIZATION=f"Basic {auth}")
|
||||
self.assertIsNone(authenticate_provider(request))
|
||||
|
||||
def test_api_create_with_secret_reference(self):
|
||||
"""The API accepts a reference to an existing secret"""
|
||||
secret = Secret.objects.create(name=generate_id())
|
||||
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,
|
||||
"secret": str(secret.pk),
|
||||
"redirect_uris": [],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 201, response.content)
|
||||
provider = OAuth2Provider.objects.get(pk=response.json()["pk"])
|
||||
self.assertEqual(provider.secret, secret)
|
||||
|
||||
def test_api_rejects_non_ascii_secret_reference(self):
|
||||
"""OAuth client secrets must remain valid HTTP Basic credentials."""
|
||||
secret = Secret.objects.create(name=generate_id(), value="non-ascii-ú")
|
||||
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,
|
||||
"secret": str(secret.pk),
|
||||
"redirect_uris": [],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(
|
||||
response.json(),
|
||||
{"secret": ["Client secret must consist of only ASCII characters."]},
|
||||
)
|
||||
@@ -35,6 +35,7 @@ from authentik.providers.oauth2.models import (
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
from authentik.providers.oauth2.token.router import parse_token_request
|
||||
from authentik.providers.oauth2.utils import extract_client_auth
|
||||
from authentik.secrets.tests.utils import create_test_secret
|
||||
|
||||
|
||||
class TestToken(OAuthTestCase):
|
||||
@@ -54,7 +55,7 @@ class TestToken(OAuthTestCase):
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://TestServer")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
header = b64encode(f"{provider.client_id}:{provider.secret.get_value()}".encode()).decode()
|
||||
user = create_test_admin_user()
|
||||
code = AuthorizationCode.objects.create(
|
||||
code="foobar", provider=provider, user=user, auth_time=timezone.now()
|
||||
@@ -69,7 +70,7 @@ class TestToken(OAuthTestCase):
|
||||
HTTP_AUTHORIZATION=f"Basic {header}",
|
||||
)
|
||||
with self.assertRaises(TokenError) as cm:
|
||||
parse_token_request(request, provider, provider.client_id, provider.client_secret)
|
||||
parse_token_request(request, provider, provider.client_id, provider.secret.get_value())
|
||||
self.assertEqual(cm.exception.cause, "grant_type_not_configured")
|
||||
|
||||
def test_request_auth_code(self):
|
||||
@@ -81,7 +82,7 @@ class TestToken(OAuthTestCase):
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://TestServer")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
header = b64encode(f"{provider.client_id}:{provider.secret.get_value()}".encode()).decode()
|
||||
user = create_test_admin_user()
|
||||
code = AuthorizationCode.objects.create(
|
||||
code="foobar", provider=provider, user=user, auth_time=timezone.now()
|
||||
@@ -95,7 +96,9 @@ class TestToken(OAuthTestCase):
|
||||
},
|
||||
HTTP_AUTHORIZATION=f"Basic {header}",
|
||||
)
|
||||
params = parse_token_request(request, provider, provider.client_id, provider.client_secret)
|
||||
params = parse_token_request(
|
||||
request, provider, provider.client_id, provider.secret.get_value()
|
||||
)
|
||||
self.assertEqual(params.provider, provider)
|
||||
with self.assertRaises(TokenError):
|
||||
parse_token_request(request, provider, provider.client_id, generate_id())
|
||||
@@ -109,7 +112,7 @@ class TestToken(OAuthTestCase):
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
header = b64encode(f"{provider.client_id}:{provider.secret.get_value()}".encode()).decode()
|
||||
request = self.factory.post(
|
||||
"/",
|
||||
data={
|
||||
@@ -120,7 +123,7 @@ class TestToken(OAuthTestCase):
|
||||
HTTP_AUTHORIZATION=f"Basic {header}",
|
||||
)
|
||||
with self.assertRaises(TokenError):
|
||||
parse_token_request(request, provider, provider.client_id, provider.client_secret)
|
||||
parse_token_request(request, provider, provider.client_id, provider.secret.get_value())
|
||||
|
||||
def test_redirect_uri_regex(self):
|
||||
"""test valid redirect URI (regex)"""
|
||||
@@ -131,7 +134,7 @@ class TestToken(OAuthTestCase):
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.REGEX, ".+")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
header = b64encode(f"{provider.client_id}:{provider.secret.get_value()}".encode()).decode()
|
||||
user = create_test_admin_user()
|
||||
code = AuthorizationCode.objects.create(
|
||||
code="foobar", provider=provider, user=user, auth_time=timezone.now()
|
||||
@@ -145,7 +148,9 @@ class TestToken(OAuthTestCase):
|
||||
},
|
||||
HTTP_AUTHORIZATION=f"Basic {header}",
|
||||
)
|
||||
params = parse_token_request(request, provider, provider.client_id, provider.client_secret)
|
||||
params = parse_token_request(
|
||||
request, provider, provider.client_id, provider.secret.get_value()
|
||||
)
|
||||
self.assertEqual(params.provider, provider)
|
||||
|
||||
def test_invalid_redirect_uri_regex(self):
|
||||
@@ -157,7 +162,7 @@ class TestToken(OAuthTestCase):
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.REGEX, "http://local.invalid?")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
header = b64encode(f"{provider.client_id}:{provider.secret.get_value()}".encode()).decode()
|
||||
request = self.factory.post(
|
||||
"/",
|
||||
data={
|
||||
@@ -168,7 +173,7 @@ class TestToken(OAuthTestCase):
|
||||
HTTP_AUTHORIZATION=f"Basic {header}",
|
||||
)
|
||||
with self.assertRaises(TokenError) as cm:
|
||||
parse_token_request(request, provider, provider.client_id, provider.client_secret)
|
||||
parse_token_request(request, provider, provider.client_id, provider.secret.get_value())
|
||||
self.assertEqual(cm.exception.error, "invalid_client")
|
||||
events = Event.objects.filter(action=EventAction.CONFIGURATION_ERROR)
|
||||
self.assertTrue(events.exists())
|
||||
@@ -185,7 +190,7 @@ class TestToken(OAuthTestCase):
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.REGEX, "+")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
header = b64encode(f"{provider.client_id}:{provider.secret.get_value()}".encode()).decode()
|
||||
request = self.factory.post(
|
||||
"/",
|
||||
data={
|
||||
@@ -196,7 +201,7 @@ class TestToken(OAuthTestCase):
|
||||
HTTP_AUTHORIZATION=f"Basic {header}",
|
||||
)
|
||||
with self.assertRaises(TokenError) as cm:
|
||||
parse_token_request(request, provider, provider.client_id, provider.client_secret)
|
||||
parse_token_request(request, provider, provider.client_id, provider.secret.get_value())
|
||||
self.assertEqual(cm.exception.error, "invalid_client")
|
||||
# The unparsable pattern is reported on its own, and the request then falls through
|
||||
# to the regular no-match handling, so both events are emitted
|
||||
@@ -215,9 +220,11 @@ class TestToken(OAuthTestCase):
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
signing_key=self.keypair,
|
||||
client_secret="à",
|
||||
secret=create_test_secret(
|
||||
"à",
|
||||
),
|
||||
)
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
header = b64encode(f"{provider.client_id}:{provider.secret.get_value()}".encode()).decode()
|
||||
request = self.factory.post(
|
||||
"/",
|
||||
data={
|
||||
@@ -228,7 +235,7 @@ class TestToken(OAuthTestCase):
|
||||
HTTP_AUTHORIZATION=f"Basic {header}",
|
||||
)
|
||||
with self.assertRaises(TokenError) as cm:
|
||||
parse_token_request(request, provider, provider.client_id, provider.client_secret)
|
||||
parse_token_request(request, provider, provider.client_id, provider.secret.get_value())
|
||||
self.assertEqual(cm.exception.error, "invalid_client")
|
||||
self.assertEqual(cm.exception.cause, "invalid_secret")
|
||||
|
||||
@@ -241,7 +248,7 @@ class TestToken(OAuthTestCase):
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
header = b64encode(f"{provider.client_id}:{provider.secret.get_value()}".encode()).decode()
|
||||
user = create_test_admin_user()
|
||||
token = RefreshToken.objects.create(
|
||||
provider=provider,
|
||||
@@ -258,7 +265,9 @@ class TestToken(OAuthTestCase):
|
||||
},
|
||||
HTTP_AUTHORIZATION=f"Basic {header}",
|
||||
)
|
||||
params = parse_token_request(request, provider, provider.client_id, provider.client_secret)
|
||||
params = parse_token_request(
|
||||
request, provider, provider.client_id, provider.secret.get_value()
|
||||
)
|
||||
self.assertEqual(params.provider, provider)
|
||||
|
||||
def test_extract_client_auth_basic_auth_percent_decodes(self):
|
||||
@@ -287,7 +296,7 @@ class TestToken(OAuthTestCase):
|
||||
# Needs to be assigned to an application for iss to be set
|
||||
self.app.provider = provider
|
||||
self.app.save()
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
header = b64encode(f"{provider.client_id}:{provider.secret.get_value()}".encode()).decode()
|
||||
user = create_test_admin_user()
|
||||
code = AuthorizationCode.objects.create(
|
||||
code="foobar", provider=provider, user=user, auth_time=timezone.now()
|
||||
@@ -329,7 +338,7 @@ class TestToken(OAuthTestCase):
|
||||
# Needs to be assigned to an application for iss to be set
|
||||
self.app.provider = provider
|
||||
self.app.save()
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
header = b64encode(f"{provider.client_id}:{provider.secret.get_value()}".encode()).decode()
|
||||
user = create_test_admin_user()
|
||||
code = AuthorizationCode.objects.create(
|
||||
code="foobar", provider=provider, user=user, auth_time=timezone.now()
|
||||
@@ -370,7 +379,7 @@ class TestToken(OAuthTestCase):
|
||||
# Needs to be assigned to an application for iss to be set
|
||||
self.app.provider = provider
|
||||
self.app.save()
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
header = b64encode(f"{provider.client_id}:{provider.secret.get_value()}".encode()).decode()
|
||||
user = create_test_admin_user()
|
||||
token = RefreshToken.objects.create(
|
||||
provider=provider,
|
||||
@@ -429,7 +438,7 @@ class TestToken(OAuthTestCase):
|
||||
]
|
||||
)
|
||||
)
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
header = b64encode(f"{provider.client_id}:{provider.secret.get_value()}".encode()).decode()
|
||||
user = create_test_admin_user()
|
||||
token = RefreshToken.objects.create(
|
||||
provider=provider,
|
||||
@@ -490,7 +499,7 @@ class TestToken(OAuthTestCase):
|
||||
# Needs to be assigned to an application for iss to be set
|
||||
self.app.provider = provider
|
||||
self.app.save()
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
header = b64encode(f"{provider.client_id}:{provider.secret.get_value()}".encode()).decode()
|
||||
user = create_test_admin_user()
|
||||
token = RefreshToken.objects.create(
|
||||
provider=provider,
|
||||
@@ -564,7 +573,7 @@ class TestToken(OAuthTestCase):
|
||||
# Needs to be assigned to an application for iss to be set
|
||||
self.app.provider = provider
|
||||
self.app.save()
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
header = b64encode(f"{provider.client_id}:{provider.secret.get_value()}".encode()).decode()
|
||||
user = create_test_admin_user()
|
||||
token = RefreshToken.objects.create(
|
||||
provider=provider,
|
||||
@@ -655,7 +664,7 @@ class TestToken(OAuthTestCase):
|
||||
self.app.provider = provider
|
||||
self.app.save()
|
||||
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
header = b64encode(f"{provider.client_id}:{provider.secret.get_value()}".encode()).decode()
|
||||
user = create_test_admin_user()
|
||||
code = AuthorizationCode.objects.create(
|
||||
code="foobar",
|
||||
|
||||
@@ -28,6 +28,7 @@ from authentik.providers.oauth2.models import (
|
||||
)
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
from authentik.providers.oauth2.views.jwks import JWKSView
|
||||
from authentik.secrets.tests.utils import create_test_secret
|
||||
from authentik.sources.oauth.models import OAuthSource, OAuthSourcePropertyMapping
|
||||
|
||||
|
||||
@@ -53,7 +54,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase):
|
||||
slug=generate_id(),
|
||||
provider_type="openidconnect",
|
||||
consumer_key=generate_id(),
|
||||
consumer_secret=generate_id(),
|
||||
secret=create_test_secret(generate_id()),
|
||||
authorization_url="http://foo",
|
||||
access_token_url=f"http://{generate_id()}",
|
||||
profile_url="http://foo",
|
||||
@@ -254,7 +255,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase):
|
||||
slug=generate_id(),
|
||||
provider_type="openidconnect",
|
||||
consumer_key=generate_id(),
|
||||
consumer_secret=generate_id(),
|
||||
secret=create_test_secret(generate_id()),
|
||||
authorization_url="http://foo",
|
||||
access_token_url=f"http://{generate_id()}",
|
||||
profile_url="http://foo",
|
||||
|
||||
@@ -64,7 +64,7 @@ class TestTokenClientCredentialsStandard(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
"scope": SCOPE_OPENID,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret + "foo",
|
||||
"client_secret": self.provider.secret.get_value() + "foo",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
@@ -87,7 +87,7 @@ class TestTokenClientCredentialsStandard(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
"scope": SCOPE_OPENID,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
@@ -114,7 +114,7 @@ class TestTokenClientCredentialsStandard(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
"scope": SCOPE_OPENID,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
@@ -135,7 +135,7 @@ class TestTokenClientCredentialsStandard(OAuthTestCase):
|
||||
"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,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
@@ -177,7 +177,7 @@ class TestTokenClientCredentialsStandard(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
@@ -213,7 +213,7 @@ class TestTokenClientCredentialsStandard(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_PASSWORD,
|
||||
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@@ -56,7 +56,7 @@ class TestTokenDeviceCode(OAuthTestCase):
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
data={
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
"grant_type": GRANT_TYPE_DEVICE_CODE,
|
||||
},
|
||||
)
|
||||
@@ -75,7 +75,7 @@ class TestTokenDeviceCode(OAuthTestCase):
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
data={
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
"grant_type": GRANT_TYPE_DEVICE_CODE,
|
||||
"device_code": device_token.device_code,
|
||||
},
|
||||
@@ -116,7 +116,7 @@ class TestTokenDeviceCode(OAuthTestCase):
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
data={
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
"grant_type": GRANT_TYPE_DEVICE_CODE,
|
||||
"device_code": device_token.device_code,
|
||||
},
|
||||
@@ -136,7 +136,7 @@ class TestTokenDeviceCode(OAuthTestCase):
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
data={
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
"grant_type": GRANT_TYPE_DEVICE_CODE,
|
||||
"device_code": device_token.device_code,
|
||||
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} invalid",
|
||||
@@ -163,7 +163,7 @@ class TestTokenDeviceCode(OAuthTestCase):
|
||||
"""Poll the token endpoint for the given device code"""
|
||||
data = {
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
"grant_type": GRANT_TYPE_DEVICE_CODE,
|
||||
"device_code": device_code,
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ class TestTokenExchange(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
"scope": SCOPES,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
"subject_token_type": TOKEN_TYPE_URI_ACCESS_TOKEN,
|
||||
},
|
||||
)
|
||||
@@ -145,7 +145,7 @@ class TestTokenExchange(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
"scope": SCOPES,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
"subject_token": self.subject_token,
|
||||
"subject_token_type": "urn:ietf:params:oauth:token-type:saml2",
|
||||
},
|
||||
@@ -162,7 +162,7 @@ class TestTokenExchange(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
"scope": SCOPES,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
"subject_token": self.subject_token,
|
||||
"subject_token_type": TOKEN_TYPE_URI_ACCESS_TOKEN,
|
||||
"requested_token_type": "urn:ietf:params:oauth:token-type:refresh_token",
|
||||
@@ -181,7 +181,7 @@ class TestTokenExchange(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
"scope": SCOPES,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
"subject_token": self.subject_token,
|
||||
"subject_token_type": TOKEN_TYPE_URI_ACCESS_TOKEN,
|
||||
"actor_token": self.subject_token,
|
||||
@@ -200,7 +200,7 @@ class TestTokenExchange(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
"scope": SCOPES,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
"subject_token": self.subject_token,
|
||||
"subject_token_type": TOKEN_TYPE_URI_ACCESS_TOKEN,
|
||||
**extra,
|
||||
@@ -268,7 +268,7 @@ class TestTokenExchange(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
"scope": SCOPES,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
"subject_token": self.subject_token,
|
||||
"subject_token_type": TOKEN_TYPE_URI_ACCESS_TOKEN,
|
||||
"audience": [self.provider.client_id, self.target_provider.client_id],
|
||||
@@ -360,7 +360,7 @@ class TestTokenExchange(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
"scope": SCOPES,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
"subject_token": self.subject_token,
|
||||
"subject_token_type": TOKEN_TYPE_URI_ACCESS_TOKEN,
|
||||
"resource": "https://api.example.com/orders",
|
||||
@@ -378,7 +378,7 @@ class TestTokenExchange(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
"scope": SCOPES,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
"subject_token": self.subject_token + "foo",
|
||||
"subject_token_type": TOKEN_TYPE_URI_ACCESS_TOKEN,
|
||||
},
|
||||
@@ -396,7 +396,7 @@ class TestTokenExchange(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
"scope": SCOPES,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
"subject_token": self.subject_token,
|
||||
"subject_token_type": TOKEN_TYPE_URI_ACCESS_TOKEN,
|
||||
},
|
||||
@@ -414,7 +414,7 @@ class TestTokenExchange(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
"scope": SCOPES,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
"subject_token": expired,
|
||||
"subject_token_type": TOKEN_TYPE_URI_ACCESS_TOKEN,
|
||||
},
|
||||
@@ -433,7 +433,7 @@ class TestTokenExchange(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
"scope": SCOPES,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
"subject_token": self.subject_token,
|
||||
"subject_token_type": TOKEN_TYPE_URI_ACCESS_TOKEN,
|
||||
},
|
||||
@@ -498,7 +498,7 @@ class TestTokenExchange(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
"scope": SCOPES,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
"subject_token": self.subject_token,
|
||||
"subject_token_type": TOKEN_TYPE_URI_ACCESS_TOKEN,
|
||||
},
|
||||
@@ -533,7 +533,7 @@ class TestTokenExchange(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
"scope": SCOPES,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
"subject_token": self.subject_token,
|
||||
"subject_token_type": TOKEN_TYPE_URI_ACCESS_TOKEN,
|
||||
},
|
||||
@@ -551,7 +551,7 @@ class TestTokenExchange(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
"scope": SCOPES,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
"subject_token": self.subject_token,
|
||||
"subject_token_type": TOKEN_TYPE_URI_ACCESS_TOKEN,
|
||||
},
|
||||
@@ -568,7 +568,7 @@ class TestTokenExchange(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
"scope": SCOPES,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
"subject_token": self.subject_token,
|
||||
"subject_token_type": TOKEN_TYPE_URI_JWT,
|
||||
"requested_token_type": TOKEN_TYPE_URI_JWT,
|
||||
@@ -621,7 +621,7 @@ class TestTokenExchange(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
"scope": SCOPES,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
"subject_token": self.subject_token,
|
||||
"subject_token_type": TOKEN_TYPE_URI_ACCESS_TOKEN,
|
||||
"actor_token": actor_token,
|
||||
@@ -653,7 +653,7 @@ class TestTokenExchange(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
"scope": SCOPES,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
"subject_token": self.subject_token,
|
||||
"subject_token_type": TOKEN_TYPE_URI_ACCESS_TOKEN,
|
||||
"actor_token": actor_token,
|
||||
@@ -681,7 +681,7 @@ class TestTokenExchange(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
"scope": SCOPES,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
"subject_token": self.subject_token,
|
||||
"subject_token_type": TOKEN_TYPE_URI_ACCESS_TOKEN,
|
||||
"actor_token": actor_token,
|
||||
@@ -710,7 +710,7 @@ class TestTokenExchange(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
"scope": SCOPES,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
"subject_token": self.subject_token,
|
||||
"subject_token_type": TOKEN_TYPE_URI_ACCESS_TOKEN,
|
||||
"actor_token": actor_token,
|
||||
@@ -741,7 +741,7 @@ class TestTokenExchange(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
"scope": SCOPES,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
"subject_token": self.subject_token,
|
||||
"subject_token_type": TOKEN_TYPE_URI_ACCESS_TOKEN,
|
||||
"actor_token": actor_token,
|
||||
@@ -763,7 +763,7 @@ class TestTokenExchange(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
"scope": SCOPES,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
"subject_token": self.subject_token,
|
||||
"subject_token_type": TOKEN_TYPE_URI_ACCESS_TOKEN,
|
||||
"actor_token": actor_token,
|
||||
@@ -787,7 +787,7 @@ class TestTokenExchange(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
"scope": SCOPES,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
"subject_token": self.subject_token,
|
||||
"subject_token_type": TOKEN_TYPE_URI_ACCESS_TOKEN,
|
||||
"actor_token": actor_token,
|
||||
@@ -822,7 +822,7 @@ class TestTokenExchange(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
"scope": SCOPES,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
"subject_token": self.subject_token,
|
||||
"subject_token_type": TOKEN_TYPE_URI_ACCESS_TOKEN,
|
||||
"actor_token": actor_token,
|
||||
@@ -841,7 +841,7 @@ class TestTokenExchange(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
"scope": SCOPES,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
"subject_token": self.subject_token,
|
||||
"subject_token_type": TOKEN_TYPE_URI_ACCESS_TOKEN,
|
||||
"actor_token": "not-a-real-token",
|
||||
@@ -861,7 +861,7 @@ class TestTokenExchange(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
"scope": SCOPES,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
"subject_token": self.subject_token,
|
||||
"subject_token_type": TOKEN_TYPE_URI_ACCESS_TOKEN,
|
||||
"actor_token": "not-a-real-token",
|
||||
@@ -882,7 +882,7 @@ class TestTokenExchange(OAuthTestCase):
|
||||
"grant_type": GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
"scope": SCOPES,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"client_secret": self.provider.secret.get_value(),
|
||||
"subject_token": self.subject_token,
|
||||
"subject_token_type": TOKEN_TYPE_URI_ACCESS_TOKEN,
|
||||
},
|
||||
|
||||
@@ -37,6 +37,7 @@ from authentik.providers.oauth2.models import (
|
||||
)
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
from authentik.providers.oauth2.views.jwks import JWKSView
|
||||
from authentik.secrets.tests.utils import create_test_secret
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
|
||||
|
||||
@@ -58,7 +59,7 @@ class TestTokenJWTAlgConfusion(OAuthTestCase):
|
||||
slug=generate_id(),
|
||||
provider_type="openidconnect",
|
||||
consumer_key=generate_id(),
|
||||
consumer_secret=generate_id(),
|
||||
secret=create_test_secret(generate_id()),
|
||||
authorization_url="http://foo",
|
||||
access_token_url=f"http://{generate_id()}",
|
||||
profile_url="http://foo",
|
||||
|
||||
@@ -45,7 +45,7 @@ class TestTokenPKCE(OAuthTestCase):
|
||||
user = create_test_admin_user()
|
||||
self.client.force_login(user)
|
||||
challenge = generate_id()
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
header = b64encode(f"{provider.client_id}:{provider.secret.get_value()}".encode()).decode()
|
||||
# Step 1, initiate params and get redirect to flow
|
||||
response = self.client.get(
|
||||
reverse("authentik_providers_oauth2:authorize"),
|
||||
@@ -103,7 +103,7 @@ class TestTokenPKCE(OAuthTestCase):
|
||||
state = generate_id()
|
||||
user = create_test_admin_user()
|
||||
self.client.force_login(user)
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
header = b64encode(f"{provider.client_id}:{provider.secret.get_value()}".encode()).decode()
|
||||
# Step 1, initiate params and get redirect to flow
|
||||
response = self.client.get(
|
||||
reverse("authentik_providers_oauth2:authorize"),
|
||||
@@ -161,7 +161,7 @@ class TestTokenPKCE(OAuthTestCase):
|
||||
user = create_test_admin_user()
|
||||
self.client.force_login(user)
|
||||
verifier = generate_id()
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
header = b64encode(f"{provider.client_id}:{provider.secret.get_value()}".encode()).decode()
|
||||
# Step 1, initiate params and get redirect to flow
|
||||
response = self.client.get(
|
||||
reverse("authentik_providers_oauth2:authorize"),
|
||||
@@ -207,7 +207,7 @@ class TestTokenPKCE(OAuthTestCase):
|
||||
user = create_test_admin_user()
|
||||
self.client.force_login(user)
|
||||
verifier = generate_id()
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
header = b64encode(f"{provider.client_id}:{provider.secret.get_value()}".encode()).decode()
|
||||
# Step 1, initiate params and get redirect to flow
|
||||
response = self.client.get(
|
||||
reverse("authentik_providers_oauth2:authorize"),
|
||||
|
||||
@@ -86,8 +86,9 @@ class TokenRequest:
|
||||
GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
]:
|
||||
if self.provider.client_type == ClientType.CONFIDENTIAL and (
|
||||
not is_all_vschar(self.client_secret)
|
||||
or not compare_digest(self.provider.client_secret, self.client_secret)
|
||||
not self.provider.secret
|
||||
or not is_all_vschar(self.client_secret)
|
||||
or not compare_digest(self.provider.secret.get_value(), self.client_secret)
|
||||
):
|
||||
self.logger.warning(
|
||||
"Invalid client secret",
|
||||
|
||||
@@ -40,7 +40,7 @@ class ClientCredentialsTokenRequest(FederatedTokenRequest):
|
||||
request, request.POST.get("username"), request.POST.get("password")
|
||||
)
|
||||
# Standard method which creates an automatic user
|
||||
if self.client_secret == self.provider.client_secret:
|
||||
if self.provider.secret and self.client_secret == self.provider.secret.get_value():
|
||||
return self.post_init_client_credentials_generated(request)
|
||||
# Standard workaround method which stores username:password
|
||||
# as client_secret
|
||||
|
||||
@@ -211,8 +211,10 @@ def authenticate_provider(request: HttpRequest) -> OAuth2Provider | None:
|
||||
provider, client_id, client_secret = provider_from_request(request)
|
||||
if not provider:
|
||||
return None
|
||||
if not compare_digest(client_id, provider.client_id) or not compare_digest(
|
||||
client_secret, provider.client_secret
|
||||
if (
|
||||
not provider.secret
|
||||
or not compare_digest(client_id, provider.client_id)
|
||||
or not compare_digest(client_secret, provider.secret.get_value())
|
||||
):
|
||||
LOGGER.debug("(basic) Provider for basic auth does not exist")
|
||||
return None
|
||||
|
||||
@@ -28,7 +28,6 @@ from authentik.providers.oauth2.models import (
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
generate_client_secret,
|
||||
)
|
||||
|
||||
LOGGER = get_logger()
|
||||
@@ -141,7 +140,6 @@ class DynamicClientRegistrationView(View):
|
||||
provider = OAuth2Provider(
|
||||
name=client_name or generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_client_secret(),
|
||||
client_type=client_type,
|
||||
grant_types=grant_types,
|
||||
authorization_flow=self.dcr.override_authorization_flow
|
||||
@@ -206,7 +204,7 @@ class DynamicClientRegistrationView(View):
|
||||
}
|
||||
|
||||
if client_type == ClientType.CONFIDENTIAL:
|
||||
response_data["client_secret"] = provider.client_secret
|
||||
response_data["client_secret"] = provider.secret.get_value()
|
||||
response_data["client_secret_expires_at"] = 0
|
||||
|
||||
if client_name:
|
||||
|
||||
@@ -138,6 +138,7 @@ class ProxyOutpostConfigSerializer(ModelSerializer):
|
||||
oidc_configuration = SerializerMethodField()
|
||||
access_token_validity = SerializerMethodField()
|
||||
scopes_to_request = SerializerMethodField()
|
||||
client_secret = CharField(source="secret.get_value", read_only=True)
|
||||
|
||||
@extend_schema_field(OpenIDConnectConfigurationSerializer)
|
||||
def get_oidc_configuration(self, obj: ProxyProvider):
|
||||
@@ -186,7 +187,7 @@ class ProxyOutpostConfigSerializer(ModelSerializer):
|
||||
class ProxyOutpostConfigViewSet(ListModelMixin, GenericViewSet):
|
||||
"""ProxyProvider Viewset"""
|
||||
|
||||
queryset = ProxyProvider.objects.filter(application__isnull=False)
|
||||
queryset = ProxyProvider.objects.filter(application__isnull=False).select_related("secret")
|
||||
serializer_class = ProxyOutpostConfigSerializer
|
||||
ordering = ["name"]
|
||||
search_fields = ["name"]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""proxy provider tests"""
|
||||
|
||||
from json import loads
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
@@ -20,6 +21,21 @@ class ProxyProviderTests(APITestCase):
|
||||
self.user = create_test_admin_user()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_secret_rotation_triggers_outpost_update(self):
|
||||
"""Inherited OAuth secret fields still notify Proxy outposts."""
|
||||
outpost = Outpost.objects.create(name=generate_id(), type=OutpostType.PROXY)
|
||||
provider = ProxyProvider.objects.create(name=generate_id())
|
||||
outpost.providers.add(provider)
|
||||
|
||||
with patch("authentik.outposts.signals.outpost_send_update.send_with_options") as sender:
|
||||
with self.captureOnCommitCallbacks(execute=True):
|
||||
provider.secret.rotate()
|
||||
|
||||
self.assertTrue(
|
||||
any(call.kwargs.get("args") == (outpost.pk,) for call in sender.call_args_list),
|
||||
sender.call_args_list,
|
||||
)
|
||||
|
||||
def test_basic_auth(self):
|
||||
"""Test basic_auth_enabled"""
|
||||
response = self.client.post(
|
||||
|
||||
@@ -45,9 +45,7 @@ class RadiusProviderSerializer(
|
||||
model = RadiusProvider
|
||||
fields = ProviderSerializer.Meta.fields + [
|
||||
"client_networks",
|
||||
# Shared secret is not a write-only field, as
|
||||
# an admin might have to view it
|
||||
"shared_secret",
|
||||
"secret",
|
||||
"outpost_set",
|
||||
"mfa_support",
|
||||
"certificate",
|
||||
@@ -75,6 +73,7 @@ class RadiusOutpostConfigSerializer(ModelSerializer):
|
||||
|
||||
application_slug = CharField(source="application.slug")
|
||||
auth_flow_slug = CharField(source="authorization_flow.slug")
|
||||
shared_secret = CharField(source="secret.get_value", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RadiusProvider
|
||||
@@ -93,7 +92,7 @@ class RadiusOutpostConfigSerializer(ModelSerializer):
|
||||
class RadiusOutpostConfigViewSet(ListModelMixin, GenericViewSet):
|
||||
"""RadiusProvider Viewset"""
|
||||
|
||||
queryset = RadiusProvider.objects.filter(application__isnull=False)
|
||||
queryset = RadiusProvider.objects.filter(application__isnull=False).select_related("secret")
|
||||
serializer_class = RadiusOutpostConfigSerializer
|
||||
ordering = ["name"]
|
||||
search_fields = ["name"]
|
||||
@@ -121,7 +120,7 @@ class RadiusOutpostConfigViewSet(ListModelMixin, GenericViewSet):
|
||||
)
|
||||
|
||||
packet = AuthPacket()
|
||||
packet.secret = provider.shared_secret
|
||||
packet.secret = provider.secret.get_value()
|
||||
packet.dict = dict
|
||||
|
||||
def define_attribute(
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Generated by Django 5.2.17 on 2026-08-30 21:12
|
||||
|
||||
import authentik.lib.generators
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def migrate_shared_secret(apps, schema_editor):
|
||||
"""Move each provider's shared_secret into a Secret object"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
RadiusProvider = apps.get_model("authentik_providers_radius", "RadiusProvider")
|
||||
Secret = apps.get_model("authentik_secrets", "Secret")
|
||||
names = set(Secret.objects.using(db_alias).values_list("name", flat=True))
|
||||
for provider in RadiusProvider.objects.using(db_alias).exclude(_shared_secret=""):
|
||||
base = f"{provider.name} shared secret"
|
||||
name, idx = base, 2
|
||||
while name in names:
|
||||
name = f"{base} ({idx})"
|
||||
idx += 1
|
||||
names.add(name)
|
||||
provider.secret = Secret.objects.using(db_alias).create(
|
||||
name=name, value=provider._shared_secret
|
||||
)
|
||||
provider.save(update_fields=["secret"])
|
||||
|
||||
|
||||
def rollback_shared_secret(apps, schema_editor):
|
||||
"""Copy secret values back into the legacy column, so a downgrade keeps values
|
||||
set or rotated after the upgrade"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
RadiusProvider = apps.get_model("authentik_providers_radius", "RadiusProvider")
|
||||
for provider in (
|
||||
RadiusProvider.objects.using(db_alias).exclude(secret=None).select_related("secret")
|
||||
):
|
||||
provider._shared_secret = provider.secret.value
|
||||
provider.save(update_fields=["_shared_secret"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_providers_radius", "0005_radiusprovider_certificate"),
|
||||
("authentik_secrets", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="radiusprovider",
|
||||
name="shared_secret",
|
||||
field=models.TextField(
|
||||
db_column="shared_secret",
|
||||
default=authentik.lib.generators.generate_id,
|
||||
help_text="Shared secret between clients and server to hash packets.",
|
||||
),
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="radiusprovider",
|
||||
old_name="shared_secret",
|
||||
new_name="_shared_secret",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="radiusprovider",
|
||||
name="secret",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="Shared secret between clients and server to hash packets.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="radius_providers",
|
||||
to="authentik_secrets.secret",
|
||||
verbose_name="Shared Secret",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_shared_secret, rollback_shared_secret),
|
||||
]
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
from django.db import models
|
||||
from django.db import models, transaction
|
||||
from django.templatetags.static import static
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.serializers import Serializer
|
||||
@@ -11,14 +11,26 @@ from authentik.core.models import PropertyMapping, Provider
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.outposts.models import OutpostModel
|
||||
from authentik.secrets.models import create_named_secret
|
||||
|
||||
|
||||
class RadiusProvider(OutpostModel, Provider):
|
||||
"""Allow applications to authenticate against authentik's users using Radius."""
|
||||
|
||||
shared_secret = models.TextField(
|
||||
secret = models.ForeignKey(
|
||||
"authentik_secrets.Secret",
|
||||
verbose_name=_("Shared Secret"),
|
||||
help_text=_("Shared secret between clients and server to hash packets."),
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
default=None,
|
||||
related_name="radius_providers",
|
||||
)
|
||||
_shared_secret = models.TextField(
|
||||
default=generate_id,
|
||||
help_text=_("Shared secret between clients and server to hash packets."),
|
||||
db_column="shared_secret",
|
||||
)
|
||||
|
||||
client_networks = models.TextField(
|
||||
@@ -50,6 +62,14 @@ class RadiusProvider(OutpostModel, Provider):
|
||||
"""Radius never has a launch URL"""
|
||||
return None
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
with transaction.atomic():
|
||||
if not self.secret_id:
|
||||
self.secret = create_named_secret(f"{self.name} shared secret")
|
||||
if (update_fields := kwargs.get("update_fields")) is not None:
|
||||
kwargs["update_fields"] = set(update_fields) | {"secret"}
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def component(self) -> str:
|
||||
return "ak-provider-radius-form"
|
||||
|
||||
1
authentik/providers/radius/tests/__init__.py
Normal file
1
authentik/providers/radius/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
73
authentik/providers/radius/tests/test_secret.py
Normal file
73
authentik/providers/radius/tests/test_secret.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Test RADIUS provider secret handling"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
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.outposts.models import Outpost, OutpostType
|
||||
from authentik.providers.radius.models import RadiusProvider
|
||||
from authentik.secrets.models import Secret
|
||||
|
||||
|
||||
class TestProviderSecret(APITestCase):
|
||||
"""Test RADIUS provider secret handling"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.user = create_test_admin_user()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_auto_create(self):
|
||||
"""Creating a provider without a secret creates one with a generated value"""
|
||||
provider = RadiusProvider.objects.create(
|
||||
name=generate_id(), authorization_flow=create_test_flow()
|
||||
)
|
||||
self.assertIsNotNone(provider.secret)
|
||||
self.assertNotEqual(provider.secret.get_value(), "")
|
||||
|
||||
def test_rotation_triggers_outpost_update(self):
|
||||
"""Rotating a secret pushes new config to outposts whose providers use it"""
|
||||
provider = RadiusProvider.objects.create(
|
||||
name=generate_id(), authorization_flow=create_test_flow()
|
||||
)
|
||||
outpost = Outpost.objects.create(name=generate_id(), type=OutpostType.RADIUS)
|
||||
outpost.providers.add(provider)
|
||||
with patch("authentik.outposts.signals.outpost_send_update.send_with_options") as sender:
|
||||
with self.captureOnCommitCallbacks(execute=True):
|
||||
provider.secret.rotate()
|
||||
self.assertTrue(
|
||||
any(call.kwargs.get("args") == (outpost.pk,) for call in sender.call_args_list),
|
||||
sender.call_args_list,
|
||||
)
|
||||
|
||||
def test_api_create_with_secret_reference(self):
|
||||
"""The API accepts a reference to an existing secret"""
|
||||
secret = Secret.objects.create(name=generate_id())
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:radiusprovider-list"),
|
||||
data={
|
||||
"name": generate_id(),
|
||||
"authorization_flow": create_test_flow().pk,
|
||||
"invalidation_flow": create_test_flow().pk,
|
||||
"secret": secret.pk,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 201, response.content)
|
||||
provider = RadiusProvider.objects.get(pk=response.json()["pk"])
|
||||
self.assertEqual(provider.secret, secret)
|
||||
self.assertEqual(provider.secret.get_value(), secret.value)
|
||||
|
||||
def test_outpost_config_shared_secret(self):
|
||||
"""The outpost config endpoint returns the value for the outpost to use"""
|
||||
provider = RadiusProvider.objects.create(
|
||||
name=generate_id(), authorization_flow=create_test_flow()
|
||||
)
|
||||
Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
|
||||
response = self.client.get(reverse("authentik_api:radiusprovideroutpost-list"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
results = response.json()["results"]
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0]["shared_secret"], provider.secret.get_value())
|
||||
@@ -37,7 +37,7 @@ class SCIMProviderSerializer(
|
||||
"meta_model_name",
|
||||
"url",
|
||||
"verify_certificates",
|
||||
"token",
|
||||
"secret",
|
||||
"auth_mode",
|
||||
"auth_oauth",
|
||||
"auth_oauth_params",
|
||||
|
||||
@@ -12,5 +12,5 @@ class SCIMTokenAuth:
|
||||
self.provider = provider
|
||||
|
||||
def __call__(self, request: Request) -> Request:
|
||||
request.headers["Authorization"] = f"Bearer {self.provider.token}"
|
||||
request.headers["Authorization"] = f"Bearer {self.provider.secret.get_value()}"
|
||||
return request
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Generated by Django 5.2.17 on 2026-08-30 21:31
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def migrate_token(apps, schema_editor):
|
||||
"""Move each provider's token into a Secret object"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
SCIMProvider = apps.get_model("authentik_providers_scim", "SCIMProvider")
|
||||
Secret = apps.get_model("authentik_secrets", "Secret")
|
||||
names = set(Secret.objects.using(db_alias).values_list("name", flat=True))
|
||||
for provider in SCIMProvider.objects.using(db_alias).exclude(_token=""):
|
||||
base = f"{provider.name} SCIM token"
|
||||
name, idx = base, 2
|
||||
while name in names:
|
||||
name = f"{base} ({idx})"
|
||||
idx += 1
|
||||
names.add(name)
|
||||
provider.secret = Secret.objects.using(db_alias).create(
|
||||
name=name,
|
||||
value=provider._token,
|
||||
type="multiline" if "\n" in provider._token else "text",
|
||||
)
|
||||
provider.save(update_fields=["secret"])
|
||||
|
||||
|
||||
def rollback_token(apps, schema_editor):
|
||||
"""Copy secret values back into the legacy column, so a downgrade keeps values
|
||||
set or rotated after the upgrade"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
SCIMProvider = apps.get_model("authentik_providers_scim", "SCIMProvider")
|
||||
for provider in (
|
||||
SCIMProvider.objects.using(db_alias).exclude(secret=None).select_related("secret")
|
||||
):
|
||||
provider._token = provider.secret.value
|
||||
provider.save(update_fields=["_token"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_providers_scim", "0021_scimprovider_discovery_enabled"),
|
||||
("authentik_secrets", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="scimprovider",
|
||||
name="token",
|
||||
field=models.TextField(blank=True, db_column="token", help_text="Authentication token"),
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="scimprovider",
|
||||
old_name="token",
|
||||
new_name="_token",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="scimprovider",
|
||||
name="secret",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="Authentication token",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="scim_providers",
|
||||
to="authentik_secrets.secret",
|
||||
verbose_name="Token",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_token, rollback_token),
|
||||
]
|
||||
@@ -107,7 +107,18 @@ class SCIMProvider(OutgoingSyncProvider, BackchannelProvider):
|
||||
choices=SCIMAuthenticationMode.choices, default=SCIMAuthenticationMode.TOKEN
|
||||
)
|
||||
|
||||
token = models.TextField(help_text=_("Authentication token"), blank=True)
|
||||
secret = models.ForeignKey(
|
||||
"authentik_secrets.Secret",
|
||||
verbose_name=_("Token"),
|
||||
help_text=_("Authentication token"),
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
default=None,
|
||||
related_name="scim_providers",
|
||||
)
|
||||
_token = models.TextField(help_text=_("Authentication token"), blank=True, db_column="token")
|
||||
|
||||
auth_oauth = models.ForeignKey(
|
||||
"authentik_sources_oauth.OAuthSource",
|
||||
on_delete=models.SET_DEFAULT,
|
||||
|
||||
@@ -11,6 +11,7 @@ from authentik.lib.generators import generate_id
|
||||
from authentik.policies.dummy.models import DummyPolicy
|
||||
from authentik.policies.models import PolicyBinding, PolicyEngineMode
|
||||
from authentik.providers.scim.models import SCIMMapping, SCIMProvider
|
||||
from authentik.secrets.tests.utils import create_test_secret
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
|
||||
@@ -27,7 +28,7 @@ class SCIMApplicationPoliciesTests(TestCase):
|
||||
self.provider: SCIMProvider = SCIMProvider.objects.create(
|
||||
name=generate_id(),
|
||||
url="https://localhost",
|
||||
token=generate_id(),
|
||||
secret=create_test_secret(generate_id()),
|
||||
exclude_users_service_account=True,
|
||||
)
|
||||
self.provider.property_mappings.add(
|
||||
|
||||
@@ -12,6 +12,7 @@ from authentik.lib.generators import generate_id
|
||||
from authentik.providers.scim.clients.base import SCIMClient
|
||||
from authentik.providers.scim.models import SCIMMapping, SCIMProvider
|
||||
from authentik.providers.scim.tasks import scim_sync
|
||||
from authentik.secrets.tests.utils import create_test_secret
|
||||
|
||||
|
||||
@patch("authentik.providers.scim.clients.base.SCIMClient.can_discover", False)
|
||||
@@ -25,7 +26,7 @@ class SCIMClientTests(TestCase):
|
||||
self.provider: SCIMProvider = SCIMProvider.objects.create(
|
||||
name=generate_id(),
|
||||
url="https://localhost",
|
||||
token=generate_id(),
|
||||
secret=create_test_secret(generate_id()),
|
||||
)
|
||||
self.app: Application = Application.objects.create(
|
||||
name=generate_id(),
|
||||
|
||||
@@ -6,6 +6,7 @@ from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.models import Application, Group, User
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.scim.models import SCIMMapping, SCIMProvider
|
||||
from authentik.secrets.tests.utils import create_test_secret
|
||||
|
||||
|
||||
class SCIMFilterGroupsTests(TestCase):
|
||||
@@ -20,7 +21,7 @@ class SCIMFilterGroupsTests(TestCase):
|
||||
self.provider: SCIMProvider = SCIMProvider.objects.create(
|
||||
name=generate_id(),
|
||||
url="https://localhost",
|
||||
token=generate_id(),
|
||||
secret=create_test_secret(generate_id()),
|
||||
exclude_users_service_account=True,
|
||||
)
|
||||
self.provider.property_mappings.add(
|
||||
|
||||
@@ -13,6 +13,7 @@ from authentik.lib.generators import generate_id
|
||||
from authentik.providers.scim.clients.groups import SCIMGroupClient
|
||||
from authentik.providers.scim.models import SCIMMapping, SCIMProvider, SCIMProviderGroup
|
||||
from authentik.providers.scim.tasks import scim_sync
|
||||
from authentik.secrets.tests.utils import create_test_secret
|
||||
|
||||
|
||||
@patch("authentik.providers.scim.clients.base.SCIMClient.can_discover", False)
|
||||
@@ -28,7 +29,7 @@ class SCIMGroupTests(TestCase):
|
||||
self.provider: SCIMProvider = SCIMProvider.objects.create(
|
||||
name=generate_id(),
|
||||
url="https://localhost",
|
||||
token=generate_id(),
|
||||
secret=create_test_secret(generate_id()),
|
||||
)
|
||||
self.app: Application = Application.objects.create(
|
||||
name=generate_id(),
|
||||
|
||||
@@ -16,6 +16,7 @@ from authentik.providers.scim.models import (
|
||||
SCIMProviderGroup,
|
||||
)
|
||||
from authentik.providers.scim.tasks import scim_sync
|
||||
from authentik.secrets.tests.utils import create_test_secret
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
|
||||
@@ -39,7 +40,7 @@ class SCIMMembershipTests(TestCase):
|
||||
self.provider: SCIMProvider = SCIMProvider.objects.create(
|
||||
name=generate_id(),
|
||||
url="https://localhost",
|
||||
token=generate_id(),
|
||||
secret=create_test_secret(generate_id()),
|
||||
**kwargs,
|
||||
)
|
||||
self.app: Application = Application.objects.create(
|
||||
|
||||
@@ -15,6 +15,7 @@ from authentik.lib.sync.outgoing.exceptions import TransientSyncException
|
||||
from authentik.providers.scim.clients.users import SCIMUserClient
|
||||
from authentik.providers.scim.models import SCIMMapping, SCIMProvider, SCIMProviderUser
|
||||
from authentik.providers.scim.tasks import scim_sync, scim_sync_objects, sync_tasks
|
||||
from authentik.secrets.tests.utils import create_test_secret
|
||||
from authentik.tasks.models import Task
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
@@ -33,7 +34,7 @@ class SCIMUserTests(TestCase):
|
||||
self.provider: SCIMProvider = SCIMProvider.objects.create(
|
||||
name=generate_id(),
|
||||
url="https://localhost",
|
||||
token=generate_id(),
|
||||
secret=create_test_secret(generate_id()),
|
||||
exclude_users_service_account=True,
|
||||
)
|
||||
self.app: Application = Application.objects.create(
|
||||
@@ -164,7 +165,7 @@ class SCIMUserTests(TestCase):
|
||||
provider: SCIMProvider = SCIMProvider.objects.create(
|
||||
name=generate_id(),
|
||||
url="https://localhost",
|
||||
token=generate_id(),
|
||||
secret=create_test_secret(generate_id()),
|
||||
exclude_users_service_account=True,
|
||||
)
|
||||
app: Application = Application.objects.create(
|
||||
|
||||
@@ -90,6 +90,7 @@ TENANT_APPS = [
|
||||
"authentik.api",
|
||||
"authentik.core",
|
||||
"authentik.crypto",
|
||||
"authentik.secrets",
|
||||
"authentik.endpoints",
|
||||
"authentik.endpoints.connectors.agent",
|
||||
"authentik.events",
|
||||
@@ -212,6 +213,7 @@ SPECTACULAR_SETTINGS = {
|
||||
"SAMLLogoutMethods": "authentik.providers.saml.models.SAMLLogoutMethods",
|
||||
"SAMLNameIDPolicyEnum": "authentik.sources.saml.models.SAMLNameIDPolicy",
|
||||
"SCIMAuthenticationModeEnum": "authentik.providers.scim.models.SCIMAuthenticationMode",
|
||||
"SecretTypeEnum": "authentik.secrets.models.SecretType",
|
||||
"StageModeEnum": "authentik.endpoints.models.StageMode",
|
||||
"TaskAggregatedStatusEnum": "authentik.tasks.models.TaskStatus",
|
||||
"TaskStatusEnum": "django_dramatiq_postgres.models.TaskState",
|
||||
|
||||
1
authentik/secrets/__init__.py
Normal file
1
authentik/secrets/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Managed secrets."""
|
||||
147
authentik/secrets/api.py
Normal file
147
authentik/secrets/api.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""Managed secrets API."""
|
||||
|
||||
from base64 import b64decode
|
||||
from binascii import Error as BinasciiError
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models.deletion import ProtectedError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.fields import CharField
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.blueprints.api import ManagedSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.rbac.decorators import permission_required
|
||||
from authentik.rbac.permissions import ObjectPermissions
|
||||
from authentik.secrets.models import Secret, SecretType
|
||||
|
||||
|
||||
class SecretSerializer(ManagedSerializer, ModelSerializer):
|
||||
"""Create and configure a secret without exposing its value."""
|
||||
|
||||
def to_internal_value(self, data):
|
||||
values = super().to_internal_value(data)
|
||||
if values.get("value") == "":
|
||||
values.pop("value")
|
||||
return values
|
||||
|
||||
def validate_value(self, value: str) -> str:
|
||||
instance = self.instance
|
||||
if not instance or value == instance.value:
|
||||
return value
|
||||
request = self.context.get("request")
|
||||
if request and not (
|
||||
request.user.has_perm("authentik_secrets.rotate_secret")
|
||||
or request.user.has_perm("authentik_secrets.rotate_secret", instance)
|
||||
):
|
||||
raise PermissionDenied(_("You do not have permission to replace this value."))
|
||||
if instance.oauth2_providers.exists():
|
||||
from authentik.providers.oauth2.utils import is_all_vschar
|
||||
|
||||
if not is_all_vschar(value):
|
||||
raise ValidationError(
|
||||
_("OAuth client secrets must consist of only ASCII characters.")
|
||||
)
|
||||
return value
|
||||
|
||||
def validate(self, attrs: dict) -> dict:
|
||||
instance = self.instance
|
||||
if instance and attrs.get("type", instance.type) != instance.type:
|
||||
raise ValidationError({"type": _("Type cannot be changed after creation.")})
|
||||
secret_type = attrs.get("type", instance.type if instance else SecretType.TEXT)
|
||||
if not instance and secret_type != SecretType.TEXT and not attrs.get("value"):
|
||||
raise ValidationError({"value": _("A value is required for this type.")})
|
||||
if secret_type == SecretType.FILE and attrs.get("value"):
|
||||
try:
|
||||
b64decode(attrs["value"], validate=True)
|
||||
except BinasciiError, ValueError:
|
||||
raise ValidationError({"value": _("Value must be base64-encoded.")}) from None
|
||||
return attrs
|
||||
|
||||
def update(self, instance: Secret, validated_data: dict) -> Secret:
|
||||
value = validated_data.pop("value", None)
|
||||
with transaction.atomic():
|
||||
instance = super().update(instance, validated_data)
|
||||
if value is not None:
|
||||
instance.replace_value(value, self.context.get("request"))
|
||||
return instance
|
||||
|
||||
class Meta:
|
||||
model = Secret
|
||||
fields = ["pk", "name", "type", "managed", "value", "created", "last_updated"]
|
||||
extra_kwargs = {
|
||||
"managed": {"read_only": True},
|
||||
"value": {"write_only": True, "required": False, "allow_blank": True},
|
||||
"created": {"read_only": True},
|
||||
"last_updated": {"read_only": True},
|
||||
}
|
||||
|
||||
|
||||
class SecretValueSerializer(PassiveSerializer):
|
||||
"""A secret value."""
|
||||
|
||||
value = CharField(read_only=True)
|
||||
|
||||
|
||||
class RotatedSecretSerializer(PassiveSerializer):
|
||||
"""A rotated value, hidden when the caller cannot view it."""
|
||||
|
||||
value = CharField(read_only=True, allow_null=True)
|
||||
|
||||
|
||||
class SecretRotatePermissions(ObjectPermissions):
|
||||
"""Map rotation to its dedicated object permission."""
|
||||
|
||||
perms_map = {**ObjectPermissions.perms_map, "POST": ["%(app_label)s.rotate_%(model_name)s"]}
|
||||
|
||||
|
||||
class SecretViewSet(UsedByMixin, ModelViewSet):
|
||||
"""Manage secrets."""
|
||||
|
||||
queryset = Secret.objects.all()
|
||||
serializer_class = SecretSerializer
|
||||
ordering = ["name"]
|
||||
search_fields = ["name"]
|
||||
filterset_fields = ["name", "type", "managed"]
|
||||
|
||||
def destroy(self, request: Request, *args, **kwargs) -> Response:
|
||||
try:
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
except ProtectedError:
|
||||
raise ValidationError(
|
||||
_("Secret is in use. Remove it from the objects referencing it first.")
|
||||
) from None
|
||||
|
||||
@permission_required("authentik_secrets.view_secret_value")
|
||||
@extend_schema(responses={200: SecretValueSerializer})
|
||||
@action(detail=True, pagination_class=None)
|
||||
def view_value(self, request: Request, pk: str) -> Response:
|
||||
"""Return and audit a secret value."""
|
||||
secret = self.get_object()
|
||||
Event.new(EventAction.SECRET_VIEW, secret=secret).from_http(request) # noqa: S105
|
||||
return Response(SecretValueSerializer({"value": secret.get_value()}).data)
|
||||
|
||||
@extend_schema(request=None, responses={200: RotatedSecretSerializer})
|
||||
@action(
|
||||
detail=True,
|
||||
methods=["POST"],
|
||||
pagination_class=None,
|
||||
permission_classes=[SecretRotatePermissions],
|
||||
)
|
||||
def rotate(self, request: Request, pk: str) -> Response:
|
||||
"""Replace a text secret with a generated value."""
|
||||
secret = self.get_object()
|
||||
if secret.type != SecretType.TEXT:
|
||||
raise ValidationError({"non_field_errors": [_("Only text secrets can be rotated.")]})
|
||||
value = secret.rotate(request)
|
||||
can_view = request.user.has_perm("authentik_secrets.view_secret_value") or (
|
||||
request.user.has_perm("authentik_secrets.view_secret_value", secret)
|
||||
)
|
||||
return Response(RotatedSecretSerializer({"value": value if can_view else None}).data)
|
||||
12
authentik/secrets/apps.py
Normal file
12
authentik/secrets/apps.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""Managed secrets app configuration."""
|
||||
|
||||
from authentik.blueprints.apps import ManagedAppConfig
|
||||
|
||||
|
||||
class AuthentikSecretsConfig(ManagedAppConfig):
|
||||
"""Managed secrets app configuration."""
|
||||
|
||||
name = "authentik.secrets"
|
||||
label = "authentik_secrets"
|
||||
verbose_name = "authentik Secrets"
|
||||
default = True
|
||||
59
authentik/secrets/migrations/0001_initial.py
Normal file
59
authentik/secrets/migrations/0001_initial.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# Generated by Django 5.2.17 on 2026-08-31 00:13
|
||||
|
||||
import authentik.secrets.models
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Secret",
|
||||
fields=[
|
||||
("created", models.DateTimeField(auto_now_add=True)),
|
||||
("last_updated", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"managed",
|
||||
models.TextField(
|
||||
default=None,
|
||||
help_text="Objects that are managed by authentik. These objects are created and updated automatically. This flag only indicates that an object can be overwritten by migrations. You can still modify the objects via the API, but expect changes to be overwritten in a later update.",
|
||||
null=True,
|
||||
unique=True,
|
||||
verbose_name="Managed by authentik",
|
||||
),
|
||||
),
|
||||
(
|
||||
"secret_uuid",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("name", models.TextField(unique=True)),
|
||||
(
|
||||
"type",
|
||||
models.TextField(
|
||||
choices=[
|
||||
("text", "Text"),
|
||||
("multiline", "Multi-line text"),
|
||||
("file", "File"),
|
||||
],
|
||||
default="text",
|
||||
),
|
||||
),
|
||||
("value", models.TextField(default=authentik.secrets.models.generate_secret_value)),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Secret",
|
||||
"verbose_name_plural": "Secrets",
|
||||
"permissions": [
|
||||
("view_secret_value", "View secret's value"),
|
||||
("rotate_secret", "Rotate secret's value"),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
1
authentik/secrets/migrations/__init__.py
Normal file
1
authentik/secrets/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Managed secrets migrations."""
|
||||
103
authentik/secrets/models.py
Normal file
103
authentik/secrets/models.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Managed secret models."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import uuid4
|
||||
|
||||
from django.db import IntegrityError, models, transaction
|
||||
from django.dispatch import Signal
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from authentik.blueprints.models import ManagedModel
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.models import CreatedUpdatedModel, SerializerModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.serializers import Serializer
|
||||
|
||||
|
||||
class SecretType(models.TextChoices):
|
||||
"""How a secret value is entered and displayed."""
|
||||
|
||||
TEXT = "text", _("Text")
|
||||
MULTILINE = "multiline", _("Multi-line text")
|
||||
FILE = "file", _("File")
|
||||
|
||||
|
||||
secret_value_changed = Signal()
|
||||
|
||||
|
||||
def generate_secret_value() -> str:
|
||||
"""Generate a value safe for HTTP Basic authentication and similar protocols."""
|
||||
return generate_id(128)
|
||||
|
||||
|
||||
def create_named_secret(name: str, value: str | None = None) -> Secret:
|
||||
"""Create a secret with a readable, collision-safe name."""
|
||||
for suffix in range(1, 100):
|
||||
candidate = name if suffix == 1 else f"{name} ({suffix})"
|
||||
try:
|
||||
with transaction.atomic():
|
||||
values = {} if value is None else {"value": value}
|
||||
return Secret.objects.create(name=candidate, **values)
|
||||
except IntegrityError:
|
||||
continue
|
||||
raise IntegrityError(f"Could not allocate a name for {name!r}")
|
||||
|
||||
|
||||
class Secret(SerializerModel, ManagedModel, CreatedUpdatedModel):
|
||||
"""A named value that can be shared by configuration objects."""
|
||||
|
||||
secret_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
name = models.TextField(unique=True)
|
||||
type = models.TextField(choices=SecretType.choices, default=SecretType.TEXT)
|
||||
value = models.TextField(default=generate_secret_value)
|
||||
|
||||
def get_value(self, key: str = "value") -> str:
|
||||
"""Return a value through a key-based interface that can grow later."""
|
||||
if key != "value":
|
||||
raise KeyError(key)
|
||||
return self.value
|
||||
|
||||
def replace_value(self, value: str, request: Request | None = None) -> None:
|
||||
"""Replace and audit the value, then notify consumers after commit."""
|
||||
if value == self.value:
|
||||
return
|
||||
from authentik.events.middleware import audit_ignore
|
||||
from authentik.events.models import Event, EventAction
|
||||
|
||||
with transaction.atomic():
|
||||
self.value = value
|
||||
with audit_ignore():
|
||||
self.save(update_fields=["value", "last_updated"])
|
||||
event = Event.new(EventAction.SECRET_ROTATE, secret=self)
|
||||
if request:
|
||||
event.from_http(request)
|
||||
else:
|
||||
event.save()
|
||||
secret_value_changed.send(sender=Secret, secret=self)
|
||||
|
||||
def rotate(self, request: Request | None = None) -> str:
|
||||
"""Generate and store a new text value."""
|
||||
if self.type != SecretType.TEXT:
|
||||
raise ValueError("Only text secrets can be rotated.")
|
||||
value = generate_secret_value()
|
||||
self.replace_value(value, request)
|
||||
return value
|
||||
|
||||
@property
|
||||
def serializer(self) -> type[Serializer]:
|
||||
from authentik.secrets.api import SecretSerializer
|
||||
|
||||
return SecretSerializer
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Secret")
|
||||
verbose_name_plural = _("Secrets")
|
||||
permissions = [
|
||||
("view_secret_value", _("View secret's value")),
|
||||
("rotate_secret", _("Rotate secret's value")),
|
||||
]
|
||||
1
authentik/secrets/tests/__init__.py
Normal file
1
authentik/secrets/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Managed secrets tests."""
|
||||
102
authentik/secrets/tests/test_api.py
Normal file
102
authentik/secrets/tests/test_api.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Managed secret API tests."""
|
||||
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_user
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.providers.oauth2.models import OAuth2Provider
|
||||
from authentik.secrets.models import Secret, SecretType
|
||||
|
||||
|
||||
class TestSecretsAPI(APITestCase):
|
||||
"""Secret values require permissions separate from metadata."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.admin = create_test_admin_user()
|
||||
self.user = create_test_user()
|
||||
self.secret = Secret.objects.create(name="test")
|
||||
|
||||
def test_create_generated(self):
|
||||
self.client.force_login(self.admin)
|
||||
response = self.client.post(reverse("authentik_api:secret-list"), {"name": "created"})
|
||||
self.assertEqual(response.status_code, 201, response.content)
|
||||
secret = Secret.objects.get(name="created")
|
||||
self.assertTrue(secret.value)
|
||||
self.assertNotIn(secret.value, response.content.decode())
|
||||
|
||||
def test_create_explicit(self):
|
||||
self.client.force_login(self.admin)
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:secret-list"),
|
||||
{"name": "external", "value": "provided-value"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 201, response.content)
|
||||
self.assertEqual(Secret.objects.get(name="external").value, "provided-value")
|
||||
|
||||
def test_view_value_permission_and_audit(self):
|
||||
self.user.assign_perms_to_managed_role("authentik_secrets.view_secret", self.secret)
|
||||
self.client.force_login(self.user)
|
||||
url = reverse("authentik_api:secret-view-value", kwargs={"pk": self.secret.pk})
|
||||
self.assertEqual(self.client.get(url).status_code, 403)
|
||||
|
||||
self.user.assign_perms_to_managed_role("authentik_secrets.view_secret_value", self.secret)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), {"value": self.secret.value})
|
||||
self.assertTrue(Event.objects.filter(action=EventAction.SECRET_VIEW).exists())
|
||||
|
||||
def test_rotate_permission_and_disclosure(self):
|
||||
self.user.assign_perms_to_managed_role("authentik_secrets.view_secret", self.secret)
|
||||
self.user.assign_perms_to_managed_role("authentik_secrets.rotate_secret", self.secret)
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:secret-rotate", kwargs={"pk": self.secret.pk})
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIsNone(response.json()["value"])
|
||||
|
||||
def test_replace_value_requires_rotate_permission(self):
|
||||
self.user.assign_perms_to_managed_role("authentik_secrets.view_secret", self.secret)
|
||||
self.user.assign_perms_to_managed_role("authentik_secrets.change_secret", self.secret)
|
||||
self.client.force_login(self.user)
|
||||
url = reverse("authentik_api:secret-detail", kwargs={"pk": self.secret.pk})
|
||||
response = self.client.patch(url, {"value": "replacement"})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.secret.refresh_from_db()
|
||||
self.assertNotEqual(self.secret.value, "replacement")
|
||||
|
||||
self.user.assign_perms_to_managed_role("authentik_secrets.rotate_secret", self.secret)
|
||||
response = self.client.patch(url, {"value": "replacement"})
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
self.secret.refresh_from_db()
|
||||
self.assertEqual(self.secret.value, "replacement")
|
||||
|
||||
def test_file_validation_and_rotation(self):
|
||||
self.client.force_login(self.admin)
|
||||
list_url = reverse("authentik_api:secret-list")
|
||||
response = self.client.post(list_url, {"name": "file", "type": "file"})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
response = self.client.post(
|
||||
list_url, {"name": "file", "type": "file", "value": "not base64"}
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
secret = Secret.objects.create(name="file", type=SecretType.FILE, value="aGk=")
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:secret-rotate", kwargs={"pk": secret.pk})
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_oauth_consumer_requires_ascii_value(self):
|
||||
self.client.force_login(self.admin)
|
||||
secret = Secret.objects.create(name="oauth", value="ascii")
|
||||
OAuth2Provider.objects.create(name="provider", secret=secret)
|
||||
|
||||
response = self.client.patch(
|
||||
reverse("authentik_api:secret-detail", kwargs={"pk": secret.pk}),
|
||||
{"value": "non-ascii-ú"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
secret.refresh_from_db()
|
||||
self.assertEqual(secret.value, "ascii")
|
||||
60
authentik/secrets/tests/test_consumers.py
Normal file
60
authentik/secrets/tests/test_consumers.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Contract tests for models that consume managed secrets."""
|
||||
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from authentik.secrets.models import Secret
|
||||
|
||||
EXPECTED_CONSUMERS = {
|
||||
("authentik_endpoints_connectors_fleet.FleetConnector", "token", "secret"),
|
||||
(
|
||||
"authentik_providers_microsoft_entra.MicrosoftEntraProvider",
|
||||
"client_secret",
|
||||
"secret",
|
||||
),
|
||||
("authentik_events.NotificationTransport", "webhook_url", "secret"),
|
||||
("authentik_providers_oauth2.OAuth2Provider", "client_secret", "secret"),
|
||||
("authentik_providers_radius.RadiusProvider", "shared_secret", "secret"),
|
||||
("authentik_providers_scim.SCIMProvider", "token", "secret"),
|
||||
("authentik_sources_kerberos.KerberosSource", "sync_password", "secret"),
|
||||
("authentik_sources_ldap.LDAPSource", "bind_password", "secret"),
|
||||
("authentik_sources_oauth.OAuthSource", "consumer_secret", "secret"),
|
||||
("authentik_sources_plex.PlexSource", "plex_token", "secret"),
|
||||
("authentik_sources_telegram.TelegramSource", "bot_token", "secret"),
|
||||
("authentik_stages_authenticator_duo.AuthenticatorDuoStage", "client_secret", "secret"),
|
||||
(
|
||||
"authentik_stages_authenticator_duo.AuthenticatorDuoStage",
|
||||
"admin_secret_key",
|
||||
"admin_secret",
|
||||
),
|
||||
("authentik_stages_authenticator_email.AuthenticatorEmailStage", "password", "secret"),
|
||||
("authentik_stages_authenticator_sms.AuthenticatorSMSStage", "auth", "auth_secret"),
|
||||
(
|
||||
"authentik_stages_authenticator_sms.AuthenticatorSMSStage",
|
||||
"auth_password",
|
||||
"auth_password_secret",
|
||||
),
|
||||
("authentik_stages_captcha.CaptchaStage", "private_key", "secret"),
|
||||
("authentik_stages_email.EmailStage", "password", "secret"),
|
||||
}
|
||||
|
||||
|
||||
class TestSecretConsumers(SimpleTestCase):
|
||||
"""Keep migration columns and public API fields aligned across every consumer."""
|
||||
|
||||
def test_consumer_contract(self):
|
||||
for label, legacy_attribute, secret_field in EXPECTED_CONSUMERS:
|
||||
model = apps.get_model(label)
|
||||
retained = model._meta.get_field(f"_{legacy_attribute}")
|
||||
self.assertEqual(retained.db_column, legacy_attribute)
|
||||
|
||||
reference = model._meta.get_field(secret_field)
|
||||
self.assertIs(reference.related_model, Secret)
|
||||
self.assertIs(reference.remote_field.on_delete, models.PROTECT)
|
||||
|
||||
fields = model().serializer().fields
|
||||
self.assertIn(secret_field, fields)
|
||||
self.assertNotIn(legacy_attribute, fields)
|
||||
self.assertNotIn(f"_{legacy_attribute}", fields)
|
||||
self.assertNotIn(legacy_attribute, model.__dict__)
|
||||
41
authentik/secrets/tests/test_models.py
Normal file
41
authentik/secrets/tests/test_models.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Managed secret model tests."""
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.secrets.models import Secret, SecretType, create_named_secret
|
||||
|
||||
|
||||
class TestSecret(TestCase):
|
||||
"""Managed secret behavior."""
|
||||
|
||||
def test_rotate(self):
|
||||
secret = Secret.objects.create(name="test")
|
||||
previous = secret.value
|
||||
|
||||
value = secret.rotate()
|
||||
|
||||
secret.refresh_from_db()
|
||||
self.assertEqual(secret.value, value)
|
||||
self.assertNotEqual(value, previous)
|
||||
self.assertEqual(len(value), 128)
|
||||
event = Event.objects.get(action=EventAction.SECRET_ROTATE)
|
||||
self.assertEqual(event.context["secret"]["pk"], secret.pk.hex)
|
||||
self.assertNotIn(value, str(event.context))
|
||||
|
||||
def test_get_value_is_keyed(self):
|
||||
secret = Secret.objects.create(name="test", value="value")
|
||||
self.assertEqual(secret.get_value(), "value")
|
||||
with self.assertRaises(KeyError):
|
||||
secret.get_value("missing")
|
||||
|
||||
def test_non_text_cannot_rotate(self):
|
||||
secret = Secret.objects.create(name="file", type=SecretType.FILE, value="aGk=")
|
||||
with self.assertRaises(ValueError):
|
||||
secret.rotate()
|
||||
|
||||
def test_collision_safe_name(self):
|
||||
first = create_named_secret("consumer")
|
||||
second = create_named_secret("consumer")
|
||||
self.assertEqual(first.name, "consumer")
|
||||
self.assertEqual(second.name, "consumer (2)")
|
||||
9
authentik/secrets/tests/utils.py
Normal file
9
authentik/secrets/tests/utils.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Helpers for models that reference secrets in tests."""
|
||||
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.secrets.models import Secret
|
||||
|
||||
|
||||
def create_test_secret(value: str) -> Secret:
|
||||
"""Create a uniquely named text secret with a known value."""
|
||||
return Secret.objects.create(name=generate_id(), value=value)
|
||||
5
authentik/secrets/urls.py
Normal file
5
authentik/secrets/urls.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Managed secrets API URLs."""
|
||||
|
||||
from authentik.secrets.api import SecretViewSet
|
||||
|
||||
api_urlpatterns = [("secrets/secrets", SecretViewSet)]
|
||||
@@ -36,7 +36,7 @@ class KerberosSourceSerializer(SourceSerializer):
|
||||
"sync_users",
|
||||
"sync_users_password",
|
||||
"sync_principal",
|
||||
"sync_password",
|
||||
"secret",
|
||||
"sync_keytab",
|
||||
"sync_ccache",
|
||||
"connectivity",
|
||||
@@ -47,7 +47,6 @@ class KerberosSourceSerializer(SourceSerializer):
|
||||
"sync_outgoing_trigger_mode",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"sync_password": {"write_only": True},
|
||||
"sync_keytab": {"write_only": True},
|
||||
"spnego_keytab": {"write_only": True},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# Generated by Django 5.2.17 on 2026-08-30 21:32
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def migrate_sync_password(apps, schema_editor):
|
||||
"""Move each source's sync_password into a Secret object"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
KerberosSource = apps.get_model("authentik_sources_kerberos", "KerberosSource")
|
||||
Secret = apps.get_model("authentik_secrets", "Secret")
|
||||
names = set(Secret.objects.using(db_alias).values_list("name", flat=True))
|
||||
for source in KerberosSource.objects.using(db_alias).exclude(_sync_password=""):
|
||||
base = f"{source.name} sync password"
|
||||
name, idx = base, 2
|
||||
while name in names:
|
||||
name = f"{base} ({idx})"
|
||||
idx += 1
|
||||
names.add(name)
|
||||
source.secret = Secret.objects.using(db_alias).create(
|
||||
name=name,
|
||||
value=source._sync_password,
|
||||
type="multiline" if "\n" in source._sync_password else "text",
|
||||
)
|
||||
source.save(update_fields=["secret"])
|
||||
|
||||
|
||||
def rollback_sync_password(apps, schema_editor):
|
||||
"""Copy secret values back into the legacy column, so a downgrade keeps values
|
||||
set or rotated after the upgrade"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
KerberosSource = apps.get_model("authentik_sources_kerberos", "KerberosSource")
|
||||
for source in (
|
||||
KerberosSource.objects.using(db_alias).exclude(secret=None).select_related("secret")
|
||||
):
|
||||
source._sync_password = source.secret.value
|
||||
source.save(update_fields=["_sync_password"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_secrets", "0001_initial"),
|
||||
("authentik_sources_kerberos", "0005_alter_kerberossource_kadmin_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="kerberossource",
|
||||
name="sync_password",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
db_column="sync_password",
|
||||
help_text="Password to authenticate to kadmin for sync",
|
||||
),
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="kerberossource",
|
||||
old_name="sync_password",
|
||||
new_name="_sync_password",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="kerberossource",
|
||||
name="secret",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="Password to authenticate to kadmin for sync",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="kerberos_sources",
|
||||
to="authentik_secrets.secret",
|
||||
verbose_name="Sync password",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_sync_password, rollback_sync_password),
|
||||
]
|
||||
@@ -68,8 +68,20 @@ class KerberosSource(IncomingSyncSource):
|
||||
sync_principal = models.TextField(
|
||||
help_text=_("Principal to authenticate to kadmin for sync."), blank=True
|
||||
)
|
||||
sync_password = models.TextField(
|
||||
help_text=_("Password to authenticate to kadmin for sync"), blank=True
|
||||
secret = models.ForeignKey(
|
||||
"authentik_secrets.Secret",
|
||||
verbose_name=_("Sync password"),
|
||||
help_text=_("Password to authenticate to kadmin for sync"),
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
default=None,
|
||||
related_name="kerberos_sources",
|
||||
)
|
||||
_sync_password = models.TextField(
|
||||
help_text=_("Password to authenticate to kadmin for sync"),
|
||||
blank=True,
|
||||
db_column="sync_password",
|
||||
)
|
||||
sync_keytab = models.TextField(
|
||||
help_text=_(
|
||||
@@ -253,11 +265,11 @@ class KerberosSource(IncomingSyncSource):
|
||||
# as such, we don't need to create a separate ccache for each source
|
||||
if not self.sync_principal:
|
||||
return None
|
||||
if self.sync_password:
|
||||
if self.secret:
|
||||
return KAdmin.with_password(
|
||||
variant,
|
||||
self.sync_principal,
|
||||
self.sync_password,
|
||||
self.secret.get_value(),
|
||||
api_version=api_version,
|
||||
)
|
||||
if self.sync_keytab:
|
||||
|
||||
@@ -4,6 +4,7 @@ from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.models import Session, User
|
||||
from authentik.core.tests.utils import create_test_session
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.secrets.tests.utils import create_test_secret
|
||||
from authentik.sources.kerberos.models import KerberosSource, KerberosSourcePropertyMapping
|
||||
from authentik.sources.kerberos.sync import KerberosSync
|
||||
from authentik.sources.kerberos.tasks import kerberos_sync
|
||||
@@ -23,7 +24,7 @@ class TestKerberosSync(KerberosTestCase):
|
||||
sync_users=True,
|
||||
sync_users_password=True,
|
||||
sync_principal=self.realm.admin_princ,
|
||||
sync_password=self.realm.password("admin"),
|
||||
secret=create_test_secret(self.realm.password("admin")),
|
||||
)
|
||||
self.source.user_property_mappings.set(
|
||||
KerberosSourcePropertyMapping.objects.filter(
|
||||
|
||||
@@ -83,7 +83,7 @@ class LDAPSourceSerializer(SourceSerializer):
|
||||
"peer_certificate",
|
||||
"client_certificate",
|
||||
"bind_cn",
|
||||
"bind_password",
|
||||
"secret",
|
||||
"start_tls",
|
||||
"sni",
|
||||
"base_dn",
|
||||
@@ -105,7 +105,6 @@ class LDAPSourceSerializer(SourceSerializer):
|
||||
"sync_outgoing_trigger_mode",
|
||||
"sync_group_hierarchy",
|
||||
]
|
||||
extra_kwargs = {"bind_password": {"write_only": True}}
|
||||
|
||||
|
||||
class LDAPSourceViewSet(UsedByMixin, ModelViewSet):
|
||||
|
||||
70
authentik/sources/ldap/migrations/0013_ldapsource_secret.py
Normal file
70
authentik/sources/ldap/migrations/0013_ldapsource_secret.py
Normal file
@@ -0,0 +1,70 @@
|
||||
# Generated by Django 5.2.17 on 2026-08-30 21:31
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def migrate_bind_password(apps, schema_editor):
|
||||
"""Move each source's bind_password into a Secret object"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
LDAPSource = apps.get_model("authentik_sources_ldap", "LDAPSource")
|
||||
Secret = apps.get_model("authentik_secrets", "Secret")
|
||||
names = set(Secret.objects.using(db_alias).values_list("name", flat=True))
|
||||
for source in LDAPSource.objects.using(db_alias).exclude(_bind_password=""):
|
||||
base = f"{source.name} bind password"
|
||||
name, idx = base, 2
|
||||
while name in names:
|
||||
name = f"{base} ({idx})"
|
||||
idx += 1
|
||||
names.add(name)
|
||||
source.secret = Secret.objects.using(db_alias).create(
|
||||
name=name,
|
||||
value=source._bind_password,
|
||||
type="multiline" if "\n" in source._bind_password else "text",
|
||||
)
|
||||
source.save(update_fields=["secret"])
|
||||
|
||||
|
||||
def rollback_bind_password(apps, schema_editor):
|
||||
"""Copy secret values back into the legacy column, so a downgrade keeps values
|
||||
set or rotated after the upgrade"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
LDAPSource = apps.get_model("authentik_sources_ldap", "LDAPSource")
|
||||
for source in LDAPSource.objects.using(db_alias).exclude(secret=None).select_related("secret"):
|
||||
source._bind_password = source.secret.value
|
||||
source.save(update_fields=["_bind_password"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_secrets", "0001_initial"),
|
||||
("authentik_sources_ldap", "0012_ldapsource_sync_group_parents"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="ldapsource",
|
||||
name="bind_password",
|
||||
field=models.TextField(blank=True, db_column="bind_password"),
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="ldapsource",
|
||||
old_name="bind_password",
|
||||
new_name="_bind_password",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ldapsource",
|
||||
name="secret",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="ldap_sources",
|
||||
to="authentik_secrets.secret",
|
||||
verbose_name="Bind password",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_bind_password, rollback_bind_password),
|
||||
]
|
||||
@@ -91,7 +91,16 @@ class LDAPSource(IncomingSyncSource):
|
||||
)
|
||||
|
||||
bind_cn = models.TextField(verbose_name=_("Bind CN"), blank=True)
|
||||
bind_password = models.TextField(blank=True)
|
||||
secret = models.ForeignKey(
|
||||
"authentik_secrets.Secret",
|
||||
verbose_name=_("Bind password"),
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
default=None,
|
||||
related_name="ldap_sources",
|
||||
)
|
||||
_bind_password = models.TextField(blank=True, db_column="bind_password")
|
||||
start_tls = models.BooleanField(default=False, verbose_name=_("Enable Start TLS"))
|
||||
sni = models.BooleanField(default=False, verbose_name=_("Use Server URI for SNI verification"))
|
||||
|
||||
@@ -272,8 +281,8 @@ class LDAPSource(IncomingSyncSource):
|
||||
connection_kwargs = connection_kwargs or {}
|
||||
if self.bind_cn is not None:
|
||||
connection_kwargs.setdefault("user", self.bind_cn)
|
||||
if self.bind_password is not None:
|
||||
connection_kwargs.setdefault("password", self.bind_password)
|
||||
if self.secret:
|
||||
connection_kwargs.setdefault("password", self.secret.get_value())
|
||||
conn = Connection(
|
||||
server or self.server(**server_kwargs),
|
||||
raise_exceptions=True,
|
||||
|
||||
@@ -11,6 +11,8 @@ from rest_framework.test import APITestCase
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_user
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.secrets.models import Secret
|
||||
from authentik.secrets.tests.utils import create_test_secret
|
||||
from authentik.sources.ldap.api.sources import LDAPSourceSerializer
|
||||
from authentik.sources.ldap.models import LDAPSource, LDAPSourcePropertyMapping
|
||||
from authentik.sources.ldap.tests.mock_ad import mock_ad_connection
|
||||
@@ -27,7 +29,7 @@ class LDAPAPITests(APITestCase):
|
||||
"slug": " foo",
|
||||
"server_uri": "ldaps://1.2.3.4",
|
||||
"bind_cn": "",
|
||||
"bind_password": generate_id(),
|
||||
"secret": Secret.objects.create(name=generate_id()).pk,
|
||||
"base_dn": "dc=foo",
|
||||
"sync_users_password": True,
|
||||
}
|
||||
@@ -42,7 +44,7 @@ class LDAPAPITests(APITestCase):
|
||||
slug=generate_id(),
|
||||
server_uri="ldaps://1.2.3.4",
|
||||
bind_cn="",
|
||||
bind_password=generate_id(),
|
||||
secret=create_test_secret(generate_id()),
|
||||
base_dn="dc=foo",
|
||||
sync_users_password=True,
|
||||
)
|
||||
@@ -52,7 +54,7 @@ class LDAPAPITests(APITestCase):
|
||||
"slug": generate_id(),
|
||||
"server_uri": "ldaps://1.2.3.4",
|
||||
"bind_cn": "",
|
||||
"bind_password": generate_id(),
|
||||
"secret": Secret.objects.create(name=generate_id()).pk,
|
||||
"base_dn": "dc=foo",
|
||||
"sync_users_password": True,
|
||||
}
|
||||
@@ -78,7 +80,7 @@ class LDAPAPITests(APITestCase):
|
||||
"slug": " foo",
|
||||
"server_uri": "ldaps://1.2.3.4",
|
||||
"bind_cn": "",
|
||||
"bind_password": generate_id(),
|
||||
"secret": Secret.objects.create(name=generate_id()).pk,
|
||||
"base_dn": "dc=foo",
|
||||
"sync_users": True,
|
||||
"user_property_mappings": [],
|
||||
@@ -94,7 +96,7 @@ class LDAPAPITests(APITestCase):
|
||||
"slug": " foo",
|
||||
"server_uri": "ldaps://1.2.3.4",
|
||||
"bind_cn": "",
|
||||
"bind_password": generate_id(),
|
||||
"secret": Secret.objects.create(name=generate_id()).pk,
|
||||
"base_dn": "dc=foo",
|
||||
"sync_groups": True,
|
||||
"group_property_mappings": [],
|
||||
|
||||
@@ -140,7 +140,7 @@ class OAuthSourceSerializer(SourceSerializer):
|
||||
"profile_url",
|
||||
"pkce",
|
||||
"consumer_key",
|
||||
"consumer_secret",
|
||||
"secret",
|
||||
"callback_url",
|
||||
"additional_scopes",
|
||||
"type",
|
||||
@@ -150,7 +150,7 @@ class OAuthSourceSerializer(SourceSerializer):
|
||||
"authorization_code_auth_method",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"consumer_secret": {"write_only": True},
|
||||
"secret": {"required": True, "allow_null": False},
|
||||
"request_token_url": {"allow_blank": True},
|
||||
"authorization_url": {"allow_blank": True},
|
||||
"access_token_url": {"allow_blank": True},
|
||||
|
||||
@@ -101,7 +101,7 @@ class OAuthClient(BaseOAuthClient):
|
||||
resource_owner_key=resource_owner_key,
|
||||
resource_owner_secret=resource_owner_secret,
|
||||
client_key=self.source.consumer_key,
|
||||
client_secret=self.source.consumer_secret,
|
||||
client_secret=self.source.secret.get_value(),
|
||||
verifier=verifier,
|
||||
callback_uri=callback,
|
||||
)
|
||||
|
||||
@@ -60,7 +60,7 @@ class OAuth2Client(BaseOAuthClient):
|
||||
|
||||
def get_client_secret(self) -> str:
|
||||
"""Get client secret"""
|
||||
return self.source.consumer_secret
|
||||
return self.source.secret.get_value()
|
||||
|
||||
def get_access_token_args(self, callback: str | None, code: str | None) -> dict[str, Any]:
|
||||
args = {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Generated by Django 5.2.17 on 2026-08-30 21:31
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def migrate_consumer_secret(apps, schema_editor):
|
||||
"""Move each source's consumer_secret into a Secret object"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
OAuthSource = apps.get_model("authentik_sources_oauth", "OAuthSource")
|
||||
Secret = apps.get_model("authentik_secrets", "Secret")
|
||||
names = set(Secret.objects.using(db_alias).values_list("name", flat=True))
|
||||
for source in OAuthSource.objects.using(db_alias).exclude(_consumer_secret=""):
|
||||
base = f"{source.name} consumer secret"
|
||||
name, idx = base, 2
|
||||
while name in names:
|
||||
name = f"{base} ({idx})"
|
||||
idx += 1
|
||||
names.add(name)
|
||||
source.secret = Secret.objects.using(db_alias).create(
|
||||
name=name,
|
||||
value=source._consumer_secret,
|
||||
type="multiline" if "\n" in source._consumer_secret else "text",
|
||||
)
|
||||
source.save(update_fields=["secret"])
|
||||
|
||||
|
||||
def rollback_consumer_secret(apps, schema_editor):
|
||||
"""Copy secret values back into the legacy column, so a downgrade keeps values
|
||||
set or rotated after the upgrade"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
OAuthSource = apps.get_model("authentik_sources_oauth", "OAuthSource")
|
||||
for source in OAuthSource.objects.using(db_alias).exclude(secret=None).select_related("secret"):
|
||||
source._consumer_secret = source.secret.value
|
||||
source.save(update_fields=["_consumer_secret"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_secrets", "0001_initial"),
|
||||
("authentik_sources_oauth", "0015_oauthsource_url_textfields"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="oauthsource",
|
||||
name="consumer_secret",
|
||||
field=models.TextField(db_column="consumer_secret"),
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="oauthsource",
|
||||
old_name="consumer_secret",
|
||||
new_name="_consumer_secret",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="oauthsource",
|
||||
name="secret",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="oauth_sources",
|
||||
to="authentik_secrets.secret",
|
||||
verbose_name="Consumer secret",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_consumer_secret, rollback_consumer_secret),
|
||||
]
|
||||
@@ -63,8 +63,16 @@ class OAuthSource(NonCreatableType, Source):
|
||||
default="", blank=True, verbose_name=_("Additional Scopes")
|
||||
)
|
||||
consumer_key = models.TextField()
|
||||
consumer_secret = models.TextField()
|
||||
|
||||
secret = models.ForeignKey(
|
||||
"authentik_secrets.Secret",
|
||||
verbose_name=_("Consumer secret"),
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
default=None,
|
||||
related_name="oauth_sources",
|
||||
)
|
||||
_consumer_secret = models.TextField(db_column="consumer_secret")
|
||||
oidc_well_known_url = models.TextField(default="", blank=True)
|
||||
oidc_jwks_url = models.TextField(default="", blank=True)
|
||||
oidc_jwks = models.JSONField(default=dict, blank=True)
|
||||
|
||||
@@ -2,6 +2,7 @@ from django.test import RequestFactory, TestCase
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.secrets.tests.utils import create_test_secret
|
||||
from authentik.sources.oauth.clients.oauth2 import OAuth2Client
|
||||
from authentik.sources.oauth.models import AuthorizationCodeAuthMethod, OAuthSource
|
||||
from authentik.sources.oauth.types.oidc import OpenIDConnectClient
|
||||
@@ -18,6 +19,7 @@ class TestOAuthClient(TestCase):
|
||||
authorization_url="",
|
||||
profile_url="",
|
||||
consumer_key=generate_id(),
|
||||
secret=create_test_secret(generate_id()),
|
||||
)
|
||||
self.factory = RequestFactory()
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from jwt import decode
|
||||
from authentik.core.tests.utils import RequestFactory, create_test_cert
|
||||
from authentik.crypto.builder import PrivateKeyAlg
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.secrets.tests.utils import create_test_secret
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
from authentik.sources.oauth.types.apple import AppleOAuthClient
|
||||
from authentik.sources.oauth.types.registry import registry
|
||||
@@ -27,7 +28,7 @@ class TestTypeApple(TestCase):
|
||||
authorization_url="",
|
||||
profile_url="",
|
||||
consumer_key=f"{self.service_identifier};{self.team_id};{self.key_id}",
|
||||
consumer_secret=self.kp.key_data,
|
||||
secret=create_test_secret(self.kp.key_data),
|
||||
)
|
||||
self.factory = RequestFactory()
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.utils import pkce_s256_challenge
|
||||
from authentik.secrets.models import Secret
|
||||
from authentik.sources.oauth.api.source import OAuthSourceSerializer
|
||||
from authentik.sources.oauth.clients.oauth2 import SESSION_KEY_OAUTH_PKCE
|
||||
from authentik.sources.oauth.models import OAuthSource, PKCEMethod
|
||||
@@ -53,7 +54,7 @@ class TestOAuthSource(APITestCase):
|
||||
"slug": "bar",
|
||||
"provider_type": "google",
|
||||
"consumer_key": "foo",
|
||||
"consumer_secret": "foo",
|
||||
"secret": Secret.objects.create(name=generate_id()).pk,
|
||||
"oidc_well_known_url": "",
|
||||
"oidc_jwks_url": "",
|
||||
}
|
||||
@@ -66,7 +67,7 @@ class TestOAuthSource(APITestCase):
|
||||
"slug": "bar",
|
||||
"provider_type": "openidconnect",
|
||||
"consumer_key": "foo",
|
||||
"consumer_secret": "foo",
|
||||
"secret": Secret.objects.create(name=generate_id()).pk,
|
||||
}
|
||||
).is_valid()
|
||||
)
|
||||
@@ -93,7 +94,7 @@ class TestOAuthSource(APITestCase):
|
||||
"slug": "bar",
|
||||
"provider_type": "openidconnect",
|
||||
"consumer_key": "foo",
|
||||
"consumer_secret": "foo",
|
||||
"secret": Secret.objects.create(name=generate_id()).pk,
|
||||
"oidc_well_known_url": url,
|
||||
"oidc_jwks_url": "",
|
||||
},
|
||||
@@ -125,7 +126,7 @@ class TestOAuthSource(APITestCase):
|
||||
"slug": "bar",
|
||||
"provider_type": "openidconnect",
|
||||
"consumer_key": "foo",
|
||||
"consumer_secret": "foo",
|
||||
"secret": Secret.objects.create(name=generate_id()).pk,
|
||||
"authorization_url": "http://foo",
|
||||
"access_token_url": "http://foo",
|
||||
"profile_url": "http://foo",
|
||||
|
||||
@@ -69,7 +69,7 @@ class AppleOAuthClient(OpenIDConnectClient):
|
||||
"aud": "https://appleid.apple.com",
|
||||
"sub": parts[0].strip(),
|
||||
}
|
||||
jwt = encode(payload, self.source.consumer_secret, "ES256", {"kid": parts[2].strip()})
|
||||
jwt = encode(payload, self.source.secret.get_value(), "ES256", {"kid": parts[2].strip()})
|
||||
self.logger.debug("signing payload as secret key", payload=payload, jwt=jwt)
|
||||
return jwt
|
||||
|
||||
|
||||
@@ -36,8 +36,11 @@ class PlexSourceSerializer(SourceSerializer):
|
||||
"client_id",
|
||||
"allowed_servers",
|
||||
"allow_friends",
|
||||
"plex_token",
|
||||
"secret",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"secret": {"required": True, "allow_null": False},
|
||||
}
|
||||
|
||||
|
||||
class PlexTokenRedeemSerializer(PassiveSerializer):
|
||||
@@ -106,7 +109,7 @@ class PlexSourceViewSet(UsedByMixin, ModelViewSet):
|
||||
# Check friendship first, then check server overlay
|
||||
friends_allowed = False
|
||||
if source.allow_friends:
|
||||
owner_api = PlexAuth(source, source.plex_token)
|
||||
owner_api = PlexAuth(source, source.secret.get_value())
|
||||
friends_allowed = owner_api.check_friends_overlap(identifier)
|
||||
servers_allowed = auth_api.check_server_overlap()
|
||||
if any([friends_allowed, servers_allowed]):
|
||||
@@ -165,7 +168,7 @@ class PlexSourceViewSet(UsedByMixin, ModelViewSet):
|
||||
# Check friendship first, then check server overlay
|
||||
friends_allowed = False
|
||||
if source.allow_friends:
|
||||
owner_api = PlexAuth(source, source.plex_token)
|
||||
owner_api = PlexAuth(source, source.secret.get_value())
|
||||
friends_allowed = owner_api.check_friends_overlap(identifier)
|
||||
servers_allowed = auth_api.check_server_overlap()
|
||||
if any([friends_allowed, servers_allowed]):
|
||||
|
||||
73
authentik/sources/plex/migrations/0007_plexsource_secret.py
Normal file
73
authentik/sources/plex/migrations/0007_plexsource_secret.py
Normal file
@@ -0,0 +1,73 @@
|
||||
# Generated by Django 5.2.17 on 2026-08-30 21:31
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def migrate_plex_token(apps, schema_editor):
|
||||
"""Move each source's plex_token into a Secret object"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
PlexSource = apps.get_model("authentik_sources_plex", "PlexSource")
|
||||
Secret = apps.get_model("authentik_secrets", "Secret")
|
||||
names = set(Secret.objects.using(db_alias).values_list("name", flat=True))
|
||||
for source in PlexSource.objects.using(db_alias).exclude(_plex_token=""):
|
||||
base = f"{source.name} Plex token"
|
||||
name, idx = base, 2
|
||||
while name in names:
|
||||
name = f"{base} ({idx})"
|
||||
idx += 1
|
||||
names.add(name)
|
||||
source.secret = Secret.objects.using(db_alias).create(
|
||||
name=name,
|
||||
value=source._plex_token,
|
||||
type="multiline" if "\n" in source._plex_token else "text",
|
||||
)
|
||||
source.save(update_fields=["secret"])
|
||||
|
||||
|
||||
def rollback_plex_token(apps, schema_editor):
|
||||
"""Copy secret values back into the legacy column, so a downgrade keeps values
|
||||
set or rotated after the upgrade"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
PlexSource = apps.get_model("authentik_sources_plex", "PlexSource")
|
||||
for source in PlexSource.objects.using(db_alias).exclude(secret=None).select_related("secret"):
|
||||
source._plex_token = source.secret.value
|
||||
source.save(update_fields=["_plex_token"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_secrets", "0001_initial"),
|
||||
("authentik_sources_plex", "0006_migrate_groupplexsourceconnection"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="plexsource",
|
||||
name="plex_token",
|
||||
field=models.TextField(
|
||||
db_column="plex_token", help_text="Plex token used to check friends"
|
||||
),
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="plexsource",
|
||||
old_name="plex_token",
|
||||
new_name="_plex_token",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="plexsource",
|
||||
name="secret",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="Plex token used to check friends",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="plex_sources",
|
||||
to="authentik_secrets.secret",
|
||||
verbose_name="Plex token",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_plex_token, rollback_plex_token),
|
||||
]
|
||||
@@ -59,7 +59,19 @@ class PlexSource(ScheduledModel, Source):
|
||||
default=True,
|
||||
help_text=_("Allow friends to authenticate, even if you don't share a server."),
|
||||
)
|
||||
plex_token = models.TextField(help_text=_("Plex token used to check friends"))
|
||||
secret = models.ForeignKey(
|
||||
"authentik_secrets.Secret",
|
||||
verbose_name=_("Plex token"),
|
||||
help_text=_("Plex token used to check friends"),
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
default=None,
|
||||
related_name="plex_sources",
|
||||
)
|
||||
_plex_token = models.TextField(
|
||||
help_text=_("Plex token used to check friends"), db_column="plex_token"
|
||||
)
|
||||
|
||||
@property
|
||||
def component(self) -> str:
|
||||
|
||||
@@ -19,14 +19,15 @@ def check_plex_token(source_pk: str):
|
||||
if not sources.exists():
|
||||
return
|
||||
source: PlexSource = sources.first()
|
||||
auth = PlexAuth(source, source.plex_token)
|
||||
plex_token = source.secret.get_value()
|
||||
auth = PlexAuth(source, plex_token)
|
||||
try:
|
||||
auth.get_user_info()
|
||||
self.info("Plex token is valid.")
|
||||
except RequestException as exc:
|
||||
error = exception_to_string(exc)
|
||||
if len(source.plex_token) > 0:
|
||||
error = error.replace(source.plex_token, "$PLEX_TOKEN")
|
||||
if plex_token:
|
||||
error = error.replace(plex_token, "$PLEX_TOKEN")
|
||||
self.error("Plex token is invalid/an error occurred")
|
||||
self.error(error)
|
||||
Event.new(
|
||||
|
||||
@@ -19,12 +19,12 @@ class TelegramSourceSerializer(SourceSerializer):
|
||||
model = TelegramSource
|
||||
fields = SourceSerializer.Meta.fields + [
|
||||
"bot_username",
|
||||
"bot_token",
|
||||
"secret",
|
||||
"request_message_access",
|
||||
"pre_authentication_flow",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"bot_token": {"write_only": True},
|
||||
"secret": {"required": True, "allow_null": False},
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ class TelegramSourceViewSet(UsedByMixin, ModelViewSet):
|
||||
def connect_user(self, request: Request, slug: str) -> Response:
|
||||
|
||||
source: TelegramSource = get_object_or_404(TelegramSource, slug=slug)
|
||||
serializer = TelegramAuthSerializer(bot_token=source.bot_token, data=request.data)
|
||||
serializer = TelegramAuthSerializer(bot_token=source.secret.get_value(), data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
connection, created = UserTelegramSourceConnection.objects.get_or_create(
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Generated by Django 5.2.17 on 2026-08-30 21:31
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def migrate_bot_token(apps, schema_editor):
|
||||
"""Move each source's bot_token into a Secret object"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
TelegramSource = apps.get_model("authentik_sources_telegram", "TelegramSource")
|
||||
Secret = apps.get_model("authentik_secrets", "Secret")
|
||||
names = set(Secret.objects.using(db_alias).values_list("name", flat=True))
|
||||
for source in TelegramSource.objects.using(db_alias).exclude(_bot_token=""):
|
||||
base = f"{source.name} bot token"
|
||||
name, idx = base, 2
|
||||
while name in names:
|
||||
name = f"{base} ({idx})"
|
||||
idx += 1
|
||||
names.add(name)
|
||||
source.secret = Secret.objects.using(db_alias).create(
|
||||
name=name,
|
||||
value=source._bot_token,
|
||||
type="multiline" if "\n" in source._bot_token else "text",
|
||||
)
|
||||
source.save(update_fields=["secret"])
|
||||
|
||||
|
||||
def rollback_bot_token(apps, schema_editor):
|
||||
"""Copy secret values back into the legacy column, so a downgrade keeps values
|
||||
set or rotated after the upgrade"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
TelegramSource = apps.get_model("authentik_sources_telegram", "TelegramSource")
|
||||
for source in (
|
||||
TelegramSource.objects.using(db_alias).exclude(secret=None).select_related("secret")
|
||||
):
|
||||
source._bot_token = source.secret.value
|
||||
source.save(update_fields=["_bot_token"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_secrets", "0001_initial"),
|
||||
("authentik_sources_telegram", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="telegramsource",
|
||||
name="bot_token",
|
||||
field=models.TextField(db_column="bot_token", help_text="Telegram bot token"),
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="telegramsource",
|
||||
old_name="bot_token",
|
||||
new_name="_bot_token",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="telegramsource",
|
||||
name="secret",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="Telegram bot token",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="telegram_sources",
|
||||
to="authentik_secrets.secret",
|
||||
verbose_name="Bot token",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_bot_token, rollback_bot_token),
|
||||
]
|
||||
@@ -25,8 +25,17 @@ class TelegramSource(Source):
|
||||
"""Log in with Telegram."""
|
||||
|
||||
bot_username = models.TextField(help_text=_("Telegram bot username"))
|
||||
bot_token = models.TextField(help_text=_("Telegram bot token"))
|
||||
|
||||
secret = models.ForeignKey(
|
||||
"authentik_secrets.Secret",
|
||||
verbose_name=_("Bot token"),
|
||||
help_text=_("Telegram bot token"),
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
default=None,
|
||||
related_name="telegram_sources",
|
||||
)
|
||||
_bot_token = models.TextField(help_text=_("Telegram bot token"), db_column="bot_token")
|
||||
request_message_access = models.BooleanField(
|
||||
default=False, help_text=_("Request access to send messages from your bot.")
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@ class TelegramChallengeResponse(TelegramAuth, ChallengeResponse):
|
||||
component = CharField(default="ak-source-telegram")
|
||||
|
||||
def get_bot_token(self) -> str:
|
||||
return self.stage.source.bot_token
|
||||
return self.stage.source.secret.get_value()
|
||||
|
||||
def validate(self, attrs: dict) -> dict:
|
||||
attrs_to_check = attrs.copy()
|
||||
|
||||
@@ -15,6 +15,7 @@ from authentik.flows.planner import PLAN_CONTEXT_REDIRECT, FlowPlan
|
||||
from authentik.flows.tests import FlowTestCase
|
||||
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.secrets.tests.utils import create_test_secret
|
||||
from authentik.sources.telegram.models import UserTelegramSourceConnection
|
||||
from authentik.sources.telegram.stage import TelegramChallengeResponse
|
||||
from authentik.stages.identification.models import IdentificationStage, UserFields
|
||||
@@ -24,7 +25,7 @@ class MockTelegramResponseMixin:
|
||||
def _add_hash(self, response):
|
||||
to_hash = "\n".join([f"{key}={value}" for key, value in sorted(response.items())])
|
||||
response["hash"] = hmac.new(
|
||||
hashlib.sha256(self.source.bot_token.encode("utf-8")).digest(),
|
||||
hashlib.sha256(self.source.secret.get_value().encode("utf-8")).digest(),
|
||||
to_hash.encode("utf-8"),
|
||||
"sha256",
|
||||
).hexdigest()
|
||||
@@ -57,7 +58,7 @@ class TestTelegramSource(MockTelegramResponseMixin, TestCase):
|
||||
name="test",
|
||||
slug="test",
|
||||
bot_username="test_bot",
|
||||
bot_token="modern_token", # nosec
|
||||
secret=create_test_secret("modern_token"), # nosec
|
||||
request_message_access=True,
|
||||
pre_authentication_flow=create_test_flow(),
|
||||
)
|
||||
@@ -128,7 +129,7 @@ class TestTelegramViews(MockTelegramResponseMixin, FlowTestCase):
|
||||
name="test",
|
||||
slug="test",
|
||||
bot_username="test_bot",
|
||||
bot_token="modern_token", # nosec
|
||||
secret=create_test_secret("modern_token"), # nosec
|
||||
request_message_access=True,
|
||||
enrollment_flow=create_test_flow(),
|
||||
pre_authentication_flow=self.pre_auth_flow,
|
||||
|
||||
@@ -41,14 +41,13 @@ class AuthenticatorDuoStageSerializer(StageSerializer):
|
||||
"configure_flow",
|
||||
"friendly_name",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"secret",
|
||||
"api_hostname",
|
||||
"admin_integration_key",
|
||||
"admin_secret_key",
|
||||
"admin_secret",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"client_secret": {"write_only": True},
|
||||
"admin_secret_key": {"write_only": True},
|
||||
"secret": {"required": True, "allow_null": False},
|
||||
}
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user