mirror of
https://github.com/inventree/InvenTree.git
synced 2026-09-09 22:30:17 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user