mirror of
https://github.com/inventree/InvenTree.git
synced 2026-09-10 06:37: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:
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user