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."""
+1
View File
@@ -266,6 +266,7 @@ export enum ApiEndpoints {
scim_generate = 'admin/scim/generate/',
scim_disable = 'admin/scim/disable/',
config_list = 'admin/config/',
sso_list = 'admin/sso/',
parameter_list = 'parameter/',
parameter_template_list = 'parameter/template/',
tag_list = 'tag/',
+2 -1
View File
@@ -100,7 +100,8 @@ export type ApiFormFieldType = {
| 'nested object'
| 'dependent field'
| 'table'
| 'tags';
| 'tags'
| 'json';
api_url?: string;
pk_field?: string;
model?: ModelType;
+2
View File
@@ -184,6 +184,7 @@ export type RowViewProps = RowAction & RowModelProps & RowViewBehaviorProps;
* @param barcodeActions : any[] - List of barcode actions
* @param tableFilters : TableFilter[] - List of custom filters
* @param tableActions : any[] - List of custom action groups
* @param tableActionsFullWidth : boolean - Allow custom table actions to use the available header width
* @param isRecordSelectable : (record: any, index: number) => 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<T = any> = {
barcodeActions?: React.ReactNode[];
tableFilters?: TableFilter[];
tableActions?: React.ReactNode[];
tableActionsFullWidth?: boolean;
isRecordSelectable?: (record: T, index: number) => boolean;
rowExpansion?: DataTableRowExpansionProps<T>;
dataFormatter?: (data: any) => any;
@@ -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 (
<TagsField controller={controller} definition={fieldDefinition} />
);
case 'json':
return (
<JsonField
controller={controller}
definition={fieldDefinition}
fieldName={fieldName}
onChange={onChange}
/>
);
default:
return (
<Alert color='red' title={t`Error`}>
@@ -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<FieldValues, any>;
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 (
<JsonInput
label={definition.label}
description={definition.description}
placeholder={definition.placeholder}
defaultValue={undefined}
value={formattedValue}
id={fieldId}
aria-label={`json-field-${fieldName}`}
error={definition.error ?? error?.message}
onChange={handleChange}
/>
);
}
export const JsonField = memo(JsonFieldComponent);
@@ -217,8 +217,22 @@ export default function InvenTreeTableHeader({
onClose={() => clearQueryFilters()}
/>
)}
<Group justify='apart' grow wrap='nowrap'>
<Group justify='left' key='custom-actions' gap={5} wrap='nowrap'>
<Group
justify='apart'
grow={!tableProps.tableActionsFullWidth}
wrap='nowrap'
>
<Group
justify='left'
key='custom-actions'
gap={5}
wrap='nowrap'
style={
tableProps.tableActionsFullWidth
? { flex: '1 1 auto', minWidth: 'max-content' }
: undefined
}
>
<PrintingActions
items={printingIdValues}
modelType={tableProps.modelType}
@@ -249,7 +263,7 @@ export default function InvenTreeTableHeader({
<Fragment key={idx}>{group}</Fragment>
))}
</Group>
<Space />
{!tableProps.tableActionsFullWidth && <Space />}
<Group justify='right' gap={5} wrap='nowrap'>
{tableProps.enableSearch && (
<SearchInput
@@ -1,6 +1,6 @@
import { AddItemButton } from '@lib/components/AddItemButton';
import { CopyButton } from '@lib/components/CopyButton';
import { RowDeleteAction } from '@lib/components/RowActions';
import { RowDeleteAction, RowEditAction } from '@lib/components/RowActions';
import type { RowAction } from '@lib/components/RowActions';
import { StylishText } from '@lib/components/StylishText';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
@@ -31,6 +31,7 @@ import { showNotification } from '@mantine/notifications';
import {
IconArrowBigLeft,
IconArrowBigRight,
IconPlus,
IconShieldLock,
IconShieldOff
} from '@tabler/icons-react';
@@ -43,8 +44,10 @@ import { InvenTreeTable } from '../../../../components/tables/InvenTreeTable';
import { showApiErrorMessage } from '../../../../functions/notifications';
import {
useCreateApiFormModal,
useDeleteApiFormModal
useDeleteApiFormModal,
useEditApiFormModal
} from '../../../../hooks/UseForm';
import { useLocalState } from '../../../../states/LocalState';
function ScimManagementPanel() {
const [secret, setSecret] = useState<string>('');
@@ -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<string | null>(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(
() => [
<Button
key={'add-generic-sso-application'}
leftSection={<IconPlus size={16} />}
onClick={() => newGenericSsoApplication.open()}
>
<Trans>Add Generic App</Trans>
</Button>,
<Button
key={'add-oidc-sso-application'}
leftSection={<IconPlus size={16} />}
onClick={() => newOidcSsoApplication.open()}
>
<Trans>Add OIDC App</Trans>
</Button>,
<Button
key={'add-saml-sso-application'}
leftSection={<IconPlus size={16} />}
onClick={() => newSamlSsoApplication.open()}
>
<Trans>Add SAML App</Trans>
</Button>
],
[newGenericSsoApplication, newOidcSsoApplication, newSamlSsoApplication]
);
return (
<Stack gap='md'>
TBD
<Text>
<Trans>
Frontend Single Sign-On (SSO) is based on django-allauth. By default
generic OIDC (client) and SAML providers are enabled.
<br />
You can add more specific providers using the
`INVENTREE_SOCIAL_BACKENDS` config key. After a restart those
providers become available below.
<br />
The documentation goes more in depth on SSO setup steps.
</Trans>
</Text>
{newGenericSsoApplication.modal}
<Modal
opened={oidcCallback !== null}
onClose={() => setOidcCallback(null)}
title={<StylishText size='xl'>{t`OIDC Callback URL`}</StylishText>}
centered
>
<Stack gap='sm'>
<Text>{t`Add this callback URL to your OIDC provider.`}</Text>
<Group justify='space-between' wrap='nowrap'>
<Code style={{ wordBreak: 'break-all', whiteSpace: 'normal' }}>
{oidcCallback}
</Code>
<CopyButton value={oidcCallback ?? ''} />
</Group>
</Stack>
</Modal>
<Modal
opened={samlUrls !== null}
onClose={() => setSamlUrls(null)}
title={<StylishText size='xl'>{t`SAML Service URLs`}</StylishText>}
centered
>
<Stack gap='sm'>
<Text>{t`Register these URLs with your SAML identity provider.`}</Text>
<Table
data={{
head: [<Trans>Endpoint</Trans>, <Trans>URL</Trans>],
body: [
[<Trans>ACS</Trans>, samlUrls?.acs],
[<Trans>SLS</Trans>, samlUrls?.sls],
[<Trans>Metadata</Trans>, samlUrls?.metadata]
]
}}
/>
</Stack>
</Modal>
{newOidcSsoApplication.modal}
{newSamlSsoApplication.modal}
{editSsoApplication.modal}
{deleteSsoApplication.modal}
<InvenTreeTable
tableState={table}
url={apiUrl(ApiEndpoints.sso_list)}
columns={ssoColumns}
props={{
enableSearch: true,
enableColumnSwitching: true,
enableSelection: false,
enablePagination: true,
enableRefresh: true,
tableActionsFullWidth: true,
rowActions: rowActions,
tableActions: tableActions
}}
/>
<GlobalSettingList
heading={t`Single Sign-On (SSO) Settings`}
keys={[