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`