feat(frontend): add SAML helper (#12799)

* squash #12762

* implement https://github.com/inventree/InvenTree/issues/3509

* add changelog

* add option to use the full width
This commit is contained in:
Matthias Mair
2026-09-07 16:52:24 +10:00
committed by GitHub
parent 2566d332a8
commit c959ca4574
15 changed files with 692 additions and 50 deletions
@@ -1,11 +1,14 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 542
INVENTREE_API_VERSION = 543
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v543 -> 2026-09-05 : https://github.com/inventree/InvenTree/pull/12762
- Adds admin APIs for managing SSO applications
v542 -> 2026-09-03 : https://github.com/inventree/InvenTree/pull/12731
- Adds management APIs for oAuth2 provider applications
@@ -492,3 +492,4 @@ class InvenTreeMetadata(SimpleMetadata):
InvenTreeMetadata.label_lookup[DependentField] = 'dependent field'
InvenTreeMetadata.label_lookup[serializers.JSONField] = 'json'
+13 -7
View File
@@ -786,11 +786,6 @@ SITE_MULTI = get_boolean_setting('INVENTREE_SITE_MULTI', 'site_multi', False)
# If a SITE_ID is specified
SITE_ID = get_setting('INVENTREE_SITE_ID', 'site_id', 1 if SITE_MULTI else None)
# Load the allauth social backends
SOCIAL_BACKENDS = get_setting(
'INVENTREE_SOCIAL_BACKENDS', 'social_backends', [], typecast=list
)
if not SITE_MULTI:
INSTALLED_APPS.remove('django.contrib.sites')
@@ -986,8 +981,16 @@ else:
FRONTEND_SETTINGS = config.get_frontend_settings(debug=DEBUG)
FRONTEND_URL_BASE = FRONTEND_SETTINGS['base_url']
# Load the allauth social backends
SOCIAL_BACKENDS = get_setting(
'INVENTREE_SOCIAL_BACKENDS', 'social_backends', [], typecast=list
)
DEFAULT_SOCIAL = ['saml', 'openid_connect']
_SOCIAL_BACKENDS = {*DEFAULT_SOCIAL, *SOCIAL_BACKENDS}
# region auth
for app in SOCIAL_BACKENDS: # pragma: no cover
for app in _SOCIAL_BACKENDS: # pragma: no cover
# Ensure that the app starts with 'allauth.socialaccount.providers'
social_prefix = 'allauth.socialaccount.providers.'
@@ -996,9 +999,12 @@ for app in SOCIAL_BACKENDS: # pragma: no cover
INSTALLED_APPS.append(app)
SOCIALACCOUNT_PROVIDERS = get_setting(
SOCIALACCOUNT_PROVIDERS = {a: {} for a in DEFAULT_SOCIAL}
_PROVIDER_SETTINGS = get_setting(
'INVENTREE_SOCIAL_PROVIDERS', 'social_providers', None, typecast=dict
)
if _PROVIDER_SETTINGS and isinstance(_PROVIDER_SETTINGS, dict):
SOCIALACCOUNT_PROVIDERS.update(_PROVIDER_SETTINGS)
SOCIALACCOUNT_STORE_TOKENS = True
+88
View File
@@ -18,6 +18,8 @@ from django.views.decorators.csrf import csrf_exempt
import django_filters.rest_framework.filters as rest_filters
import django_q.models
import django_q.tasks
from allauth.socialaccount import providers
from allauth.socialaccount.models import SocialApp
from django_filters.rest_framework.filterset import FilterSet
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from drf_spectacular.utils import (
@@ -1765,6 +1767,92 @@ class ObservabilityEnd(CreateAPI):
return Response({'status': 'ok'})
class SocialAppSerializer(serializers.ModelSerializer):
"""Serializer for SocialApp records."""
provider = serializers.ChoiceField(label=_('Provider'), choices=[])
name = serializers.CharField(
label=_('Name'),
help_text=_(
'Human friendly name for the application - will be displayed to users'
),
)
provider_id = serializers.CharField(
label=_('Provider ID'),
help_text=_(
'Unique identifier - required for generic providers that can be configured multiple times such as SAML or OpenID Connect'
),
required=False,
allow_blank=True,
)
class Meta:
"""Meta options for SocialAppSerializer."""
model = SocialApp
fields = [
'id',
'name',
'provider',
'provider_id',
'client_id',
'secret',
'settings',
]
read_only_fields = ['id']
def __init__(self, *args, **kwargs):
"""Populate provider choices from the active allauth registry."""
super().__init__(*args, **kwargs)
self.fields['provider'].choices = providers.registry.as_choices()
def validate_provider(self, value):
"""Ensure the selected provider is supported by the active allauth registry."""
if value not in [provider[0] for provider in providers.registry.as_choices()]:
raise serializers.ValidationError(_('Provider is not supported'))
return value
def validate(self, data):
"""Ensure that the provider is unique across all SocialApp records."""
provider = data.get('provider', None)
if (
provider
and SocialApp.objects.filter(provider=provider).exists()
and provider not in ('saml', 'openid_connect')
):
raise serializers.ValidationError({
'provider': _('A SocialApp with this provider already exists')
})
if provider == 'saml':
settings = data.get('settings') or {}
idp = settings.get('idp') or {}
has_metadata = bool(idp.get('metadata_url'))
has_inline_metadata = all(
idp.get(field) for field in ('sso_url', 'slo_url', 'x509cert')
)
if not has_metadata and not has_inline_metadata:
raise serializers.ValidationError({
'settings': _(
'Provide an IdP metadata URL, or configure the IdP '
'SSO URL, SLO URL, and X.509 certificate.'
)
})
return data
class SocialAppViewSet(CleanModelViewSet):
"""Manage a SocialApp (client side) application."""
queryset = SocialApp.objects.all()
serializer_class = SocialAppSerializer
admin_router.register('sso', SocialAppViewSet, basename='api-sso')
class ApplicationViewSet(CleanModelViewSet):
"""Manage a oAuth2 (provider side) application."""
+96
View File
@@ -9,6 +9,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile
from django.test.utils import override_settings
from django.urls import reverse
from allauth.socialaccount import providers
from PIL import Image
from taggit.models import Tag
@@ -56,6 +57,101 @@ class DataOutputAPITests(InvenTreeAPITestCase):
self.assertEqual(len(response.data), 5)
class SocialAppAPITests(InvenTreeAPITestCase):
"""Tests for the SocialApp API serializer."""
roles = 'all'
def test_provider_choices_and_validation(self):
"""Provider choices should come from the allauth registry and reject invalid values."""
from common.api import SocialAppSerializer
available = [provider[0] for provider in providers.registry.as_choices()]
serializer = SocialAppSerializer()
provider_field = serializer.fields['provider']
self.assertDictEqual(
dict(provider_field.choices), dict(providers.registry.as_choices())
)
self.assertCountEqual(available, list(provider_field.choices.keys()))
url = reverse('api-sso-list')
options = self.options(url)
actions = options.data['actions']['GET']
self.assertIn('provider', actions)
self.assertCountEqual(
[choice['value'] for choice in actions['provider']['choices']], available
)
self.assertEqual(
{
choice['value']: choice['display_name']
for choice in actions['provider']['choices']
},
dict(providers.registry.as_choices()),
)
invalid = SocialAppSerializer(
data={'name': 'Bad Provider', 'provider': 'not-a-provider'}
)
self.assertFalse(invalid.is_valid())
self.assertIn('provider', invalid.errors)
def test_saml_idp_configuration(self):
"""SAML apps require metadata or a complete inline IdP configuration."""
from common.api import SocialAppSerializer
common = {
'name': 'SAML App',
'provider': 'saml',
'provider_id': 'saml-provider',
'client_id': 'saml-org',
}
metadata = SocialAppSerializer(
data={
**common,
'settings': {
'idp': {
'entity_id': 'https://idp.example.com',
'metadata_url': 'https://idp.example.com/metadata',
}
},
}
)
self.assertTrue(metadata.is_valid(), metadata.errors)
inline = SocialAppSerializer(
data={
**common,
'settings': {
'idp': {
'entity_id': 'https://idp.example.com',
'sso_url': 'https://idp.example.com/sso',
'slo_url': 'https://idp.example.com/slo',
'x509cert': 'certificate',
}
},
}
)
self.assertTrue(inline.is_valid(), inline.errors)
incomplete = SocialAppSerializer(
data={
**common,
'settings': {
'idp': {
'entity_id': 'https://idp.example.com',
'sso_url': 'https://idp.example.com/sso',
}
},
}
)
self.assertFalse(incomplete.is_valid())
self.assertIn('settings', incomplete.errors)
class ParameterAPITests(InvenTreeAPITestCase):
"""Tests for the Parameter API."""