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
+4 -5
View File
@@ -101,17 +101,16 @@ response = request.get('http://localhost:8080/api/part/', data=data, headers=hea
### oAuth2 and OIDC
!!! warning "Experimental"
This is an experimental feature that needs to be specifically enabled. See [Experimental features](../settings/experimental.md) for more information.
InvenTree has built-in support for using [oAuth2](https://oauth.net/2/) and OpenID Connect (OIDC) for authentication to the API. This enables using the instance as a very limited identity provider.
InvenTree has built-in support for using [oAuth2](https://oauth.net/2/) and OpenID Connect (OIDC) for authentication to the API. This enables using the instance as a *very limited* identity provider.
A default application using a public client with PKCE enabled ships with each instance. Intended to be used with the python api and configured with very wide scopes this can also be used for quick tests - the client_id is `zDFnsiRheJIOKNx6aCQ0quBxECg1QBHtVFDPloJ6`.
#### Managing applications
Superusers can register new applications and manage existing ones using a small application under the subpath `/o/applications/`.
Superusers/admins can register new applications and manage existing ones using the [admin center](../settings/admin.md#admin-center).
It is recommended to:
- read the spec (RFC 6749 / 6750) and/or best practices (RFC 9700) before choosing client types
- chose scopes as narrow as possible
- configure redirection URIs as exact as possible
-1
View File
@@ -14,4 +14,3 @@ Superusers can configure run-time conditions [as per django-flags](https://cfpb.
| Feature | Key | Description |
| --- | --- | --- |
| oAuth provider / api | OIDC | Use oAuth and OIDC to authenticate users with the API - [read more](../api/index.md#oauth2-and-oidc) |
@@ -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>
);
}