From 2e560820660f83df983a4d72dd5fc4bc5239c9b3 Mon Sep 17 00:00:00 2001 From: "Jens L." Date: Tue, 23 Sep 2025 17:52:02 +0200 Subject: [PATCH] enterprise/providers/scim: Add SCIM OAuth support (#16903) * sources/oauth: add expires field to user source connection Signed-off-by: Jens Langhammer * providers/scim: add support for other auth methods Signed-off-by: Jens Langhammer * rest of the owl Signed-off-by: Jens Langhammer * allow specifying any params Signed-off-by: Jens Langhammer * add UI Signed-off-by: Jens Langhammer * delete user when token Signed-off-by: Jens Langhammer * add tests and fix Signed-off-by: Jens Langhammer * sigh Signed-off-by: Jens Langhammer * gen Signed-off-by: Jens Langhammer * better API validation Signed-off-by: Jens Langhammer * fix sentry Signed-off-by: Jens Langhammer * one more test and fix Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens Langhammer --- Makefile | 1 + .../enterprise/providers/scim/__init__.py | 0 authentik/enterprise/providers/scim/api.py | 14 ++ authentik/enterprise/providers/scim/apps.py | 9 + .../enterprise/providers/scim/auth_oauth2.py | 80 ++++++++ .../enterprise/providers/scim/signals.py | 30 +++ authentik/enterprise/providers/scim/tests.py | 189 ++++++++++++++++++ authentik/enterprise/settings.py | 1 + authentik/lib/utils/reflection.py | 11 + authentik/providers/scim/api/providers.py | 9 +- authentik/providers/scim/clients/auth.py | 16 ++ authentik/providers/scim/clients/base.py | 5 +- ...h_mode_scimprovider_auth_oauth_and_more.py | 59 ++++++ authentik/providers/scim/models.py | 43 +++- authentik/root/settings.py | 1 + .../sources/oauth/api/source_connection.py | 2 +- authentik/sources/oauth/clients/oauth2.py | 10 +- .../0011_useroauthsourceconnection_expires.py | 19 ++ authentik/sources/oauth/models.py | 6 + authentik/sources/oauth/views/callback.py | 5 + blueprints/schema.json | 26 ++- schema.yml | 68 +++++-- ...ak-application-wizard-provider-for-scim.ts | 1 + web/src/admin/providers/rac/EndpointForm.ts | 4 +- .../admin/providers/scim/SCIMProviderForm.ts | 10 +- .../providers/scim/SCIMProviderFormForm.ts | 125 +++++++++++- web/src/common/sentry/index.ts | 25 +-- 27 files changed, 716 insertions(+), 53 deletions(-) create mode 100644 authentik/enterprise/providers/scim/__init__.py create mode 100644 authentik/enterprise/providers/scim/api.py create mode 100644 authentik/enterprise/providers/scim/apps.py create mode 100644 authentik/enterprise/providers/scim/auth_oauth2.py create mode 100644 authentik/enterprise/providers/scim/signals.py create mode 100644 authentik/enterprise/providers/scim/tests.py create mode 100644 authentik/providers/scim/clients/auth.py create mode 100644 authentik/providers/scim/migrations/0014_scimprovider_auth_mode_scimprovider_auth_oauth_and_more.py create mode 100644 authentik/sources/oauth/migrations/0011_useroauthsourceconnection_expires.py diff --git a/Makefile b/Makefile index 94e90dc8b3..efd072a6b2 100644 --- a/Makefile +++ b/Makefile @@ -193,6 +193,7 @@ gen-client-ts: gen-clean-ts ## Build and install the authentik API for Typescri --git-repo-id authentik \ --git-user-id goauthentik + cd ${PWD}/${GEN_API_TS} && npm i cd ${PWD}/${GEN_API_TS} && npm link cd ${PWD}/web && npm link @goauthentik/api diff --git a/authentik/enterprise/providers/scim/__init__.py b/authentik/enterprise/providers/scim/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/enterprise/providers/scim/api.py b/authentik/enterprise/providers/scim/api.py new file mode 100644 index 0000000000..ba065304cf --- /dev/null +++ b/authentik/enterprise/providers/scim/api.py @@ -0,0 +1,14 @@ +from django.utils.translation import gettext as _ +from rest_framework.exceptions import ValidationError + +from authentik.enterprise.license import LicenseKey +from authentik.providers.scim.models import SCIMAuthenticationMode + + +class SCIMProviderSerializerMixin: + + def validate_auth_mode(self, auth_mode: SCIMAuthenticationMode) -> SCIMAuthenticationMode: + if auth_mode == SCIMAuthenticationMode.OAUTH: + if not LicenseKey.cached_summary().status.is_valid: + raise ValidationError(_("Enterprise is required to use the OAuth mode.")) + return auth_mode diff --git a/authentik/enterprise/providers/scim/apps.py b/authentik/enterprise/providers/scim/apps.py new file mode 100644 index 0000000000..032d1e77ee --- /dev/null +++ b/authentik/enterprise/providers/scim/apps.py @@ -0,0 +1,9 @@ +from authentik.enterprise.apps import EnterpriseConfig + + +class AuthentikEnterpriseProviderSCIMConfig(EnterpriseConfig): + + name = "authentik.enterprise.providers.scim" + label = "authentik_enterprise_providers_scim" + verbose_name = "authentik Enterprise.Providers.SCIM" + default = True diff --git a/authentik/enterprise/providers/scim/auth_oauth2.py b/authentik/enterprise/providers/scim/auth_oauth2.py new file mode 100644 index 0000000000..db7dab2d04 --- /dev/null +++ b/authentik/enterprise/providers/scim/auth_oauth2.py @@ -0,0 +1,80 @@ +from datetime import timedelta +from typing import TYPE_CHECKING + +from django.utils.timezone import now +from requests import Request, RequestException +from structlog.stdlib import get_logger + +from authentik.providers.scim.clients.exceptions import SCIMRequestException +from authentik.sources.oauth.clients.oauth2 import OAuth2Client +from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection + +if TYPE_CHECKING: + from authentik.providers.scim.models import SCIMProvider + + +class SCIMOAuthException(SCIMRequestException): + """Exceptions related to OAuth operations for SCIM requests""" + + +class SCIMOAuthAuth: + + def __init__(self, provider: "SCIMProvider"): + self.provider = provider + self.user = provider.auth_oauth_user + self.connection = self.get_connection() + self.logger = get_logger().bind() + + def retrieve_token(self): + if not self.provider.auth_oauth: + return None + source: OAuthSource = self.provider.auth_oauth + client = OAuth2Client(source, None) + access_token_url = source.source_type.access_token_url or "" + if source.source_type.urls_customizable and source.access_token_url: + access_token_url = source.access_token_url + data = client.get_access_token_args(None, None) + data["grant_type"] = "password" + data.update(self.provider.auth_oauth_params) + try: + response = client.do_request( + "POST", + access_token_url, + auth=client.get_access_token_auth(), + data=data, + headers=client._default_headers, + ) + response.raise_for_status() + body = response.json() + if "error" in body: + self.logger.info("Failed to get new OAuth token", error=body["error"]) + raise SCIMOAuthException(response, body["error"]) + return body + except RequestException as exc: + raise SCIMOAuthException(exc.response, message="Failed to get OAuth token") from exc + + def get_connection(self): + token = UserOAuthSourceConnection.objects.filter( + source=self.provider.auth_oauth, user=self.user, expires__gt=now() + ).first() + if token and token.access_token: + return token + token = self.retrieve_token() + access_token = token["access_token"] + expires_in = int(token.get("expires_in", 0)) + token, _ = UserOAuthSourceConnection.objects.update_or_create( + source=self.provider.auth_oauth, + user=self.user, + defaults={ + "access_token": access_token, + "expires": now() + timedelta(seconds=expires_in), + }, + ) + return token + + def __call__(self, request: Request) -> Request: + if not self.connection.is_valid: + self.logger.info("OAuth token expired, renewing token") + self.connection = self.get_connection() + request.headers["Authorization"] = f"Bearer {self.connection.access_token}" + return request diff --git a/authentik/enterprise/providers/scim/signals.py b/authentik/enterprise/providers/scim/signals.py new file mode 100644 index 0000000000..d150da2178 --- /dev/null +++ b/authentik/enterprise/providers/scim/signals.py @@ -0,0 +1,30 @@ +from django.db.models import Model +from django.db.models.signals import post_save +from django.dispatch import receiver + +from authentik.core.models import USER_PATH_SYSTEM_PREFIX, User, UserTypes +from authentik.events.middleware import audit_ignore +from authentik.providers.scim.models import SCIMAuthenticationMode, SCIMProvider + +USER_PATH_PROVIDERS_SCIM = USER_PATH_SYSTEM_PREFIX + "/providers/scim" + + +@receiver(post_save, sender=SCIMProvider) +def scim_provider_post_save(sender: type[Model], instance: SCIMProvider, created: bool, **__): + """Create service account before provider is saved""" + identifier = f"ak-providers-scim-{instance.pk}" + with audit_ignore(): + if instance.auth_mode == SCIMAuthenticationMode.OAUTH: + user, user_created = User.objects.update_or_create( + username=identifier, + defaults={ + "name": f"SCIM Provider {instance.name} Service-Account", + "type": UserTypes.INTERNAL_SERVICE_ACCOUNT, + "path": USER_PATH_PROVIDERS_SCIM, + }, + ) + if created or user_created: + instance.auth_oauth_user = user + instance.save() + elif instance.auth_mode == SCIMAuthenticationMode.TOKEN: + User.objects.filter(username=identifier).delete() diff --git a/authentik/enterprise/providers/scim/tests.py b/authentik/enterprise/providers/scim/tests.py new file mode 100644 index 0000000000..bfc68b7711 --- /dev/null +++ b/authentik/enterprise/providers/scim/tests.py @@ -0,0 +1,189 @@ +"""SCIM OAuth tests""" + +from base64 import b64encode +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from django.urls import reverse +from django.utils.timezone import now +from requests_mock import Mocker +from rest_framework.test import APITestCase + +from authentik.blueprints.tests import apply_blueprint +from authentik.core.models import Application, Group, User +from authentik.core.tests.utils import create_test_admin_user +from authentik.enterprise.license import LicenseKey +from authentik.enterprise.models import License +from authentik.enterprise.tests.test_license import expiry_valid +from authentik.lib.generators import generate_id +from authentik.providers.scim.models import SCIMAuthenticationMode, SCIMMapping, SCIMProvider +from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection +from authentik.tenants.models import Tenant + + +class SCIMOAuthTests(APITestCase): + """SCIM User tests""" + + @apply_blueprint("system/providers-scim.yaml") + def setUp(self) -> None: + # Delete all users and groups as the mocked HTTP responses only return one ID + # which will cause errors with multiple users + Tenant.objects.update(avatars="none") + User.objects.all().exclude_anonymous().delete() + Group.objects.all().delete() + self.source = OAuthSource.objects.create( + name=generate_id(), + slug=generate_id(), + access_token_url="http://localhost/token", # nosec + consumer_key=generate_id(), + consumer_secret=generate_id(), + provider_type="openidconnect", + ) + self.provider = SCIMProvider.objects.create( + name=generate_id(), + url="https://localhost", + auth_mode=SCIMAuthenticationMode.OAUTH, + auth_oauth=self.source, + auth_oauth_params={ + "foo": "bar", + }, + exclude_users_service_account=True, + ) + self.app: Application = Application.objects.create( + name=generate_id(), + slug=generate_id(), + ) + self.app.backchannel_providers.add(self.provider) + self.provider.property_mappings.add( + SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user") + ) + self.provider.property_mappings_group.add( + SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/group") + ) + + def test_retrieve_token(self): + """Test token retrieval""" + with Mocker() as mocker: + token = generate_id() + mocker.post("http://localhost/token", json={"access_token": token, "expires_in": 3600}) + self.provider.scim_auth() + conn = UserOAuthSourceConnection.objects.filter( + source=self.source, + user=self.provider.auth_oauth_user, + ).first() + self.assertIsNotNone(conn) + self.assertTrue(conn.is_valid) + auth = ( + b64encode( + b":".join((self.source.consumer_key.encode(), self.source.consumer_secret.encode())) + ) + .strip() + .decode() + ) + self.assertEqual( + mocker.request_history[0].headers["Authorization"], + f"Basic {auth}", + ) + self.assertEqual(mocker.request_history[0].body, "grant_type=password&foo=bar") + + def test_existing_token(self): + """Test existing token""" + UserOAuthSourceConnection.objects.create( + source=self.source, + user=self.provider.auth_oauth_user, + access_token=generate_id(), + expires=now() + timedelta(hours=3), + ) + with Mocker() as mocker: + self.provider.scim_auth() + self.assertEqual(len(mocker.request_history), 0) + + @Mocker() + def test_user_create(self, mock: Mocker): + """Test user creation""" + scim_id = generate_id() + token = generate_id() + mock.post("http://localhost/token", json={"access_token": token, "expires_in": 3600}) + mock.get( + "https://localhost/ServiceProviderConfig", + json={}, + ) + mock.post( + "https://localhost/Users", + json={ + "id": scim_id, + }, + ) + uid = generate_id() + user = User.objects.create( + username=uid, + name=f"{uid} {uid}", + email=f"{uid}@goauthentik.io", + ) + self.assertEqual(mock.call_count, 3) + self.assertEqual(mock.request_history[1].method, "GET") + self.assertEqual(mock.request_history[2].method, "POST") + self.assertJSONEqual( + mock.request_history[2].body, + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "active": True, + "emails": [ + { + "primary": True, + "type": "other", + "value": f"{uid}@goauthentik.io", + } + ], + "externalId": user.uid, + "name": { + "familyName": uid, + "formatted": f"{uid} {uid}", + "givenName": uid, + }, + "displayName": f"{uid} {uid}", + "userName": uid, + }, + ) + + @patch( + "authentik.enterprise.license.LicenseKey.validate", + MagicMock( + return_value=LicenseKey( + aud="", + exp=expiry_valid, + name=generate_id(), + internal_users=100, + external_users=100, + ) + ), + ) + def test_api_create(self): + License.objects.create(key=generate_id()) + self.client.force_login(create_test_admin_user()) + res = self.client.post( + reverse("authentik_api:scimprovider-list"), + { + "name": generate_id(), + "url": "http://localhost", + "auth_mode": "oauth", + "auth_oauth": str(self.source.pk), + }, + ) + self.assertEqual(res.status_code, 201) + + def test_api_create_no_license(self): + self.client.force_login(create_test_admin_user()) + res = self.client.post( + reverse("authentik_api:scimprovider-list"), + { + "name": generate_id(), + "url": "http://localhost", + "auth_mode": "oauth", + "auth_oauth": str(self.source.pk), + }, + ) + self.assertEqual(res.status_code, 400) + self.assertJSONEqual( + res.content, {"auth_mode": ["Enterprise is required to use the OAuth mode."]} + ) diff --git a/authentik/enterprise/settings.py b/authentik/enterprise/settings.py index 97b988d605..2b99b5d6de 100644 --- a/authentik/enterprise/settings.py +++ b/authentik/enterprise/settings.py @@ -5,6 +5,7 @@ TENANT_APPS = [ "authentik.enterprise.policies.unique_password", "authentik.enterprise.providers.google_workspace", "authentik.enterprise.providers.microsoft_entra", + "authentik.enterprise.providers.scim", "authentik.enterprise.providers.ssf", "authentik.enterprise.search", "authentik.enterprise.stages.authenticator_endpoint_gdtc", diff --git a/authentik/lib/utils/reflection.py b/authentik/lib/utils/reflection.py index b8f8e4c08e..9296a39bc0 100644 --- a/authentik/lib/utils/reflection.py +++ b/authentik/lib/utils/reflection.py @@ -6,6 +6,7 @@ from pathlib import Path from tempfile import gettempdir from django.conf import settings +from django.utils.module_loading import import_string from authentik.lib.config import CONFIG @@ -62,3 +63,13 @@ def get_env() -> str: if "AK_APPLIANCE" in os.environ: return os.environ["AK_APPLIANCE"] return "custom" + + +def ConditionalInheritance(path: str): + """Conditionally inherit from a class, intended for things like authentik.enterprise, + without which authentik should still be able to run""" + try: + cls = import_string(path) + return cls + except ModuleNotFoundError: + return object diff --git a/authentik/providers/scim/api/providers.py b/authentik/providers/scim/api/providers.py index 1ce65953c1..80e6b099dd 100644 --- a/authentik/providers/scim/api/providers.py +++ b/authentik/providers/scim/api/providers.py @@ -5,11 +5,15 @@ from rest_framework.viewsets import ModelViewSet from authentik.core.api.providers import ProviderSerializer from authentik.core.api.used_by import UsedByMixin from authentik.lib.sync.outgoing.api import OutgoingSyncProviderStatusMixin +from authentik.lib.utils.reflection import ConditionalInheritance from authentik.providers.scim.models import SCIMProvider from authentik.providers.scim.tasks import scim_sync, scim_sync_objects -class SCIMProviderSerializer(ProviderSerializer): +class SCIMProviderSerializer( + ConditionalInheritance("authentik.enterprise.providers.scim.api.SCIMProviderSerializerMixin"), + ProviderSerializer, +): """SCIMProvider Serializer""" class Meta: @@ -28,6 +32,9 @@ class SCIMProviderSerializer(ProviderSerializer): "url", "verify_certificates", "token", + "auth_mode", + "auth_oauth", + "auth_oauth_params", "compatibility_mode", "exclude_users_service_account", "filter_group", diff --git a/authentik/providers/scim/clients/auth.py b/authentik/providers/scim/clients/auth.py new file mode 100644 index 0000000000..7711808c0c --- /dev/null +++ b/authentik/providers/scim/clients/auth.py @@ -0,0 +1,16 @@ +from typing import TYPE_CHECKING + +from requests import Request + +if TYPE_CHECKING: + from authentik.providers.scim.models import SCIMProvider + + +class SCIMTokenAuth: + + def __init__(self, provider: "SCIMProvider"): + self.provider = provider + + def __call__(self, request: Request) -> Request: + request.headers["Authorization"] = f"Bearer {self.provider.token}" + return request diff --git a/authentik/providers/scim/clients/base.py b/authentik/providers/scim/clients/base.py index 32ffb8a951..0d48021898 100644 --- a/authentik/providers/scim/clients/base.py +++ b/authentik/providers/scim/clients/base.py @@ -35,7 +35,6 @@ class SCIMClient[TModel: "Model", TConnection: "Model", TSchema: "BaseModel"]( """SCIM Client""" base_url: str - token: str _session: Session _config: ServiceProviderConfiguration @@ -45,12 +44,12 @@ class SCIMClient[TModel: "Model", TConnection: "Model", TSchema: "BaseModel"]( self._session = get_http_session() self._session.verify = provider.verify_certificates self.provider = provider + self.auth = provider.scim_auth() # Remove trailing slashes as we assume the URL doesn't have any base_url = provider.url if base_url.endswith("/"): base_url = base_url[:-1] self.base_url = base_url - self.token = provider.token self._config = self.get_service_provider_config() def _request(self, method: str, path: str, **kwargs) -> dict: @@ -62,8 +61,8 @@ class SCIMClient[TModel: "Model", TConnection: "Model", TSchema: "BaseModel"]( method, f"{self.base_url}{path}", **kwargs, + auth=self.auth, headers={ - "Authorization": f"Bearer {self.token}", "Accept": "application/scim+json", "Content-Type": "application/scim+json", }, diff --git a/authentik/providers/scim/migrations/0014_scimprovider_auth_mode_scimprovider_auth_oauth_and_more.py b/authentik/providers/scim/migrations/0014_scimprovider_auth_mode_scimprovider_auth_oauth_and_more.py new file mode 100644 index 0000000000..f52378d808 --- /dev/null +++ b/authentik/providers/scim/migrations/0014_scimprovider_auth_mode_scimprovider_auth_oauth_and_more.py @@ -0,0 +1,59 @@ +# Generated by Django 5.1.12 on 2025-09-23 12:31 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_providers_scim", "0013_scimprovidergroup_attributes_and_more"), + ("authentik_sources_oauth", "0011_useroauthsourceconnection_expires"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="scimprovider", + name="auth_mode", + field=models.TextField( + choices=[("token", "Token"), ("oauth", "OAuth")], default="token" + ), + ), + migrations.AddField( + model_name="scimprovider", + name="auth_oauth", + field=models.ForeignKey( + default=None, + help_text="OAuth Source used for authentication", + null=True, + on_delete=django.db.models.deletion.SET_DEFAULT, + to="authentik_sources_oauth.oauthsource", + ), + ), + migrations.AddField( + model_name="scimprovider", + name="auth_oauth_params", + field=models.JSONField( + blank=True, + default=dict, + help_text="Additional OAuth parameters, such as grant_type", + ), + ), + migrations.AddField( + model_name="scimprovider", + name="auth_oauth_user", + field=models.ForeignKey( + default=None, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="scimprovider", + name="token", + field=models.TextField(blank=True, help_text="Authentication token"), + ), + ] diff --git a/authentik/providers/scim/models.py b/authentik/providers/scim/models.py index 4a8f047a34..88cc5a6ccc 100644 --- a/authentik/providers/scim/models.py +++ b/authentik/providers/scim/models.py @@ -8,12 +8,17 @@ from django.db.models import QuerySet from django.templatetags.static import static from django.utils.translation import gettext_lazy as _ from dramatiq.actor import Actor +from requests.auth import AuthBase from rest_framework.serializers import Serializer +from structlog.stdlib import get_logger from authentik.core.models import BackchannelProvider, Group, PropertyMapping, User, UserTypes from authentik.lib.models import SerializerModel from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient from authentik.lib.sync.outgoing.models import OutgoingSyncProvider +from authentik.providers.scim.clients.auth import SCIMTokenAuth + +LOGGER = get_logger() class SCIMProviderUser(SerializerModel): @@ -60,6 +65,13 @@ class SCIMProviderGroup(SerializerModel): return f"SCIM Provider Group {self.group_id} to {self.provider_id}" +class SCIMAuthenticationMode(models.TextChoices): + """SCIM authentication modes""" + + TOKEN = "token", _("Token") + OAUTH = "oauth", _("OAuth") + + class SCIMCompatibilityMode(models.TextChoices): """SCIM compatibility mode""" @@ -78,7 +90,26 @@ class SCIMProvider(OutgoingSyncProvider, BackchannelProvider): ) url = models.TextField(help_text=_("Base URL to SCIM requests, usually ends in /v2")) - token = models.TextField(help_text=_("Authentication token")) + + auth_mode = models.TextField( + choices=SCIMAuthenticationMode.choices, default=SCIMAuthenticationMode.TOKEN + ) + + token = models.TextField(help_text=_("Authentication token"), blank=True) + auth_oauth = models.ForeignKey( + "authentik_sources_oauth.OAuthSource", + on_delete=models.SET_DEFAULT, + default=None, + null=True, + help_text=_("OAuth Source used for authentication"), + ) + auth_oauth_params = models.JSONField( + blank=True, default=dict, help_text=_("Additional OAuth parameters, such as grant_type") + ) + auth_oauth_user = models.ForeignKey( + "authentik_core.User", on_delete=models.CASCADE, default=None, null=True + ) + verify_certificates = models.BooleanField(default=True) property_mappings_group = models.ManyToManyField( @@ -96,6 +127,16 @@ class SCIMProvider(OutgoingSyncProvider, BackchannelProvider): help_text=_("Alter authentik behavior for vendor-specific SCIM implementations."), ) + def scim_auth(self) -> AuthBase: + if self.auth_mode == SCIMAuthenticationMode.OAUTH: + try: + from authentik.enterprise.providers.scim.auth_oauth2 import SCIMOAuthAuth + + return SCIMOAuthAuth(self) + except ImportError: + LOGGER.warning("Failed to import SCIM OAuth Client") + return SCIMTokenAuth(self) + @property def icon_url(self) -> str | None: return static("authentik/sources/scim.png") diff --git a/authentik/root/settings.py b/authentik/root/settings.py index 4f3d9a5c8a..26e465dbf2 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -175,6 +175,7 @@ SPECTACULAR_SETTINGS = { "SAMLNameIDPolicyEnum": "authentik.sources.saml.models.SAMLNameIDPolicy", "UserTypeEnum": "authentik.core.models.UserTypes", "UserVerificationEnum": "authentik.stages.authenticator_webauthn.models.UserVerification", + "SCIMAuthenticationModeEnum": "authentik.providers.scim.models.SCIMAuthenticationMode", }, "ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE": False, "ENUM_GENERATE_CHOICE_DESCRIPTION": False, diff --git a/authentik/sources/oauth/api/source_connection.py b/authentik/sources/oauth/api/source_connection.py index ee63acdb18..54b0e0148c 100644 --- a/authentik/sources/oauth/api/source_connection.py +++ b/authentik/sources/oauth/api/source_connection.py @@ -12,7 +12,7 @@ from authentik.sources.oauth.models import GroupOAuthSourceConnection, UserOAuth class UserOAuthSourceConnectionSerializer(UserSourceConnectionSerializer): class Meta(UserSourceConnectionSerializer.Meta): model = UserOAuthSourceConnection - fields = UserSourceConnectionSerializer.Meta.fields + ["access_token"] + fields = UserSourceConnectionSerializer.Meta.fields + ["access_token", "expires"] extra_kwargs = { **UserSourceConnectionSerializer.Meta.extra_kwargs, "access_token": {"write_only": True}, diff --git a/authentik/sources/oauth/clients/oauth2.py b/authentik/sources/oauth/clients/oauth2.py index 25be20eaf0..e820ce903b 100644 --- a/authentik/sources/oauth/clients/oauth2.py +++ b/authentik/sources/oauth/clients/oauth2.py @@ -59,13 +59,15 @@ class OAuth2Client(BaseOAuthClient): """Get client secret""" return self.source.consumer_secret - def get_access_token_args(self, callback: str, code: str) -> dict[str, Any]: + def get_access_token_args(self, callback: str | None, code: str | None) -> dict[str, Any]: args = { - "redirect_uri": callback, - "code": code, "grant_type": "authorization_code", } - if SESSION_KEY_OAUTH_PKCE in self.request.session: + if callback: + args["redirect_uri"] = callback + if code: + args["code"] = code + if self.request and SESSION_KEY_OAUTH_PKCE in self.request.session: args["code_verifier"] = self.request.session[SESSION_KEY_OAUTH_PKCE] if ( self.source.source_type.authorization_code_auth_method diff --git a/authentik/sources/oauth/migrations/0011_useroauthsourceconnection_expires.py b/authentik/sources/oauth/migrations/0011_useroauthsourceconnection_expires.py new file mode 100644 index 0000000000..5815e906c9 --- /dev/null +++ b/authentik/sources/oauth/migrations/0011_useroauthsourceconnection_expires.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.12 on 2025-09-21 17:01 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_sources_oauth", "0010_oauthsource_authorization_code_auth_method"), + ] + + operations = [ + migrations.AddField( + model_name="useroauthsourceconnection", + name="expires", + field=models.DateTimeField(default=django.utils.timezone.now), + ), + ] diff --git a/authentik/sources/oauth/models.py b/authentik/sources/oauth/models.py index a9719965b1..adcf797cc4 100644 --- a/authentik/sources/oauth/models.py +++ b/authentik/sources/oauth/models.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING from django.db import models from django.http.request import HttpRequest from django.urls import reverse +from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from rest_framework.serializers import Serializer @@ -311,6 +312,11 @@ class UserOAuthSourceConnection(UserSourceConnection): """Authorized remote OAuth provider.""" access_token = models.TextField(blank=True, null=True, default=None) + expires = models.DateTimeField(default=now) + + @property + def is_valid(self): + return self.expires > now() @property def serializer(self) -> type[Serializer]: diff --git a/authentik/sources/oauth/views/callback.py b/authentik/sources/oauth/views/callback.py index 6126671aa8..a2e75877dd 100644 --- a/authentik/sources/oauth/views/callback.py +++ b/authentik/sources/oauth/views/callback.py @@ -1,5 +1,6 @@ """OAuth Callback Views""" +from datetime import timedelta from json import JSONDecodeError from typing import Any @@ -7,6 +8,7 @@ from django.conf import settings from django.contrib import messages from django.http import Http404, HttpRequest, HttpResponse from django.shortcuts import redirect +from django.utils.timezone import now from django.utils.translation import gettext as _ from django.views.generic import View from structlog.stdlib import get_logger @@ -77,6 +79,7 @@ class OAuthCallback(OAuthClientMixin, View): return sfm.get_flow( raw_info=raw_info, access_token=self.token.get("access_token"), + expires=self.token.get("expires_in"), ) def get_callback_url(self, source: OAuthSource) -> str: @@ -119,8 +122,10 @@ class OAuthSourceFlowManager(SourceFlowManager): self, connection: UserOAuthSourceConnection, access_token: str | None = None, + expires_in: int | None = None, **_, ) -> UserOAuthSourceConnection: """Set the access_token on the connection""" connection.access_token = access_token + connection.expires = now() + timedelta(seconds=expires_in) if expires_in else now() return connection diff --git a/blueprints/schema.json b/blueprints/schema.json index 23cc054cb7..21033d3e3d 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -7365,6 +7365,7 @@ "authentik.enterprise.policies.unique_password", "authentik.enterprise.providers.google_workspace", "authentik.enterprise.providers.microsoft_entra", + "authentik.enterprise.providers.scim", "authentik.enterprise.providers.ssf", "authentik.enterprise.search", "authentik.enterprise.stages.authenticator_endpoint_gdtc", @@ -9394,10 +9395,28 @@ }, "token": { "type": "string", - "minLength": 1, "title": "Token", "description": "Authentication token" }, + "auth_mode": { + "type": "string", + "enum": [ + "token", + "oauth" + ], + "title": "Auth mode" + }, + "auth_oauth": { + "type": "integer", + "title": "Auth oauth", + "description": "OAuth Source used for authentication" + }, + "auth_oauth_params": { + "type": "object", + "additionalProperties": true, + "title": "Auth oauth params", + "description": "Additional OAuth parameters, such as grant_type" + }, "compatibility_mode": { "type": "string", "enum": [ @@ -11189,6 +11208,11 @@ ], "title": "Access token" }, + "expires": { + "type": "string", + "format": "date-time", + "title": "Expires" + }, "icon": { "type": "string", "minLength": 1, diff --git a/schema.yml b/schema.yml index 277bd25963..73a06d7142 100644 --- a/schema.yml +++ b/schema.yml @@ -38573,6 +38573,7 @@ components: - authentik.enterprise.policies.unique_password - authentik.enterprise.providers.google_workspace - authentik.enterprise.providers.microsoft_entra + - authentik.enterprise.providers.scim - authentik.enterprise.providers.ssf - authentik.enterprise.search - authentik.enterprise.stages.authenticator_endpoint_gdtc @@ -38757,11 +38758,6 @@ components: required: - name - slug - AuthModeEnum: - enum: - - static - - prompt - type: string AuthTypeEnum: enum: - basic @@ -42000,7 +41996,7 @@ components: type: string format: uuid auth_mode: - $ref: '#/components/schemas/AuthModeEnum' + $ref: '#/components/schemas/EndpointAuthModeEnum' launch_url: type: string nullable: true @@ -42021,6 +42017,11 @@ components: - protocol - provider - provider_obj + EndpointAuthModeEnum: + enum: + - static + - prompt + type: string EndpointDevice: type: object description: Serializer for Endpoint authenticator devices @@ -42073,7 +42074,7 @@ components: type: string format: uuid auth_mode: - $ref: '#/components/schemas/AuthModeEnum' + $ref: '#/components/schemas/EndpointAuthModeEnum' maximum_connections: type: integer maximum: 2147483647 @@ -50497,7 +50498,7 @@ components: type: string format: uuid auth_mode: - $ref: '#/components/schemas/AuthModeEnum' + $ref: '#/components/schemas/EndpointAuthModeEnum' maximum_connections: type: integer maximum: 2147483647 @@ -52642,8 +52643,18 @@ components: type: boolean token: type: string - minLength: 1 description: Authentication token + auth_mode: + $ref: '#/components/schemas/SCIMAuthenticationModeEnum' + auth_oauth: + type: string + format: uuid + nullable: true + description: OAuth Source used for authentication + auth_oauth_params: + type: object + additionalProperties: {} + description: Additional OAuth parameters, such as grant_type compatibility_mode: allOf: - $ref: '#/components/schemas/CompatibilityModeEnum' @@ -53075,6 +53086,9 @@ components: type: string writeOnly: true nullable: true + expires: + type: string + format: date-time PatchedUserPlexSourceConnectionRequest: type: object description: User source connection @@ -56057,6 +56071,11 @@ components: - pre_authentication_flow - slug - sso_url + SCIMAuthenticationModeEnum: + enum: + - token + - oauth + type: string SCIMMapping: type: object description: SCIMMapping Serializer @@ -56177,6 +56196,17 @@ components: token: type: string description: Authentication token + auth_mode: + $ref: '#/components/schemas/SCIMAuthenticationModeEnum' + auth_oauth: + type: string + format: uuid + nullable: true + description: OAuth Source used for authentication + auth_oauth_params: + type: object + additionalProperties: {} + description: Additional OAuth parameters, such as grant_type compatibility_mode: allOf: - $ref: '#/components/schemas/CompatibilityModeEnum' @@ -56199,7 +56229,6 @@ components: - meta_model_name - name - pk - - token - url - verbose_name - verbose_name_plural @@ -56275,8 +56304,18 @@ components: type: boolean token: type: string - minLength: 1 description: Authentication token + auth_mode: + $ref: '#/components/schemas/SCIMAuthenticationModeEnum' + auth_oauth: + type: string + format: uuid + nullable: true + description: OAuth Source used for authentication + auth_oauth_params: + type: object + additionalProperties: {} + description: Additional OAuth parameters, such as grant_type compatibility_mode: allOf: - $ref: '#/components/schemas/CompatibilityModeEnum' @@ -56294,7 +56333,6 @@ components: the remote system. required: - name - - token - url SCIMProviderUser: type: object @@ -58838,6 +58876,9 @@ components: type: string format: date-time readOnly: true + expires: + type: string + format: date-time required: - created - identifier @@ -58862,6 +58903,9 @@ components: type: string writeOnly: true nullable: true + expires: + type: string + format: date-time required: - identifier - source diff --git a/web/src/admin/applications/wizard/steps/providers/ak-application-wizard-provider-for-scim.ts b/web/src/admin/applications/wizard/steps/providers/ak-application-wizard-provider-for-scim.ts index f4d5ed383a..a6de72cddd 100644 --- a/web/src/admin/applications/wizard/steps/providers/ak-application-wizard-provider-for-scim.ts +++ b/web/src/admin/applications/wizard/steps/providers/ak-application-wizard-provider-for-scim.ts @@ -22,6 +22,7 @@ export class ApplicationWizardSCIMProvider extends ApplicationWizardProviderForm return html`${this.label}
${renderForm( + this.requestUpdate.bind(this), (this.wizard.provider as SCIMProvider) ?? {}, this.wizard.errors.provider, )} diff --git a/web/src/admin/providers/rac/EndpointForm.ts b/web/src/admin/providers/rac/EndpointForm.ts index 25ecd32dbe..7ca2da5bb7 100644 --- a/web/src/admin/providers/rac/EndpointForm.ts +++ b/web/src/admin/providers/rac/EndpointForm.ts @@ -10,7 +10,7 @@ import { DEFAULT_CONFIG } from "#common/api/config"; import { ModelForm } from "#elements/forms/ModelForm"; -import { AuthModeEnum, Endpoint, ProtocolEnum, RacApi } from "@goauthentik/api"; +import { Endpoint, EndpointAuthModeEnum, ProtocolEnum, RacApi } from "@goauthentik/api"; import YAML from "yaml"; @@ -37,7 +37,7 @@ export class EndpointForm extends ModelForm { } async send(data: Endpoint): Promise { - data.authMode = AuthModeEnum.Prompt; + data.authMode = EndpointAuthModeEnum.Prompt; if (!this.instance) { data.provider = this.providerID || 0; } else { diff --git a/web/src/admin/providers/scim/SCIMProviderForm.ts b/web/src/admin/providers/scim/SCIMProviderForm.ts index 7470d2296f..984a72454a 100644 --- a/web/src/admin/providers/scim/SCIMProviderForm.ts +++ b/web/src/admin/providers/scim/SCIMProviderForm.ts @@ -4,7 +4,7 @@ import { DEFAULT_CONFIG } from "#common/api/config"; import { BaseProviderForm } from "#admin/providers/BaseProviderForm"; -import { ProvidersApi, SCIMProvider } from "@goauthentik/api"; +import { ProvidersApi, SCIMAuthenticationModeEnum, SCIMProvider } from "@goauthentik/api"; import { customElement } from "lit/decorators.js"; @@ -28,8 +28,14 @@ export class SCIMProviderFormPage extends BaseProviderForm { }); } + get defaultInstance() { + return { + authMode: SCIMAuthenticationModeEnum.Token, + } as SCIMProvider; + } + renderForm() { - return renderForm(this.instance ?? {}, []); + return renderForm(this.requestUpdate.bind(this), this.instance ?? {}, []); } } diff --git a/web/src/admin/providers/scim/SCIMProviderFormForm.ts b/web/src/admin/providers/scim/SCIMProviderFormForm.ts index 48a4c189d4..71b9783fc1 100644 --- a/web/src/admin/providers/scim/SCIMProviderFormForm.ts +++ b/web/src/admin/providers/scim/SCIMProviderFormForm.ts @@ -1,28 +1,106 @@ import "#components/ak-hidden-text-input"; +import "#components/ak-radio-input"; import "#elements/ak-dual-select/ak-dual-select-dynamic-selected-provider"; import "#elements/forms/FormGroup"; import "#elements/forms/HorizontalFormElement"; import "#elements/forms/Radio"; import "#elements/forms/SearchSelect/index"; +import "#elements/CodeMirror"; +import "#admin/common/ak-license-notice"; import { propertyMappingsProvider, propertyMappingsSelector } from "./SCIMProviderFormHelpers.js"; import { DEFAULT_CONFIG } from "#common/api/config"; +import { CodeMirrorMode } from "#elements/CodeMirror"; + import { CompatibilityModeEnum, CoreApi, CoreGroupsListRequest, Group, + OAuthSource, + SCIMAuthenticationModeEnum, SCIMProvider, + SourcesApi, + SourcesOauthListRequest, ValidationError, } from "@goauthentik/api"; +import YAML from "yaml"; + import { msg } from "@lit/localize"; import { html } from "lit"; import { ifDefined } from "lit/directives/if-defined.js"; -export function renderForm(provider?: Partial, errors: ValidationError = {}) { +export function renderAuthToken(provider?: Partial, errors: ValidationError = {}) { + return html``; +} + +export function renderAuthOAuth(provider?: Partial, errors: ValidationError = {}) { + return html` + => { + const args: SourcesOauthListRequest = { + ordering: "name", + }; + if (query !== undefined) { + args.search = query; + } + const sources = await new SourcesApi(DEFAULT_CONFIG).sourcesOauthList(args); + return sources.results; + }} + .renderElement=${(source: OAuthSource): string => { + return source.name; + }} + .value=${(source: OAuthSource | undefined): string | undefined => { + return source ? source.pk : undefined; + }} + .selected=${(source: OAuthSource): boolean => { + return source.pk === provider?.authOauth; + }} + blankable + > + +

+ ${msg("Specify OAuth source used for authentication.")} +

+
+ + + +

+ ${msg("Additional OAuth parameters, such as grant_type.")} +

+
`; +} + +export function renderAuth(provider?: Partial, errors: ValidationError = {}) { + switch (provider?.authMode) { + default: + case SCIMAuthenticationModeEnum.Token: + return renderAuthToken(provider, errors); + case SCIMAuthenticationModeEnum.Oauth: + return renderAuthOAuth(provider, errors); + } +} + +export function renderForm( + update: () => void, + provider?: Partial, + errors: ValidationError = {}, +) { return html` , errors: ValidationE > - + name="authMode" + > + ) => { + if (!provider) { + provider = {}; + } + provider.authMode = ev.detail.value; + update(); + }} + .value=${provider?.authMode} + .options=${[ + { + label: msg("Token"), + value: SCIMAuthenticationModeEnum.Token, + default: true, + description: html`${msg( + "Authenticate SCIM requests using a static token.", + )}`, + }, + { + label: msg("OAuth"), + value: SCIMAuthenticationModeEnum.Oauth, + default: true, + description: html`${msg("Authenticate SCIM requests using OAuth.")} + `, + }, + ]} + > + + + ${renderAuth(provider, errors)} + int), tracePropagationTargets: [window.location.origin], tracesSampleRate: debug ? 1.0 : cfg.errorReporting.tracesSampleRate, environment: cfg.errorReporting.environment, @@ -72,22 +74,15 @@ export function configureSentry(canDoPpi = false) { } return event; }, - }); + }; + if (debug) { + console.debug("authentik/config: Enabled Sentry Spotlight"); + } + init(opts as BrowserOptions); setTag(TAG_SENTRY_CAPABILITIES, cfg.capabilities.join(",")); if (window.location.pathname.includes("if/")) { setTag(TAG_SENTRY_COMPONENT, `web/${readInterfaceRouteParam()}`); } - if (debug) { - Spotlight.init({ - injectImmediately: true, - integrations: [ - Spotlight.sentry({ - injectIntoSDK: true, - }), - ], - }); - console.debug("authentik/config: Enabled Sentry Spotlight"); - } if (cfg.errorReporting.sendPii && canDoPpi) { me().then((user) => { setUser({ email: user.user.email });