diff --git a/docs/docs/api/index.md b/docs/docs/api/index.md index fa05c6bc2d..c651ef7876 100644 --- a/docs/docs/api/index.md +++ b/docs/docs/api/index.md @@ -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 diff --git a/docs/docs/settings/experimental.md b/docs/docs/settings/experimental.md index e207bd1375..de60b29465 100644 --- a/docs/docs/settings/experimental.md +++ b/docs/docs/settings/experimental.md @@ -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) | diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index bbe6ae909d..6c944666b2 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 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 diff --git a/src/backend/InvenTree/InvenTree/apps.py b/src/backend/InvenTree/InvenTree/apps.py index f1be268662..d417002507 100644 --- a/src/backend/InvenTree/InvenTree/apps.py +++ b/src/backend/InvenTree/InvenTree/apps.py @@ -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 diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index 200b8f635d..cb691c2685 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -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', diff --git a/src/backend/InvenTree/InvenTree/test_api.py b/src/backend/InvenTree/InvenTree/test_api.py index 2d7fd1a7f6..6bf84b2018 100644 --- a/src/backend/InvenTree/InvenTree/test_api.py +++ b/src/backend/InvenTree/InvenTree/test_api.py @@ -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.""" diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index aeaf94af41..21014008cb 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -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( '/', diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index 7ccb76d04b..99568eed6e 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -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 diff --git a/src/backend/InvenTree/plugin/builtin/integration/core_wellknown.py b/src/backend/InvenTree/plugin/builtin/integration/core_wellknown.py index 414c849785..8d910f1aff 100644 --- a/src/backend/InvenTree/plugin/builtin/integration/core_wellknown.py +++ b/src/backend/InvenTree/plugin/builtin/integration/core_wellknown.py @@ -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 diff --git a/src/backend/InvenTree/users/authentication.py b/src/backend/InvenTree/users/authentication.py index 973e1ba92f..7b486af2c0 100644 --- a/src/backend/InvenTree/users/authentication.py +++ b/src/backend/InvenTree/users/authentication.py @@ -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.""" diff --git a/src/frontend/lib/enums/ApiEndpoints.tsx b/src/frontend/lib/enums/ApiEndpoints.tsx index 52f777474d..824e881650 100644 --- a/src/frontend/lib/enums/ApiEndpoints.tsx +++ b/src/frontend/lib/enums/ApiEndpoints.tsx @@ -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/', diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/IdentityManagementPanel.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/IdentityManagementPanel.tsx new file mode 100644 index 0000000000..642a4aa46a --- /dev/null +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/IdentityManagementPanel.tsx @@ -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(''); + 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( + () => [ + [ + Status, + data?.enabled ? ( + + Enabled + + ) : ( + + Disabled + + ) + ], + [ + Base URL, + + {data?.base_url} + + + ], + [Secret Generated, data?.secret_generated ?? '-'], + [Last Used, data?.last_used ?? '-'] + ], + [data?.enabled, data?.base_url, data?.secret_generated, data?.last_used] + ); + + if (isFetching && !data) { + return ; + } + + return ( + + {t`SCIM Bearer Secret`}} + centered + data-testid='scim-secret-modal' + > + + + 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. + + + + + + {secret} + + + + + + + } color='blue'> + + SCIM allows an external Identity Provider (e.g. Okta, Microsoft Entra + ID, OneLogin) to automatically provision and deprovision Users and + Groups. + + + + + + + + + + {data?.enabled && ( + + )} + + + + + Rotating the secret immediately invalidates the previous one - update + your Identity Provider's configuration straight away. + + + + ); +} + +function SSOManagementPanel() { + const navigate = useNavigate(); + + return ( + + TBD + + + + More settings can be found in the{' '} + + navigateToLink('/settings/system/authentication', navigate, event) + } + style={{ textDecoration: 'underline' }} + > + system settings + + . + + + + ); +} + +function OAuthCredentialsModal({ + opened, + onClose, + client, + title +}: { + opened: boolean; + onClose: () => void; + client: { + client_id?: string; + client_secret?: string; + }; + title: string; +}) { + return ( + + + + + Copy these values now. The client secret is only shown once. + + +
Field, Value], + body: [ + [ + + Client ID + , + + {client.client_id ?? '-'} + + + ], + [ + + Client Secret + , + + {client.client_secret ?? '-'} + + + ] + ] + }} + /> + + + ); +} + +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: , + hidden: !!record.is_builtin, + onClick: () => regenerateOAuthApplicationSecret(record) + }, + RowDeleteAction({ + hidden: !!record.is_builtin, + onClick: () => { + setSelectedOAuthApplication(record.id); + deleteOAuthApplication.open(); + } + }) + ], + [deleteOAuthApplication, regenerateOAuthApplicationSecret] + ); + + const tableActions = useMemo( + () => [ + newOAuthApplication.open()} + /> + ], + [newOAuthApplication] + ); + + return ( + + setCreatedModalOpened(false)} + client={createdClient} + title={modalTitle} + /> + {newOAuthApplication.modal} + {deleteOAuthApplication.modal} + + + ); +} + +function headerSection(text: string, out = false) { + return ( + + {out ? : } + {text} + + ); +} + +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} + +
{identity_inbound}
+
{identity_outbound}
+
+ + + + + {headerSection(t`SCIM Provisioning`)} + + + + + + + + {headerSection(t`Single Sign-On (SSO)`)} + + + + + + + + {headerSection(t`oAuth2 Provider`, true)} + + + + + + + + ); +} diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx index 03fa7e4034..df3c90b5d2 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx @@ -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: , - content: , + content: , hidden: !user.hasViewRole(UserRoles.admin) } ]; diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/ScimManagementPanel.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/ScimManagementPanel.tsx deleted file mode 100644 index 5e11a45956..0000000000 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/ScimManagementPanel.tsx +++ /dev/null @@ -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(''); - 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 ; - } - - return ( - - {t`SCIM Bearer Secret`}} - centered - data-testid='scim-secret-modal' - > - - - 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. - - - - - - {secret} - - - - - - - } color='blue'> - - 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. - - - -
- - - - Status - - - {data?.enabled ? ( - - Enabled - - ) : ( - - Disabled - - )} - - - - - Base URL - - - - {data?.base_url} - - - - - - - Secret Generated - - {data?.secret_generated ?? '-'} - - - - Last Used - - {data?.last_used ?? '-'} - - -
- - - - - - {data?.enabled && ( - - )} - - - - - Rotating the secret immediately invalidates the previous one - update - your Identity Provider's configuration straight away. - - -
- ); -}