This commit is contained in:
Dominic R
2026-08-30 21:17:59 -04:00
parent 5408431aa0
commit 420c3e36bc
239 changed files with 6331 additions and 939 deletions

View File

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

View File

@@ -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},
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
},

View File

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

View File

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

View File

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

View File

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

View 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."]},
)

View File

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

View File

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

View File

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

View File

@@ -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,
}

View File

@@ -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,
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@

View 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())

View File

@@ -37,7 +37,7 @@ class SCIMProviderSerializer(
"meta_model_name",
"url",
"verify_certificates",
"token",
"secret",
"auth_mode",
"auth_oauth",
"auth_oauth_params",

View File

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

View 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_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),
]

View File

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

View File

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

View File

@@ -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(),

View File

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

View File

@@ -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(),

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
"""Managed secrets."""

147
authentik/secrets/api.py Normal file
View 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
View 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

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

View File

@@ -0,0 +1 @@
"""Managed secrets migrations."""

103
authentik/secrets/models.py Normal file
View 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")),
]

View File

@@ -0,0 +1 @@
"""Managed secrets tests."""

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

View 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__)

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

View 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)

View File

@@ -0,0 +1,5 @@
"""Managed secrets API URLs."""
from authentik.secrets.api import SecretViewSet
api_urlpatterns = [("secrets/secrets", SecretViewSet)]

View File

@@ -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},
}

View File

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

View File

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

View File

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

View File

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

View 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),
]

View File

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

View File

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

View File

@@ -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},

View File

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

View File

@@ -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 = {

View 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_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),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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]):

View 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),
]

View File

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

View File

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

View File

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

View 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_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),
]

View File

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

View File

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

View File

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

View File

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