feat(backend): enable OIDC/oAuth2 provider by default (#12731)

* Add SCIM
Closes https://github.com/inventree/InvenTree/issues/6339

* also test scim

* extend testing with conformance suite

* fix test suite results

* coverage completion

* fil test gaps

* add missing schema values

* fix more type issues

* fix error view

* remove route from OpenAPI

* User permissions check for Attachment API (#12689)

* enable oidc proivder by default

* add .well-known entry for OIDC server

* add default client registration

* add changelog entry

* add codeowners

* remove redundant info

* re-name and restructure panel

* add a smal explainer section on top

* flesh out section a bit more

* change name

* basic sso panel

* refactor rendering

* add api and mgmt functions

* add create/delete options

* simplify test

* handle secret generation

* fix merge

* update coverage

* add test

* reduce user friction

* add better labels

* make it more clear that this is only shown once

* add regenerate function

* update tests

* simplify test calls

* fix test
This commit is contained in:
Matthias Mair
2026-09-05 12:10:13 +10:00
committed by GitHub
parent fa44925b21
commit 0a4f095397
14 changed files with 876 additions and 212 deletions
@@ -1,11 +1,14 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 541
INVENTREE_API_VERSION = 542
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v542 -> 2026-09-03 : https://github.com/inventree/InvenTree/pull/12731
- Adds management APIs for oAuth2 provider applications
v541 -> 2026-09-03 : https://github.com/inventree/InvenTree/pull/12770
- Prevent DELETE operation against the /api/user/me/ endpoint
@@ -23,7 +26,7 @@ v537 -> 2026-08-31 : https://github.com/inventree/InvenTree/pull/11971
- Adds a generic "Note" model which can be attached to any model type via a generic foreign key relationship
- Allow multiple notes to be attached to a single object, and for notes to be created / edited / deleted via the API
v536 -> 2026-08-30 : https://github.com/inventree/InvenTree/pull/xxxx
v536 -> 2026-08-30 : https://github.com/inventree/InvenTree/pull/12713
- Adds SCIM 2 provisioning support
v535 -> 2026-08-29 : https://github.com/inventree/InvenTree/pull/12739
+35
View File
@@ -23,6 +23,9 @@ from InvenTree.ready import ignore_ready_warning
logger = structlog.get_logger('inventree')
MIGRATIONS_CHECK_DONE = False
OIDC_CLIENT_CHECKED = False
DEFAULT_OIDC_APP_ID = 'zDFnsiRheJIOKNx6aCQ0quBxECg1QBHtVFDPloJ6'
class InvenTreeConfig(AppConfig):
"""AppConfig for inventree app."""
@@ -89,6 +92,7 @@ class InvenTreeConfig(AppConfig):
if InvenTree.ready.canAppAccessDatabase() or settings.TESTING_ENV:
self.add_user_on_startup()
self.add_user_from_file()
self.add_oidc_default_application()
# register event receiver and connect signal for SSO group sync. The connected signal is
# used for account updates whereas the receiver is used for the initial account creation.
@@ -335,6 +339,37 @@ class InvenTreeConfig(AppConfig):
# do not try again
settings.USER_ADDED_FILE = True
@ignore_ready_warning
def add_oidc_default_application(self):
"""Add the default OIDC application for InvenTree clients."""
global OIDC_CLIENT_CHECKED
if OIDC_CLIENT_CHECKED:
return
from oauth2_provider.models import Application
if Application.objects.filter(
client_id=DEFAULT_OIDC_APP_ID
).exists(): # pragma: no cover
logger.info('Default OIDC client already exists - skipping creation')
OIDC_CLIENT_CHECKED = True
return
# Create the default OIDC client
client = Application.objects.create(
name='InvenTree default client',
client_id=DEFAULT_OIDC_APP_ID,
post_logout_redirect_uris=[f'{settings.SITE_URL}/'],
client_type=Application.CLIENT_PUBLIC,
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
redirect_uris=[f'{settings.SITE_URL}/oidc/callback/', 'http://localhost'],
# scopes='openid profile email g:read',
algorithm=Application.RS256_ALGORITHM,
skip_authorization=True,
)
logger.info('Default OIDC client created: %s', client)
OIDC_CLIENT_CHECKED = True
def ensure_migrations_done(self=None):
"""Ensures there are no open migrations, stop if inconsistent state."""
global MIGRATIONS_CHECK_DONE
+2 -2
View File
@@ -1163,7 +1163,7 @@ FLAGS = {
'NEXT_GEN': [
{'condition': 'parameter', 'value': 'ngen='}
], # Should next-gen features be turned on?
'OIDC': [{'condition': 'parameter', 'value': 'oidc='}],
'OIDC': [{'condition': 'boolean', 'value': True}],
}
# Get custom flags from environment/yaml
@@ -1191,7 +1191,7 @@ OAUTH2_PROVIDER = {
# OIDC
'OIDC_ENABLED': True,
'OIDC_RSA_PRIVATE_KEY': get_oidc_private_key(),
'PKCE_REQUIRED': False,
'PKCE_REQUIRED': True,
}
OAUTH2_CHECK_EXCLUDED = [ # This setting mutes schema checks for these rule/method combinations
'/api/email/generate/:post',
+167 -1
View File
@@ -1,18 +1,22 @@
"""Low level tests for the InvenTree API."""
import hashlib
from base64 import b64encode
from pathlib import Path
from tempfile import TemporaryDirectory
from urllib.parse import parse_qs, urlencode, urlsplit
from django.contrib.auth import get_user_model
from django.core.exceptions import AppRegistryNotReady
from django.test import TestCase
from django.test import TestCase, override_settings
from django.urls import reverse
from oauth2_provider.models import Application
from rest_framework import status
from InvenTree.api import read_license_file
from InvenTree.api_version import INVENTREE_API_VERSION
from InvenTree.apps import DEFAULT_OIDC_APP_ID
from InvenTree.exceptions import exception_handler
from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase
from InvenTree.version import inventreeApiText, parse_version_text
@@ -90,6 +94,168 @@ class ExceptionHandlerTests(TestCase):
self.assertEqual(response['Retry-After'], '1')
@override_settings(
SITE_URL='http://testserver', CSRF_TRUSTED_ORIGINS=['http://testserver']
)
class OAuth2ApplicationAPITests(InvenTreeAPITestCase):
"""Tests for the built-in OIDC application metadata and deletion guard."""
superuser = True
def test_builtin_client_metadata_and_delete_block(self):
"""The built-in default OIDC client should be flagged and protected from deletion."""
Application.objects.filter(client_id=DEFAULT_OIDC_APP_ID).delete()
built_in = Application.objects.create(
name='Built-In OIDC Client',
client_id=DEFAULT_OIDC_APP_ID,
client_secret='secret',
redirect_uris='https://example.com/callback',
client_type=Application.CLIENT_PUBLIC,
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
algorithm=Application.RS256_ALGORITHM,
)
response = self.get(reverse('api-oauth2-list'))
payload = response.json()
self.assertTrue(
any(
item['client_id'] == DEFAULT_OIDC_APP_ID and item['is_builtin']
for item in payload
)
)
# no delete
response = self.delete(
reverse('api-oauth2-detail', kwargs={'pk': built_in.pk}), expected_code=403
)
self.assertTrue(Application.objects.filter(pk=built_in.pk).exists())
# no secret regeneration
self.post(
reverse('api-oauth2-regenerate', kwargs={'pk': built_in.pk}),
expected_code=403,
)
def test_create_application(self):
"""An admin should be able to create a custom OAuth2 application."""
payload = {
'name': 'Custom OAuth App',
'client_type': Application.CLIENT_PUBLIC,
'authorization_grant_type': Application.GRANT_AUTHORIZATION_CODE,
'redirect_uris': 'https://example.com/callback',
'post_logout_redirect_uris': 'https://example.com/logout',
'skip_authorization': False,
'algorithm': Application.RS256_ALGORITHM,
}
response = self.post(
reverse('api-oauth2-list'), payload, expected_code=201, format='json'
)
self.assertTrue(Application.objects.filter(name='Custom OAuth App').exists())
payload = response.json()
self.assertIn('client_id', payload)
secret_1 = payload['client_secret']
assert secret_1 is not None
self.assertNotEqual(secret_1, '')
self.assertFalse(secret_1.startswith('pbkdf2_sha256$'))
# repeated GET should not return the plaintext secret
response = self.get(reverse('api-oauth2-detail', kwargs={'pk': payload['id']}))
payload = response.json()
self.assertIn('client_id', payload)
self.assertNotIn('client_secret', payload)
# regenerating the secret should return a new plaintext secret
response = self.post(
reverse('api-oauth2-regenerate', kwargs={'pk': payload['id']}),
expected_code=200,
)
result = response.json()
secret_2 = result['client_secret']
assert secret_2 is not None
self.assertFalse(secret_2.startswith('pbkdf2_sha256$'))
self.assertNotEqual(secret_1, secret_2)
def test_oauth2_application_token_can_access_profile(self):
"""A custom OAuth2 client should be able to use a valid token to read the current profile."""
payload = {
'name': 'Profile OAuth App',
'client_type': Application.CLIENT_CONFIDENTIAL,
'authorization_grant_type': Application.GRANT_AUTHORIZATION_CODE,
'redirect_uris': 'https://example.com/callback',
'post_logout_redirect_uris': 'https://example.com/logout',
'skip_authorization': False,
'algorithm': Application.RS256_ALGORITHM,
}
response = self.post(
reverse('api-oauth2-list'), payload, expected_code=201, format='json'
)
app = response.json()
client_id = app['client_id']
client_secret = app['client_secret']
challenge_verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'
code_challenge = (
__import__('base64')
.urlsafe_b64encode(
hashlib.sha256(challenge_verifier.encode('utf-8')).digest()
)
.rstrip(b'=')
.decode('ascii')
)
auth_params = {
'client_id': client_id,
'redirect_uri': 'https://example.com/callback',
'response_type': 'code',
'scope': 'openid g:read',
'state': 'abc123',
'code_challenge': code_challenge,
'code_challenge_method': 'S256',
}
self.logout()
response = self.get(reverse('api-user-profile'), expected_code=401)
self.login()
response = self.get(reverse('oauth2_provider:authorize'), auth_params)
response = self.post(
reverse('oauth2_provider:authorize'),
{**auth_params, 'allow': 'true'},
format=None,
expected_code=302,
)
self.assertIn('code=', response['Location'])
code = parse_qs(urlsplit(response['Location']).query)['code'][0]
response = self.post(
reverse('oauth2_provider:token'),
urlencode({
'grant_type': 'authorization_code',
'client_id': client_id,
'client_secret': client_secret,
'code': code,
'redirect_uri': 'https://example.com/callback',
'code_verifier': challenge_verifier,
}),
format=None,
content_type='application/x-www-form-urlencoded',
expected_code=200,
)
token_data = response.json()
self.assertIn('access_token', token_data)
access_token = token_data['access_token']
self.logout()
response = self.get(
reverse('api-user-profile'), HTTP_AUTHORIZATION=f'Bearer {access_token}'
)
profile = response.json()
self.assertIn('language', profile)
self.assertIn('theme', profile)
self.assertIn('widgets', profile)
class ApiAccessTests(InvenTreeAPITestCase):
"""Tests for various access scenarios with the InvenTree API."""
+48
View File
@@ -27,6 +27,8 @@ from drf_spectacular.utils import (
extend_schema_view,
)
from error_report.models import Error
from oauth2_provider.generators import generate_client_secret
from oauth2_provider.models import Application
from opentelemetry import trace
from pint._typing import UnitLike
from rest_framework import serializers, viewsets
@@ -45,6 +47,7 @@ import InvenTree.conversion
import InvenTree.models
import InvenTree.ready
from common.icons import get_icon_packs
from common.serializers import OAuth2ApplicationSerializer
from common.settings import get_global_setting
from data_exporter.mixins import DataExportViewMixin
from generic.states.api import urlpattern as generic_states_api_urls
@@ -56,10 +59,12 @@ from InvenTree.api import (
SimpleGenericMetadataView,
meta_path,
)
from InvenTree.apps import DEFAULT_OIDC_APP_ID
from InvenTree.config import CONFIG_LOOKUPS
from InvenTree.filters import ORDER_FILTER, SEARCH_ORDER_FILTER
from InvenTree.helpers import inheritors, str2bool
from InvenTree.helpers_api import (
CleanModelViewSet,
InvenTreeApiRouter,
RetrieveDestroyModelViewSet,
RetrieveUpdateDestroyModelViewSet,
@@ -1760,6 +1765,49 @@ class ObservabilityEnd(CreateAPI):
return Response({'status': 'ok'})
class ApplicationViewSet(CleanModelViewSet):
"""Manage a oAuth2 (provider side) application."""
queryset = Application.objects.all()
serializer_class = OAuth2ApplicationSerializer
def destroy(self, request, *args, **kwargs):
"""Delete an OAuth2 application.
Deletion of the built-in default OIDC client is not allowed.
"""
instance = self.get_object()
if instance.client_id == DEFAULT_OIDC_APP_ID:
raise PermissionDenied(
_('The built-in default OIDC client cannot be deleted.')
)
return super().destroy(request, *args, **kwargs)
@extend_schema(request=None, responses={200: OAuth2ApplicationSerializer()})
@action(detail=True, methods=['post'])
def regenerate(self, request, *args, **kwargs):
"""Regenerate the client secret."""
instance = self.get_object()
if instance.client_id == DEFAULT_OIDC_APP_ID:
raise PermissionDenied(
_('The built-in default OIDC client secret cannot be regenerated.')
)
secret = generate_client_secret()
instance.client_secret = secret
instance._raw_client_secret = secret
instance.save()
serializer = self.get_serializer(instance)
return Response(serializer.data)
# oAuth2 admin
admin_router.register('oauth2', ApplicationViewSet, basename='api-oauth2')
selection_urls = [
path(
'<int:pk>/',
@@ -10,6 +10,8 @@ from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from error_report.models import Error
from flags.state import flag_state
from oauth2_provider.generators import generate_client_secret
from oauth2_provider.models import Application
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from taggit.models import Tag
@@ -19,6 +21,7 @@ import common.models as common_models
import common.validators
import generic.states.custom
from importer.registry import register_importer
from InvenTree.apps import DEFAULT_OIDC_APP_ID
from InvenTree.helpers import get_objectreference
from InvenTree.helpers_model import construct_absolute_url
from InvenTree.mixins import DataImportExportSerializerMixin
@@ -1284,3 +1287,55 @@ class InstanceInfoSerializer(serializers.Serializer):
help_text=_('Number of parameters associated with this instance'),
read_only=True,
)
class OAuth2ApplicationSerializer(serializers.ModelSerializer):
"""Serializer for OAuth2 application records."""
class Meta:
"""Meta options for OAuth2ApplicationSerializer."""
model = Application
fields = [
'id',
'client_id',
'client_secret',
'name',
'client_type',
'authorization_grant_type',
'redirect_uris',
'post_logout_redirect_uris',
'skip_authorization',
'algorithm',
'is_builtin',
]
read_only_fields = ['id', 'client_id', 'client_secret', 'is_builtin']
is_builtin = serializers.SerializerMethodField()
@extend_schema_field(serializers.BooleanField())
def get_is_builtin(self, obj: Application) -> bool:
"""Indicate whether this OAuth2 application is the built-in InvenTree client."""
return obj.client_id == DEFAULT_OIDC_APP_ID
def create(self, validated_data):
"""Preserve the plaintext client secret for the create response before hashing."""
raw_secret = validated_data.get('client_secret', None)
if raw_secret is None:
raw_secret = generate_client_secret()
validated_data['client_secret'] = raw_secret
instance = Application(**validated_data)
instance._raw_client_secret = raw_secret
instance.save()
return instance
def to_representation(self, instance):
"""Expose the plaintext secret only for a newly-created OAuth app instance."""
data = super().to_representation(instance)
raw_secret = getattr(instance, '_raw_client_secret', None)
if raw_secret is not None:
data['client_secret'] = raw_secret
else:
data.pop('client_secret', None)
return data
@@ -4,6 +4,8 @@ from django.http import HttpRequest, JsonResponse
from django.urls import path, reverse_lazy
from django.utils.translation import gettext_lazy as _
from flags.state import flag_enabled
import InvenTree.helpers
from InvenTree.permissions import auth_exempt
from plugin import InvenTreePlugin
@@ -29,7 +31,17 @@ class InvenTreeWellKnown(WellKnownMixin, UrlsMixin, InvenTreePlugin):
# See https://www.w3.org/TR/passkey-endpoints/
data.append(('passkey-endpoints', reverse_lazy(f'plugin:{self.slug}:passkey')))
# placeholder for more
# Check if OIDC is enabled, and if so, add the relevant entries
try:
if flag_enabled('OIDC', request=request):
data.append((
'openid-configuration',
str(reverse_lazy('oauth2_provider:oidc-connect-discovery-info')),
))
except Exception: # pragma: no cover
# If the flag is not evaluated successfully, we can ignore it
pass
return data
@auth_exempt
@@ -4,7 +4,7 @@ import datetime
from django.utils.translation import gettext_lazy as _
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
from oauth2_provider.contrib.rest_framework import OAuth2ProtectedResourceAuthentication
from rest_framework import exceptions
from rest_framework.authentication import TokenAuthentication
@@ -42,5 +42,5 @@ class ApiTokenAuthentication(TokenAuthentication):
return (user, token)
class ExtendedOAuth2Authentication(OAuth2Authentication):
class ExtendedOAuth2Authentication(OAuth2ProtectedResourceAuthentication):
"""Custom implementation of OAuth2Authentication class to support custom scope rendering."""
+2
View File
@@ -260,6 +260,8 @@ export enum ApiEndpoints {
notes_image_list = 'note/image/',
email_list = 'admin/email/',
email_test = 'admin/email/test/',
admin_oauth = 'admin/oauth2/',
admin_oauth_regenerate = 'admin/oauth2/:id/regenerate/',
scim_config = 'admin/scim/',
scim_generate = 'admin/scim/generate/',
scim_disable = 'admin/scim/disable/',
@@ -0,0 +1,539 @@
import { AddItemButton } from '@lib/components/AddItemButton';
import { CopyButton } from '@lib/components/CopyButton';
import { RowDeleteAction } from '@lib/components/RowActions';
import type { RowAction } from '@lib/components/RowActions';
import { StylishText } from '@lib/components/StylishText';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
import { navigateToLink } from '@lib/functions/Navigation';
import useTable from '@lib/hooks/UseTable';
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import {
Accordion,
Alert,
Anchor,
Badge,
Button,
Code,
Divider,
Group,
Loader,
Modal,
Paper,
SimpleGrid,
Stack,
Table,
Text
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import {
IconArrowBigLeft,
IconArrowBigRight,
IconShieldLock,
IconShieldOff
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api, queryClient } from '../../../../App';
import { GlobalSettingList } from '../../../../components/settings/SettingList';
import { InvenTreeTable } from '../../../../components/tables/InvenTreeTable';
import { showApiErrorMessage } from '../../../../functions/notifications';
import {
useCreateApiFormModal,
useDeleteApiFormModal
} from '../../../../hooks/UseForm';
function ScimManagementPanel() {
const [secret, setSecret] = useState<string>('');
const [
secretModalOpened,
{ open: openSecretModal, close: closeSecretModal }
] = useDisclosure(false);
const { data, isFetching } = useQuery({
queryKey: ['scim-config'],
queryFn: () =>
api.get(apiUrl(ApiEndpoints.scim_config)).then((res) => res.data),
refetchOnMount: true
});
const generateSecret = (action: 'generate' | 'rotate') => {
api
.post(apiUrl(ApiEndpoints.scim_generate))
.then((res) => {
setSecret(res.data.secret);
openSecretModal();
queryClient.invalidateQueries({ queryKey: ['scim-config'] });
showNotification({
title:
action === 'generate' ? t`SCIM enabled` : t`SCIM secret rotated`,
message: t`The new bearer secret is only shown once`,
color: 'green'
});
})
.catch((error) => {
showApiErrorMessage({ error, title: t`Error generating SCIM secret` });
});
};
const disableScim = () => {
api
.post(apiUrl(ApiEndpoints.scim_disable))
.then(() => {
queryClient.invalidateQueries({ queryKey: ['scim-config'] });
showNotification({
title: t`SCIM disabled`,
message: t`The SCIM provisioning endpoint has been disabled and its secret revoked`,
color: 'blue'
});
})
.catch((error) => {
showApiErrorMessage({ error, title: t`Error disabling SCIM` });
});
};
const scimTableData = useMemo(
() => [
[
<Trans>Status</Trans>,
data?.enabled ? (
<Badge color='green'>
<Trans>Enabled</Trans>
</Badge>
) : (
<Badge color='gray'>
<Trans>Disabled</Trans>
</Badge>
)
],
[
<Trans>Base URL</Trans>,
<Group gap='xs' wrap='nowrap'>
<Code>{data?.base_url}</Code>
<CopyButton value={data?.base_url} />
</Group>
],
[<Trans>Secret Generated</Trans>, data?.secret_generated ?? '-'],
[<Trans>Last Used</Trans>, data?.last_used ?? '-']
],
[data?.enabled, data?.base_url, data?.secret_generated, data?.last_used]
);
if (isFetching && !data) {
return <Loader />;
}
return (
<Stack gap='md'>
<Modal
opened={secretModalOpened}
onClose={closeSecretModal}
title={<StylishText size='xl'>{t`SCIM Bearer Secret`}</StylishText>}
centered
data-testid='scim-secret-modal'
>
<Alert color='yellow' mb='sm'>
<Trans>
This secret is only shown once - copy it now and store it in your
Identity Provider's SCIM configuration. It cannot be retrieved
again, only rotated.
</Trans>
</Alert>
<Paper p='sm' withBorder>
<Group justify='space-between' wrap='nowrap'>
<Code style={{ wordBreak: 'break-all', whiteSpace: 'normal' }}>
{secret}
</Code>
<CopyButton value={secret} />
</Group>
</Paper>
</Modal>
<Alert icon={<IconShieldLock />} color='blue'>
<Trans>
SCIM allows an external Identity Provider (e.g. Okta, Microsoft Entra
ID, OneLogin) to automatically provision and deprovision Users and
Groups.
</Trans>
</Alert>
<Table data={{ body: scimTableData }} />
<Divider />
<Group>
<Button
leftSection={<IconShieldLock size={16} />}
onClick={() => generateSecret(data?.enabled ? 'rotate' : 'generate')}
>
{data?.enabled ? (
<Trans>Rotate Secret</Trans>
) : (
<Trans>Enable SCIM</Trans>
)}
</Button>
{data?.enabled && (
<Button
color='red'
variant='outline'
leftSection={<IconShieldOff size={16} />}
onClick={disableScim}
>
<Trans>Disable SCIM</Trans>
</Button>
)}
</Group>
<Text size='sm' c='dimmed'>
<Trans>
Rotating the secret immediately invalidates the previous one - update
your Identity Provider's configuration straight away.
</Trans>
</Text>
</Stack>
);
}
function SSOManagementPanel() {
const navigate = useNavigate();
return (
<Stack gap='md'>
TBD
<GlobalSettingList
heading={t`Single Sign-On (SSO) Settings`}
keys={[
'LOGIN_ENABLE_SSO',
'LOGIN_ENABLE_SSO_REG',
'LOGIN_SIGNUP_SSO_AUTO'
]}
/>
<Alert color='blue'>
<Trans>
More settings can be found in the{' '}
<Anchor
onClick={(event: any) =>
navigateToLink('/settings/system/authentication', navigate, event)
}
style={{ textDecoration: 'underline' }}
>
system settings
</Anchor>
.
</Trans>
</Alert>
</Stack>
);
}
function OAuthCredentialsModal({
opened,
onClose,
client,
title
}: {
opened: boolean;
onClose: () => void;
client: {
client_id?: string;
client_secret?: string;
};
title: string;
}) {
return (
<Modal
opened={opened}
onClose={onClose}
title={title}
centered
size='auto'
styles={{
body: { minWidth: '40rem' },
content: { width: 'fit-content' }
}}
>
<Stack gap='sm'>
<Alert color='red' mb='sm'>
<Trans>
Copy these values now. The client secret is only shown once.
</Trans>
</Alert>
<Table
withTableBorder
withColumnBorders
data={{
head: [<Trans>Field</Trans>, <Trans>Value</Trans>],
body: [
[
<Text fw={600} size='sm'>
<Trans>Client ID</Trans>
</Text>,
<Group justify='space-between' wrap='nowrap'>
<Code block>{client.client_id ?? '-'}</Code>
<CopyButton value={client.client_id ?? ''} />
</Group>
],
[
<Text fw={600} size='sm'>
<Trans>Client Secret</Trans>
</Text>,
<Group justify='space-between' wrap='nowrap'>
<Code block>{client.client_secret ?? '-'}</Code>
<CopyButton value={client.client_secret ?? ''} />
</Group>
]
]
}}
/>
</Stack>
</Modal>
);
}
function OAuthManagementPanel() {
const table = useTable('oauth-applications', { idAccessor: 'id' });
const [selectedOAuthApplication, setSelectedOAuthApplication] = useState<
number | undefined
>(undefined);
const [createdClient, setCreatedClient] = useState<{
client_id?: string;
client_secret?: string;
}>({});
const [createdModalOpened, setCreatedModalOpened] = useState(false);
const [modalTitle, setModalTitle] = useState(t`OAuth application created`);
const newOAuthApplication = useCreateApiFormModal({
url: ApiEndpoints.admin_oauth,
title: t`Add OAuth Application`,
table: table,
fields: {
name: {
label: t`Name`,
description: t`A human-readable name for the OAuth application`
},
client_type: {
label: t`Client Type`,
description: t`The type of OAuth client (confidential or public - prefer public for browser-based applications)`,
default: 'public'
},
authorization_grant_type: {
label: t`Authorization Grant Type`,
description: t`The type of OAuth2 grant schema to use - authorization code is recommended for most applications`,
default: 'authorization-code'
},
redirect_uris: {},
post_logout_redirect_uris: {},
skip_authorization: {
field_type: 'boolean',
label: t`Skip Authorization`,
description: t`If enabled, users will not be prompted to authorize this application when logging in - use with caution!`
},
algorithm: {
label: t`Sign Algorithm`,
description: t`The algorithm used to sign the OAuth2 tokens - required for OIDC`,
default: 'RS256'
}
},
onFormSuccess: (data: any) => {
setCreatedClient({
client_id: data?.client_id,
client_secret: data?.client_secret
});
setModalTitle(t`OAuth application created`);
setCreatedModalOpened(true);
}
});
const regenerateOAuthApplicationSecret = useCallback((record: any) => {
api
.post(apiUrl(ApiEndpoints.admin_oauth_regenerate, record.id))
.then((res) => {
setCreatedClient({
client_id: res.data.client_id,
client_secret: res.data.client_secret
});
setModalTitle(t`OAuth application secret regenerated`);
setCreatedModalOpened(true);
showNotification({
title: t`OAuth secret rotated`,
message: t`The new client secret is only shown once`,
color: 'green'
});
})
.catch((error) => {
showApiErrorMessage({
error,
title: t`Error regenerating OAuth client secret`
});
});
}, []);
const deleteOAuthApplication = useDeleteApiFormModal({
url: ApiEndpoints.admin_oauth,
pk: selectedOAuthApplication,
title: t`Delete OAuth Application`,
table: table
});
const oauthColumns = useMemo(
() => [
{
accessor: 'name',
title: t`Name`,
sortable: true,
switchable: false
},
{
accessor: 'client_id',
title: t`Client ID`,
sortable: true,
switchable: false
},
{
accessor: 'client_type',
title: t`Client Type`,
sortable: true,
switchable: true
},
{
accessor: 'authorization_grant_type',
title: t`Grant Type`,
sortable: true,
switchable: true
},
{
accessor: 'redirect_uris',
title: t`Redirect URIs`,
sortable: true,
switchable: true,
render: (record: any) => record.redirect_uris || '-'
},
{
accessor: 'is_builtin',
title: t`Built-in`,
sortable: true,
switchable: true,
render: (record: any) => (record.is_builtin ? t`Yes` : t`No`)
}
],
[]
);
const rowActions = useCallback(
(record: any): RowAction[] => [
{
title: t`Regenerate Secret`,
color: 'blue',
icon: <IconShieldLock size={16} />,
hidden: !!record.is_builtin,
onClick: () => regenerateOAuthApplicationSecret(record)
},
RowDeleteAction({
hidden: !!record.is_builtin,
onClick: () => {
setSelectedOAuthApplication(record.id);
deleteOAuthApplication.open();
}
})
],
[deleteOAuthApplication, regenerateOAuthApplicationSecret]
);
const tableActions = useMemo(
() => [
<AddItemButton
key={'add-oauth-application'}
tooltip={t`Add OAuth Application`}
onClick={() => newOAuthApplication.open()}
/>
],
[newOAuthApplication]
);
return (
<Stack gap='md'>
<OAuthCredentialsModal
opened={createdModalOpened}
onClose={() => setCreatedModalOpened(false)}
client={createdClient}
title={modalTitle}
/>
{newOAuthApplication.modal}
{deleteOAuthApplication.modal}
<InvenTreeTable
tableState={table}
url={apiUrl(ApiEndpoints.admin_oauth)}
columns={oauthColumns}
props={{
enableSearch: true,
enableColumnSwitching: true,
enableSelection: false,
enablePagination: true,
enableRefresh: true,
rowActions: rowActions,
tableActions: tableActions
}}
/>
</Stack>
);
}
function headerSection(text: string, out = false) {
return (
<Group>
{out ? <IconArrowBigLeft size={16} /> : <IconArrowBigRight size={16} />}
<StylishText size='lg'>{text}</StylishText>
</Group>
);
}
export default function IdentityManagementPanel() {
const identity_overview = t`InvenTree can be integrated with external Identity Providers and act as one.`;
const identity_inbound = t`External Identities can be pushed to InvenTree via Single Sign-On (SSO) and SCIM.`;
const identity_outbound = t`InvenTree can act as an Identity Provider for external applications via the built-in oAuth2 provider.`;
return (
<>
{identity_overview}
<SimpleGrid cols={2} spacing='md' mt='md' mb='md'>
<div>{identity_inbound}</div>
<div>{identity_outbound}</div>
</SimpleGrid>
<Accordion
variant='separated'
defaultValue={['scim']}
chevronPosition='left'
multiple
>
<Accordion.Item value='scim'>
<Accordion.Control>
{headerSection(t`SCIM Provisioning`)}
</Accordion.Control>
<Accordion.Panel>
<ScimManagementPanel />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='sso'>
<Accordion.Control>
{headerSection(t`Single Sign-On (SSO)`)}
</Accordion.Control>
<Accordion.Panel>
<SSOManagementPanel />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='oauth2'>
<Accordion.Control>
{headerSection(t`oAuth2 Provider`, true)}
</Accordion.Control>
<Accordion.Panel>
<OAuthManagementPanel />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</>
);
}
@@ -76,8 +76,8 @@ const MachineManagementPanel = Loadable(
const NoteTemplatePanel = Loadable(lazy(() => import('./NoteTemplatePanel')));
const ScimManagementPanel = Loadable(
lazy(() => import('./ScimManagementPanel'))
const IdentityManagementPanel = Loadable(
lazy(() => import('./IdentityManagementPanel'))
);
const ErrorReportTable = Loadable(
@@ -280,9 +280,9 @@ export default function AdminCenter() {
},
{
name: 'identity',
label: t`Identity`,
label: t`Identity Federation`,
icon: <IconShieldLock />,
content: <ScimManagementPanel />,
content: <IdentityManagementPanel />,
hidden: !user.hasViewRole(UserRoles.admin)
}
];
@@ -1,194 +0,0 @@
import { CopyButton } from '@lib/components/CopyButton';
import { StylishText } from '@lib/components/StylishText';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import {
Alert,
Badge,
Button,
Code,
Divider,
Group,
Loader,
Modal,
Paper,
Stack,
Table,
Text
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import { IconShieldLock, IconShieldOff } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { api, queryClient } from '../../../../App';
import { showApiErrorMessage } from '../../../../functions/notifications';
export default function ScimManagementPanel() {
const [secret, setSecret] = useState<string>('');
const [
secretModalOpened,
{ open: openSecretModal, close: closeSecretModal }
] = useDisclosure(false);
const { data, isFetching } = useQuery({
queryKey: ['scim-config'],
queryFn: () =>
api.get(apiUrl(ApiEndpoints.scim_config)).then((res) => res.data),
refetchOnMount: true
});
const generateSecret = (action: 'generate' | 'rotate') => {
api
.post(apiUrl(ApiEndpoints.scim_generate))
.then((res) => {
setSecret(res.data.secret);
openSecretModal();
queryClient.invalidateQueries({ queryKey: ['scim-config'] });
showNotification({
title:
action === 'generate' ? t`SCIM enabled` : t`SCIM secret rotated`,
message: t`The new bearer secret is only shown once`,
color: 'green'
});
})
.catch((error) => {
showApiErrorMessage({ error, title: t`Error generating SCIM secret` });
});
};
const disableScim = () => {
api
.post(apiUrl(ApiEndpoints.scim_disable))
.then(() => {
queryClient.invalidateQueries({ queryKey: ['scim-config'] });
showNotification({
title: t`SCIM disabled`,
message: t`The SCIM provisioning endpoint has been disabled and its secret revoked`,
color: 'blue'
});
})
.catch((error) => {
showApiErrorMessage({ error, title: t`Error disabling SCIM` });
});
};
if (isFetching && !data) {
return <Loader />;
}
return (
<Stack gap='md'>
<Modal
opened={secretModalOpened}
onClose={closeSecretModal}
title={<StylishText size='xl'>{t`SCIM Bearer Secret`}</StylishText>}
centered
data-testid='scim-secret-modal'
>
<Alert color='yellow' mb='sm'>
<Trans>
This secret is only shown once - copy it now and store it in your
Identity Provider's SCIM configuration. It cannot be retrieved
again, only rotated.
</Trans>
</Alert>
<Paper p='sm' withBorder>
<Group justify='space-between' wrap='nowrap'>
<Code style={{ wordBreak: 'break-all', whiteSpace: 'normal' }}>
{secret}
</Code>
<CopyButton value={secret} />
</Group>
</Paper>
</Modal>
<Alert icon={<IconShieldLock />} color='blue'>
<Trans>
SCIM allows an external Identity Provider (e.g. Okta, Microsoft Entra
ID, OneLogin) to automatically provision and deprovision Users and
Groups. Single Sign-On (interactive login) is configured separately,
under Single Sign On.
</Trans>
</Alert>
<Table>
<Table.Tbody>
<Table.Tr>
<Table.Td>
<Trans>Status</Trans>
</Table.Td>
<Table.Td>
{data?.enabled ? (
<Badge color='green'>
<Trans>Enabled</Trans>
</Badge>
) : (
<Badge color='gray'>
<Trans>Disabled</Trans>
</Badge>
)}
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>
<Trans>Base URL</Trans>
</Table.Td>
<Table.Td>
<Group gap='xs' wrap='nowrap'>
<Code>{data?.base_url}</Code>
<CopyButton value={data?.base_url} />
</Group>
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>
<Trans>Secret Generated</Trans>
</Table.Td>
<Table.Td>{data?.secret_generated ?? '-'}</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>
<Trans>Last Used</Trans>
</Table.Td>
<Table.Td>{data?.last_used ?? '-'}</Table.Td>
</Table.Tr>
</Table.Tbody>
</Table>
<Divider />
<Group>
<Button
leftSection={<IconShieldLock size={16} />}
onClick={() => generateSecret(data?.enabled ? 'rotate' : 'generate')}
>
{data?.enabled ? (
<Trans>Rotate Secret</Trans>
) : (
<Trans>Enable SCIM</Trans>
)}
</Button>
{data?.enabled && (
<Button
color='red'
variant='outline'
leftSection={<IconShieldOff size={16} />}
onClick={disableScim}
>
<Trans>Disable SCIM</Trans>
</Button>
)}
</Group>
<Text size='sm' c='dimmed'>
<Trans>
Rotating the secret immediately invalidates the previous one - update
your Identity Provider's configuration straight away.
</Trans>
</Text>
</Stack>
);
}