enterprise/providers/scim: Add SCIM OAuth support (#16903)

* sources/oauth: add expires field to user source connection

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* providers/scim: add support for other auth methods

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* rest of the owl

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* allow specifying any params

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add UI

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* delete user when token

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add tests and fix

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* sigh

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* gen

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* better API validation

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix sentry

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* one more test and fix

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L.
2025-09-23 17:52:02 +02:00
committed by GitHub
parent b704a54ceb
commit 2e56082066
27 changed files with 716 additions and 53 deletions
+1
View File
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
@@ -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."]}
)
+1
View File
@@ -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",
+11
View File
@@ -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
+8 -1
View File
@@ -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",
+16
View File
@@ -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
+2 -3
View File
@@ -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",
},
@@ -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"),
),
]
+42 -1
View File
@@ -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")
+1
View File
@@ -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,
@@ -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},
+6 -4
View File
@@ -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
@@ -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),
),
]
+6
View File
@@ -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]:
@@ -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
+25 -1
View File
@@ -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,
+56 -12
View File
@@ -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
@@ -22,6 +22,7 @@ export class ApplicationWizardSCIMProvider extends ApplicationWizardProviderForm
return html`<ak-wizard-title>${this.label}</ak-wizard-title>
<form id="providerform" class="pf-c-form pf-m-horizontal" slot="form">
${renderForm(
this.requestUpdate.bind(this),
(this.wizard.provider as SCIMProvider) ?? {},
this.wizard.errors.provider,
)}
+2 -2
View File
@@ -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<Endpoint, string> {
}
async send(data: Endpoint): Promise<Endpoint> {
data.authMode = AuthModeEnum.Prompt;
data.authMode = EndpointAuthModeEnum.Prompt;
if (!this.instance) {
data.provider = this.providerID || 0;
} else {
@@ -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<SCIMProvider> {
});
}
get defaultInstance() {
return {
authMode: SCIMAuthenticationModeEnum.Token,
} as SCIMProvider;
}
renderForm() {
return renderForm(this.instance ?? {}, []);
return renderForm(this.requestUpdate.bind(this), this.instance ?? {}, []);
}
}
@@ -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<SCIMProvider>, errors: ValidationError = {}) {
export function renderAuthToken(provider?: Partial<SCIMProvider>, errors: ValidationError = {}) {
return html`<ak-hidden-text-input
name="token"
label=${msg("Token")}
value="${provider?.token ?? ""}"
.errorMessages=${errors?.token}
required
help=${msg("Token to authenticate with.")}
input-hint="code"
></ak-hidden-text-input>`;
}
export function renderAuthOAuth(provider?: Partial<SCIMProvider>, errors: ValidationError = {}) {
return html`<ak-form-element-horizontal label=${msg("OAuth Source")} name="authOauth">
<ak-search-select
.fetchObjects=${async (query?: string): Promise<OAuthSource[]> => {
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
>
</ak-search-select>
<p class="pf-c-form__helper-text">
${msg("Specify OAuth source used for authentication.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("OAuth Parameters")} name="authOauthParams">
<ak-codemirror
mode=${CodeMirrorMode.YAML}
value="${YAML.stringify(provider?.authOauthParams ?? {})}"
>
</ak-codemirror>
<p class="pf-c-form__helper-text">
${msg("Additional OAuth parameters, such as grant_type.")}
</p>
</ak-form-element-horizontal> `;
}
export function renderAuth(provider?: Partial<SCIMProvider>, 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<SCIMProvider>,
errors: ValidationError = {},
) {
return html`
<ak-text-input
name="name"
@@ -51,17 +129,42 @@ export function renderForm(provider?: Partial<SCIMProvider>, errors: ValidationE
>
</ak-switch-input>
<ak-hidden-text-input
name="token"
label=${msg("Token")}
value="${provider?.token ?? ""}"
.errorMessages=${errors?.token}
<ak-form-element-horizontal
label=${msg("Authentication Mode")}
required
help=${msg(
"Token to authenticate with. Currently only bearer authentication is supported.",
)}
input-hint="code"
></ak-hidden-text-input>
name="authMode"
>
<ak-radio
@change=${(ev: CustomEvent<{ value: SCIMAuthenticationModeEnum }>) => {
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.")}
<ak-license-notice></ak-license-notice>`,
},
]}
></ak-radio>
</ak-form-element-horizontal>
${renderAuth(provider, errors)}
<ak-radio-input
name="compatibilityMode"
label=${msg("Compatibility Mode")}
+10 -15
View File
@@ -6,14 +6,15 @@ import { readInterfaceRouteParam } from "#elements/router/utils";
import { CapabilitiesEnum, ResponseError } from "@goauthentik/api";
import {
type BrowserOptions,
browserTracingIntegration,
ErrorEvent,
EventHint,
init,
setTag,
setUser,
spotlightBrowserIntegration,
} from "@sentry/browser";
import * as Spotlight from "@spotlightjs/spotlight";
/**
* A generic error that can be thrown without triggering Sentry's reporting.
@@ -29,7 +30,7 @@ export function configureSentry(canDoPpi = false) {
if (!cfg.errorReporting?.enabled && !debug) {
return cfg;
}
init({
const opts = {
dsn: cfg.errorReporting.sentryDsn,
ignoreErrors: [
/network/gi,
@@ -50,7 +51,8 @@ export function configureSentry(canDoPpi = false) {
instrumentPageLoad: false,
traceFetch: false,
}),
],
debug ? spotlightBrowserIntegration() : null,
].filter((int) => 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 });