diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ab2ce5752..0551bee7ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - [#12713](https://github.com/inventree/InvenTree/pull/12713) adds SCIM 2 provisioning support, allowing InvenTree to be integrated with external identity providers for user management. +- [#12731](https://github.com/inventree/InvenTree/pull/12731) adds OIDC provider settings to the Admin Center - making all Identity Federation settings now available in one place without the need to use the database admin interface. ### Changed diff --git a/docs/docs/assets/images/settings/sso_settings.png b/docs/docs/assets/images/settings/sso_settings.png deleted file mode 100644 index d13b2b2da8..0000000000 Binary files a/docs/docs/assets/images/settings/sso_settings.png and /dev/null differ diff --git a/docs/docs/settings/SSO.md b/docs/docs/settings/SSO.md index 4540551fc8..636c8b6d17 100644 --- a/docs/docs/settings/SSO.md +++ b/docs/docs/settings/SSO.md @@ -14,21 +14,21 @@ InvenTree provides the possibility to use 3rd party services to authenticate use ## SSO Configuration -The basic requirements for configuring SSO are outlined below: +The basic steps for configuring SSO are: -1. Enable backend for each required SSO provider(s) in the [config file or environment variables](../start/config.md#single-sign-on). -1. Create an external *app* with your provider of choice -1. Add the required client configurations as a *Social application* in the [Database Admin interface](./db_admin.md). -1. Configure the *callback* URL for the external app. -1. Enable SSO for the users in the [global settings](../settings/global.md). -1. Configure [e-mail](../settings/email.md). +1. Add the backend for the intended SSO provider(s) in the [config file](../start/config.md#configuration-file) or environment variables. +2. Create an external *app* with the provider of choice +3. Add the required client configurations as a *Social application* in the [Database Admin interface](./db_admin.md). +4. Configure the *callback* URL for the external app. +5. Enable SSO for the users in the [global settings](../settings/global.md). +6. Configure [e-mail](../settings/email.md). !!! info "Two-step setup" - Provider modules are enabled in `config.yaml` (or environment variables). Client IDs, secrets, and site assignments are **not** configured there — they must be added as *Social applications* in the [Database Admin interface](./db_admin.md). SSO providers cannot be configured via the InvenTree API. + Provider modules are enabled in `config.yaml` (or environment variables). Client IDs, secrets, and site assignments are configured there or in the database via the Admin Center or the [Database Admin interface](./db_admin.md). -### Enable Provider Backends +### Add Provider Backends -The first step is to ensure that the required provider modules are installed, via your installation [configuration file](../start/config.md#single-sign-on). +The first step is to ensure that the required provider modules are installed, via the installations [configuration file](../start/config.md#single-sign-on). There are two variables in the configuration file which define the operation of SSO: @@ -41,14 +41,10 @@ In the example below, SSO provider modules are activated for *google*, *github* {{ image("settings/sso_config.png", "SSO Config") }} -!!! info "Provider Module Format" - Note that the provider modules specified in `social_backends` must be prefixed with `allauth.socialaccounts.providers` - !!! warning "Provider Documentation" We do not provide any specific documentation for each provider module. Please refer to the [django-allauth documentation](https://docs.allauth.org/en/latest/socialaccount/providers/index.html) for more information. -!!! tip "Restart Server" - As the [configuration file](../start/config.md) is only read when the server is launched, ensure you restart the server after editing the file. +As the [configuration file](../start/config.md) is only read when the server is launched, ensure you restart the server after editing the file. ### Create Provider App @@ -64,40 +60,32 @@ In general, the external app will generate a *key* and *secret* pair - although ### Add Client Configurations -Once your external SSO app has been created, you need to create a new *Social application* entry in the [Database Admin interface](./db_admin.md) (under **Social accounts** → **Social applications** — not in the Admin Center). +Once you have added the provider, you need to create a new *Social application* entry in the Admin Center (under Identity Federation / SSO ) or in the [Database Admin interface](./db_admin.md) (under **Social accounts** → **Social applications**). -#### Create Social Application +#### Admin Database Interface -Select **Add social application** (top right of the social applications list). Social applications are listed under the **Social accounts** section of the Database Admin — not in the InvenTree Admin Center settings screens. +1. Select **Add social application** (top right of the social applications list). Social applications are listed under the **Social accounts** section. -{{ image("settings/social_account_add.png", "Database Admin — Social applications section") }} +2. Configure the social application entry with the specifics provider details: -#### Configure Social Application - -Configure the social application entry with the app details: - -{{ image("settings/social_application_configure.png", "Configure Social Application") }} +{{ image("settings/social_application_configure.png", "Sample Social Application Configuration") }} - Select the *provider* type as required -- Provide a *name* for the application (note that this should match the *name* used for any custom settings provided in the configuration file) -- Add client and secret data for your external SSO app +- Provide a *name* for the social application (note that this must match the *name* used for any custom settings provided in the configuration file) +- Add client and secret data from your external SSO provider / application - Add the *site* which you want to provide access for this SSO app -- Save the new application entry when configuration is finished +- Save the new entry !!! warning "Site Selection" You *must* assign the new application to at least one available site domain -!!! tip "Fix Your Mistakes" - You can always return to edit or adjust the social application details later - -!!! success "Multiple Applications" - To provide support for multiple SSO applications, simply repeat this process and create another social application entry +Multiple SSO applications can be configured by repeating this process and creating multiple entries. ### Configure Callback URL -The external SSO application must be provided with a *callback* URL - a URL by which it can communicate with the InvenTree server. The specific *name* that the external SSO application uses for this callback URL may vary, with some authentication applications referring to it with other names such as *reply* or *redirect*. +Most external SSO providers must be provided with a *callback* URL - a URL by which it can communicate with the InvenTree server. The specific *name* that the external SSO application uses for this callback URL may vary, with some authentication applications referring to it with other names such as *reply* or *redirect*. -In any case, the URL is is specific to your installation and the SSO provider. The general pattern for this URL is: `{% raw %}/accounts//login/callback/{% endraw %}`. +In any case, the URL is is specific to your installation and the SSO provider. The general pattern for this URL is: `{% raw %}/accounts//login/callback/{% endraw %}` but can vary. Read the specific provider documentation by django-allauth for exact information. !!! success "Works for Local Installs" Your server does not need to be "public facing" for this to work. For example the URL `http://localhost:1234/accounts/github/login/callback/` would be perfectly valid! @@ -114,7 +102,7 @@ Now that the social application is created, you need to enable SSO authenticatio In the [settings screen](./global.md), navigate to the *Login Settings* panel. Here you will see the required configuration options to enable SSO: -{{ image("settings/sso_settings.png", "SSO Settings") }} +{{ image("settings/social_account_add.png", "Database Admin — Social applications section") }} | Name | Description | Default | Units | | ---- | ----------- | ------- | ----- | diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 6c944666b2..993d917796 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/src/backend/InvenTree/InvenTree/metadata.py b/src/backend/InvenTree/InvenTree/metadata.py index d74645aa05..3b2c7e0838 100644 --- a/src/backend/InvenTree/InvenTree/metadata.py +++ b/src/backend/InvenTree/InvenTree/metadata.py @@ -492,3 +492,4 @@ class InvenTreeMetadata(SimpleMetadata): InvenTreeMetadata.label_lookup[DependentField] = 'dependent field' +InvenTreeMetadata.label_lookup[serializers.JSONField] = 'json' diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index e91ab59408..36bad35440 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -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 diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index 21014008cb..1548a2a2d4 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -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.""" diff --git a/src/backend/InvenTree/common/test_api.py b/src/backend/InvenTree/common/test_api.py index 3c7d831992..fc12d89902 100644 --- a/src/backend/InvenTree/common/test_api.py +++ b/src/backend/InvenTree/common/test_api.py @@ -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.""" diff --git a/src/backend/InvenTree/importer/serializers.py b/src/backend/InvenTree/importer/serializers.py index 566322e310..f297e46cb9 100644 --- a/src/backend/InvenTree/importer/serializers.py +++ b/src/backend/InvenTree/importer/serializers.py @@ -90,6 +90,17 @@ class DataImportSessionSerializer(InvenTreeModelSerializer): user_detail = UserSerializer(source='user', read_only=True, many=False) + def validate_model_type(self, value): + """Prevent the target model type from being changed after creation.""" + if self.instance is not None and self.instance.model_type != value: + raise ValidationError( + _( + 'Model type cannot be changed after the import session has been created' + ) + ) + + return value + def validate_field_defaults(self, defaults): """De-stringify the field defaults.""" if defaults is None: diff --git a/src/backend/InvenTree/importer/tests.py b/src/backend/InvenTree/importer/tests.py index dc96119df7..04757fbeac 100644 --- a/src/backend/InvenTree/importer/tests.py +++ b/src/backend/InvenTree/importer/tests.py @@ -744,6 +744,72 @@ class ImportAPITest(ImporterMixin, InvenTreeAPITestCase): self.assignRole('purchase_order.change') self.post(url, expected_code=200) + def test_model_type_immutable(self): + """Test that a session's model_type cannot be changed after creation. + + Regression test for a security report (dev/todo/import-retarget.md) where a + user could create a session against a model they have permission for, then + retarget it (via PATCH) to a different model they do not have permission for - + bypassing the permission checks which are resolved against the model type at + the time they run, rather than the model type the session was created against. + """ + f = self.helper_file('companies.csv') + + session = DataImportSession.objects.create( + data_file=f, model_type='company', user=self.user + ) + + # 'company' is part of the 'purchase_order' ruleset + self.assignRole('purchase_order.change') + + url = reverse('api-import-session-detail', kwargs={'pk': session.pk}) + + # Attempting to retarget the session to a different model is rejected + response = self.patch(url, {'model_type': 'partcategory'}, expected_code=400) + self.assertIn('model_type', response.data) + + session.refresh_from_db() + self.assertEqual(session.model_type, 'company') + + # Re-submitting the *same* model_type value is not treated as a change + self.patch(url, {'model_type': 'company'}, expected_code=200) + + def test_retarget_permission_bypass(self): + """Test that retargeting a session cannot be used to bypass model permissions. + + Regression test for a security report (dev/todo/import-retarget.md): + a user with permission for one model (e.g. purchase orders) must not be able + to accept the field mapping for that model, then retarget the session to a + different model (e.g. part categories) for which they have no permission. + """ + from part.models import PartCategory + from users.permissions import check_user_permission + + f = self.helper_file('companies.csv') + + session = DataImportSession.objects.create( + data_file=f, model_type='company', user=self.user + ) + + # Grant permission for 'company' (via the 'purchase_order' ruleset) only + self.assignRole('purchase_order.change') + + # Sanity check: the user has no part_category permissions + self.assertFalse(check_user_permission(self.user, PartCategory, 'change')) + + # The user is permitted to accept the field mapping for the model they have access to + accept_fields_url = reverse( + 'api-import-session-accept-fields', kwargs={'pk': session.pk} + ) + self.post(accept_fields_url, expected_code=200) + + # Attempting to retarget the session to 'partcategory' must be rejected + detail_url = reverse('api-import-session-detail', kwargs={'pk': session.pk}) + self.patch(detail_url, {'model_type': 'partcategory'}, expected_code=400) + + session.refresh_from_db() + self.assertEqual(session.model_type, 'company') + def test_accept_rows_ownership(self): """Test that accept_rows rejects requests for sessions owned by another user.""" other_user = User.objects.create_user( diff --git a/src/frontend/lib/components/SearchInput.tsx b/src/frontend/lib/components/SearchInput.tsx index 740c763719..7e5adbbee2 100644 --- a/src/frontend/lib/components/SearchInput.tsx +++ b/src/frontend/lib/components/SearchInput.tsx @@ -33,6 +33,7 @@ export function SearchInput({ leftSection={} placeholder={placeholder ?? t`Search`} onChange={(event) => setValue(event.target.value)} + style={{ minWidth: '150px' }} rightSection={ value.length > 0 ? ( boolean - Callback function to determine if a row is selectable * @param detailAction: boolean - Enable detail action for each row (default = true) * @param dataFormatter : (data: any) => any - Callback function to reformat data returned by server (if not in default format) @@ -216,6 +217,7 @@ export type InvenTreeTableProps = { barcodeActions?: React.ReactNode[]; tableFilters?: TableFilter[]; tableActions?: React.ReactNode[]; + tableActionsFullWidth?: boolean; isRecordSelectable?: (record: T, index: number) => boolean; rowExpansion?: DataTableRowExpansionProps; dataFormatter?: (data: any) => any; diff --git a/src/frontend/src/components/forms/fields/ApiFormField.tsx b/src/frontend/src/components/forms/fields/ApiFormField.tsx index 3212c3ed89..de4dcd5255 100644 --- a/src/frontend/src/components/forms/fields/ApiFormField.tsx +++ b/src/frontend/src/components/forms/fields/ApiFormField.tsx @@ -16,6 +16,7 @@ import { ChoiceField } from './ChoiceField'; import DateField from './DateField'; import { DependentField } from './DependentField'; import IconField from './IconField'; +import { JsonField } from './JsonField'; import { NestedObjectField } from './NestedObjectField'; import NumberField from './NumberField'; import { RelatedModelField } from './RelatedModelField'; @@ -295,6 +296,15 @@ export function ApiFormField({ return ( ); + case 'json': + return ( + + ); default: return ( diff --git a/src/frontend/src/components/forms/fields/JsonField.tsx b/src/frontend/src/components/forms/fields/JsonField.tsx new file mode 100644 index 0000000000..a8a62836f0 --- /dev/null +++ b/src/frontend/src/components/forms/fields/JsonField.tsx @@ -0,0 +1,75 @@ +import type { ApiFormFieldType } from '@lib/types/Forms'; +import { JsonInput } from '@mantine/core'; +import { useId } from '@mantine/hooks'; +import { memo, useCallback, useMemo } from 'react'; +import type { FieldValues, UseControllerReturn } from 'react-hook-form'; + +function JsonFieldComponent({ + controller, + definition, + fieldName, + onChange +}: Readonly<{ + controller: UseControllerReturn; + definition: ApiFormFieldType; + fieldName: string; + onChange: (value: any) => void; +}>) { + const fieldId = useId(); + + const { + field, + fieldState: { error } + } = controller; + const { value } = field; + + const formattedValue = useMemo(() => { + if (value === undefined || value === null) { + return ''; + } + + if (typeof value === 'string') { + return value; + } + + if (typeof value === 'object') { + return JSON.stringify(value, null, 2); + } + + return String(value); + }, [value]); + + const handleChange = useCallback( + (nextValue: string) => { + if (nextValue.trim() === '') { + onChange(undefined); + return; + } + + try { + onChange(JSON.parse(nextValue)); + } catch { + onChange(nextValue); + } + }, + [onChange] + ); + + console.log('error', error, 'definition.error', definition.error); + + return ( + + ); +} + +export const JsonField = memo(JsonFieldComponent); diff --git a/src/frontend/src/components/tables/InvenTreeTableHeader.tsx b/src/frontend/src/components/tables/InvenTreeTableHeader.tsx index 666c269754..6fc6fe4121 100644 --- a/src/frontend/src/components/tables/InvenTreeTableHeader.tsx +++ b/src/frontend/src/components/tables/InvenTreeTableHeader.tsx @@ -217,8 +217,22 @@ export default function InvenTreeTableHeader({ onClose={() => clearQueryFilters()} /> )} - - + + {group} ))} - + {!tableProps.tableActionsFullWidth && } {tableProps.enableSearch && ( (''); @@ -199,10 +202,363 @@ function ScimManagementPanel() { function SSOManagementPanel() { const navigate = useNavigate(); + const { getHost } = useLocalState(); + const table = useTable('sso-applications', { idAccessor: 'id' }); + const [oidcCallback, setOidcCallback] = useState(null); + const [samlUrls, setSamlUrls] = useState<{ + acs: string; + sls: string; + metadata: string; + } | null>(null); + const [selectedSsoApplication, setSelectedSsoApplication] = useState< + number | undefined + >(undefined); + + const newGenericSsoApplication = useCreateApiFormModal({ + url: ApiEndpoints.sso_list, + title: t`Add SSO Application`, + table: table, + fields: { + name: {}, + provider: {}, + provider_id: {}, + client_id: {}, + secret: {}, + settings: {} + } + }); + + const newOidcSsoApplication = useCreateApiFormModal({ + url: ApiEndpoints.sso_list, + title: t`Add OIDC SSO Application`, + table: table, + fields: { + provider: { + hidden: true, + value: 'openid_connect' + }, + name: {}, + provider_id: { required: true }, + client_id: {}, + secret: { required: true }, + oauth_pkce_enabled: { + field_type: 'boolean', + label: t`OAuth PKCE Enabled`, + description: t`Use Proof Key for Code Exchange during OIDC login with this application`, + default: true + }, + server_url: { + field_type: 'string', + label: t`OIDC Server URL`, + description: t`Base URL of the OIDC provider` + }, + uid_field: { + field_type: 'string', + label: t`UID Field`, + description: t`OIDC claim used as the user's unique identifier`, + default: 'sub' + } + }, + processFormData: (data) => { + const { oauth_pkce_enabled, server_url, uid_field, ...applicationData } = + data; + + return { + ...applicationData, + settings: { + oauth_pkce_enabled, + server_url, + uid_field + } + }; + }, + onFormSuccess: (data) => { + setOidcCallback( + new URL( + `/accounts/oidc/${data.provider_id}/login/callback/`, + getHost() + ).toString() + ); + } + }); + + const newSamlSsoApplication = useCreateApiFormModal({ + url: ApiEndpoints.sso_list, + title: t`Add SAML SSO Application`, + table: table, + fields: { + provider: { + hidden: true, + value: 'saml' + }, + name: { + label: t`Name`, + description: t`Display name for this SAML identity provider` + }, + provider_id: { + required: true, + label: t`Provider ID`, + description: t`Unique provider identifier, normally the IdP entity ID` + }, + client_id: { + required: true, + label: t`Organization Slug`, + description: t`URL-safe identifier used in SAML login and metadata URLs` + }, + idp: { + field_type: 'nested object', + label: t`Identity Provider Settings`, + children: { + entity_id: { + field_type: 'string', + required: true, + label: t`IdP Entity ID`, + description: t`Entity ID of the SAML identity provider` + }, + metadata_url: { + field_type: 'url', + label: t`IdP Metadata URL`, + description: t`Use this or provide the inline IdP settings below` + }, + sso_url: { + field_type: 'url', + label: t`IdP SSO URL`, + description: t`Inline IdP single sign-on URL` + }, + slo_url: { + field_type: 'url', + label: t`IdP SLO URL`, + description: t`Inline IdP single logout URL` + }, + x509cert: { + field_type: 'string', + label: t`IdP X.509 Certificate`, + description: t`Inline IdP signing certificate` + } + } + }, + sp: { + field_type: 'nested object', + label: t`Service Provider Settings`, + children: { + entity_id: { + field_type: 'string', + label: t`SP Entity ID`, + description: t`Optional service provider entity ID` + } + } + }, + account: { + field_type: 'nested object', + label: t`Account Mapping`, + children: { + attribute_mapping: { + field_type: 'json', + label: t`Attribute Mapping`, + description: t`Map SAML attributes to uid, email, and email_verified` + }, + use_nameid_for_email: { + field_type: 'boolean', + label: t`Use NameID for Email`, + description: t`Use the SAML NameID value as the user's email address` + } + } + } + }, + processFormData: (data) => { + const { idp, sp, account, ...applicationData } = data; + + return { + ...applicationData, + settings: { + ...account, + idp, + sp + } + }; + }, + onFormSuccess: (data) => { + const baseUrl = getHost(); + const organization = data.client_id; + + setSamlUrls({ + acs: new URL(`/accounts/saml/${organization}/acs/`, baseUrl).toString(), + sls: new URL(`/accounts/saml/${organization}/sls/`, baseUrl).toString(), + metadata: new URL( + `/accounts/saml/${organization}/metadata/`, + baseUrl + ).toString() + }); + } + }); + + const editSsoApplication = useEditApiFormModal({ + url: ApiEndpoints.sso_list, + pk: selectedSsoApplication, + title: t`Edit SSO Application`, + table: table, + fields: { + name: {}, + provider: {}, + provider_id: {}, + client_id: {}, + secret: {}, + settings: {} + } + }); + + const deleteSsoApplication = useDeleteApiFormModal({ + url: ApiEndpoints.sso_list, + pk: selectedSsoApplication, + title: t`Delete SSO Application`, + table: table + }); + + const ssoColumns = useMemo( + () => [ + { + accessor: 'name', + title: t`Name`, + sortable: true, + switchable: false + }, + { + accessor: 'provider', + title: t`Provider`, + sortable: true, + switchable: true + }, + { + accessor: 'provider_id', + title: t`Provider ID`, + sortable: true, + switchable: true + }, + { + accessor: 'client_id', + title: t`Client ID`, + sortable: true, + switchable: true + } + ], + [] + ); + + const rowActions = useCallback( + (record: any): RowAction[] => [ + RowEditAction({ + onClick: () => { + setSelectedSsoApplication(record.id); + editSsoApplication.open(); + } + }), + RowDeleteAction({ + onClick: () => { + setSelectedSsoApplication(record.id); + deleteSsoApplication.open(); + } + }) + ], + [deleteSsoApplication, editSsoApplication] + ); + + const tableActions = useMemo( + () => [ + , + , + + ], + [newGenericSsoApplication, newOidcSsoApplication, newSamlSsoApplication] + ); return ( - TBD + + + Frontend Single Sign-On (SSO) is based on django-allauth. By default + generic OIDC (client) and SAML providers are enabled. +
+ You can add more specific providers using the + `INVENTREE_SOCIAL_BACKENDS` config key. After a restart those + providers become available below. +
+ The documentation goes more in depth on SSO setup steps. +
+
+ {newGenericSsoApplication.modal} + setOidcCallback(null)} + title={{t`OIDC Callback URL`}} + centered + > + + {t`Add this callback URL to your OIDC provider.`} + + + {oidcCallback} + + + + + + setSamlUrls(null)} + title={{t`SAML Service URLs`}} + centered + > + + {t`Register these URLs with your SAML identity provider.`} + Endpoint, URL], + body: [ + [ACS, samlUrls?.acs], + [SLS, samlUrls?.sls], + [Metadata, samlUrls?.metadata] + ] + }} + /> + + + {newOidcSsoApplication.modal} + {newSamlSsoApplication.modal} + {editSsoApplication.modal} + {deleteSsoApplication.modal} +