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

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

* also test scim

* extend testing with conformance suite

* fix test suite results

* coverage completion

* fil test gaps

* add missing schema values

* fix more type issues

* fix error view

* remove route from OpenAPI

* User permissions check for Attachment API (#12689)

* enable oidc proivder by default

* add .well-known entry for OIDC server

* add default client registration

* add changelog entry

* add codeowners

* remove redundant info

* re-name and restructure panel

* add a smal explainer section on top

* flesh out section a bit more

* change name

* basic sso panel

* refactor rendering

* add api and mgmt functions

* add create/delete options

* simplify test

* handle secret generation

* fix merge

* update coverage

* add test

* reduce user friction

* add better labels

* make it more clear that this is only shown once

* add regenerate function

* update tests

* simplify test calls

* fix test
This commit is contained in:
Matthias Mair
2026-09-05 12:10:13 +10:00
committed by GitHub
parent fa44925b21
commit 0a4f095397
14 changed files with 876 additions and 212 deletions
@@ -1,11 +1,14 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 541
INVENTREE_API_VERSION = 542
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v542 -> 2026-09-03 : https://github.com/inventree/InvenTree/pull/12731
- Adds management APIs for oAuth2 provider applications
v541 -> 2026-09-03 : https://github.com/inventree/InvenTree/pull/12770
- Prevent DELETE operation against the /api/user/me/ endpoint
@@ -23,7 +26,7 @@ v537 -> 2026-08-31 : https://github.com/inventree/InvenTree/pull/11971
- Adds a generic "Note" model which can be attached to any model type via a generic foreign key relationship
- Allow multiple notes to be attached to a single object, and for notes to be created / edited / deleted via the API
v536 -> 2026-08-30 : https://github.com/inventree/InvenTree/pull/xxxx
v536 -> 2026-08-30 : https://github.com/inventree/InvenTree/pull/12713
- Adds SCIM 2 provisioning support
v535 -> 2026-08-29 : https://github.com/inventree/InvenTree/pull/12739
+35
View File
@@ -23,6 +23,9 @@ from InvenTree.ready import ignore_ready_warning
logger = structlog.get_logger('inventree')
MIGRATIONS_CHECK_DONE = False
OIDC_CLIENT_CHECKED = False
DEFAULT_OIDC_APP_ID = 'zDFnsiRheJIOKNx6aCQ0quBxECg1QBHtVFDPloJ6'
class InvenTreeConfig(AppConfig):
"""AppConfig for inventree app."""
@@ -89,6 +92,7 @@ class InvenTreeConfig(AppConfig):
if InvenTree.ready.canAppAccessDatabase() or settings.TESTING_ENV:
self.add_user_on_startup()
self.add_user_from_file()
self.add_oidc_default_application()
# register event receiver and connect signal for SSO group sync. The connected signal is
# used for account updates whereas the receiver is used for the initial account creation.
@@ -335,6 +339,37 @@ class InvenTreeConfig(AppConfig):
# do not try again
settings.USER_ADDED_FILE = True
@ignore_ready_warning
def add_oidc_default_application(self):
"""Add the default OIDC application for InvenTree clients."""
global OIDC_CLIENT_CHECKED
if OIDC_CLIENT_CHECKED:
return
from oauth2_provider.models import Application
if Application.objects.filter(
client_id=DEFAULT_OIDC_APP_ID
).exists(): # pragma: no cover
logger.info('Default OIDC client already exists - skipping creation')
OIDC_CLIENT_CHECKED = True
return
# Create the default OIDC client
client = Application.objects.create(
name='InvenTree default client',
client_id=DEFAULT_OIDC_APP_ID,
post_logout_redirect_uris=[f'{settings.SITE_URL}/'],
client_type=Application.CLIENT_PUBLIC,
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
redirect_uris=[f'{settings.SITE_URL}/oidc/callback/', 'http://localhost'],
# scopes='openid profile email g:read',
algorithm=Application.RS256_ALGORITHM,
skip_authorization=True,
)
logger.info('Default OIDC client created: %s', client)
OIDC_CLIENT_CHECKED = True
def ensure_migrations_done(self=None):
"""Ensures there are no open migrations, stop if inconsistent state."""
global MIGRATIONS_CHECK_DONE
+2 -2
View File
@@ -1163,7 +1163,7 @@ FLAGS = {
'NEXT_GEN': [
{'condition': 'parameter', 'value': 'ngen='}
], # Should next-gen features be turned on?
'OIDC': [{'condition': 'parameter', 'value': 'oidc='}],
'OIDC': [{'condition': 'boolean', 'value': True}],
}
# Get custom flags from environment/yaml
@@ -1191,7 +1191,7 @@ OAUTH2_PROVIDER = {
# OIDC
'OIDC_ENABLED': True,
'OIDC_RSA_PRIVATE_KEY': get_oidc_private_key(),
'PKCE_REQUIRED': False,
'PKCE_REQUIRED': True,
}
OAUTH2_CHECK_EXCLUDED = [ # This setting mutes schema checks for these rule/method combinations
'/api/email/generate/:post',
+167 -1
View File
@@ -1,18 +1,22 @@
"""Low level tests for the InvenTree API."""
import hashlib
from base64 import b64encode
from pathlib import Path
from tempfile import TemporaryDirectory
from urllib.parse import parse_qs, urlencode, urlsplit
from django.contrib.auth import get_user_model
from django.core.exceptions import AppRegistryNotReady
from django.test import TestCase
from django.test import TestCase, override_settings
from django.urls import reverse
from oauth2_provider.models import Application
from rest_framework import status
from InvenTree.api import read_license_file
from InvenTree.api_version import INVENTREE_API_VERSION
from InvenTree.apps import DEFAULT_OIDC_APP_ID
from InvenTree.exceptions import exception_handler
from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase
from InvenTree.version import inventreeApiText, parse_version_text
@@ -90,6 +94,168 @@ class ExceptionHandlerTests(TestCase):
self.assertEqual(response['Retry-After'], '1')
@override_settings(
SITE_URL='http://testserver', CSRF_TRUSTED_ORIGINS=['http://testserver']
)
class OAuth2ApplicationAPITests(InvenTreeAPITestCase):
"""Tests for the built-in OIDC application metadata and deletion guard."""
superuser = True
def test_builtin_client_metadata_and_delete_block(self):
"""The built-in default OIDC client should be flagged and protected from deletion."""
Application.objects.filter(client_id=DEFAULT_OIDC_APP_ID).delete()
built_in = Application.objects.create(
name='Built-In OIDC Client',
client_id=DEFAULT_OIDC_APP_ID,
client_secret='secret',
redirect_uris='https://example.com/callback',
client_type=Application.CLIENT_PUBLIC,
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
algorithm=Application.RS256_ALGORITHM,
)
response = self.get(reverse('api-oauth2-list'))
payload = response.json()
self.assertTrue(
any(
item['client_id'] == DEFAULT_OIDC_APP_ID and item['is_builtin']
for item in payload
)
)
# no delete
response = self.delete(
reverse('api-oauth2-detail', kwargs={'pk': built_in.pk}), expected_code=403
)
self.assertTrue(Application.objects.filter(pk=built_in.pk).exists())
# no secret regeneration
self.post(
reverse('api-oauth2-regenerate', kwargs={'pk': built_in.pk}),
expected_code=403,
)
def test_create_application(self):
"""An admin should be able to create a custom OAuth2 application."""
payload = {
'name': 'Custom OAuth App',
'client_type': Application.CLIENT_PUBLIC,
'authorization_grant_type': Application.GRANT_AUTHORIZATION_CODE,
'redirect_uris': 'https://example.com/callback',
'post_logout_redirect_uris': 'https://example.com/logout',
'skip_authorization': False,
'algorithm': Application.RS256_ALGORITHM,
}
response = self.post(
reverse('api-oauth2-list'), payload, expected_code=201, format='json'
)
self.assertTrue(Application.objects.filter(name='Custom OAuth App').exists())
payload = response.json()
self.assertIn('client_id', payload)
secret_1 = payload['client_secret']
assert secret_1 is not None
self.assertNotEqual(secret_1, '')
self.assertFalse(secret_1.startswith('pbkdf2_sha256$'))
# repeated GET should not return the plaintext secret
response = self.get(reverse('api-oauth2-detail', kwargs={'pk': payload['id']}))
payload = response.json()
self.assertIn('client_id', payload)
self.assertNotIn('client_secret', payload)
# regenerating the secret should return a new plaintext secret
response = self.post(
reverse('api-oauth2-regenerate', kwargs={'pk': payload['id']}),
expected_code=200,
)
result = response.json()
secret_2 = result['client_secret']
assert secret_2 is not None
self.assertFalse(secret_2.startswith('pbkdf2_sha256$'))
self.assertNotEqual(secret_1, secret_2)
def test_oauth2_application_token_can_access_profile(self):
"""A custom OAuth2 client should be able to use a valid token to read the current profile."""
payload = {
'name': 'Profile OAuth App',
'client_type': Application.CLIENT_CONFIDENTIAL,
'authorization_grant_type': Application.GRANT_AUTHORIZATION_CODE,
'redirect_uris': 'https://example.com/callback',
'post_logout_redirect_uris': 'https://example.com/logout',
'skip_authorization': False,
'algorithm': Application.RS256_ALGORITHM,
}
response = self.post(
reverse('api-oauth2-list'), payload, expected_code=201, format='json'
)
app = response.json()
client_id = app['client_id']
client_secret = app['client_secret']
challenge_verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'
code_challenge = (
__import__('base64')
.urlsafe_b64encode(
hashlib.sha256(challenge_verifier.encode('utf-8')).digest()
)
.rstrip(b'=')
.decode('ascii')
)
auth_params = {
'client_id': client_id,
'redirect_uri': 'https://example.com/callback',
'response_type': 'code',
'scope': 'openid g:read',
'state': 'abc123',
'code_challenge': code_challenge,
'code_challenge_method': 'S256',
}
self.logout()
response = self.get(reverse('api-user-profile'), expected_code=401)
self.login()
response = self.get(reverse('oauth2_provider:authorize'), auth_params)
response = self.post(
reverse('oauth2_provider:authorize'),
{**auth_params, 'allow': 'true'},
format=None,
expected_code=302,
)
self.assertIn('code=', response['Location'])
code = parse_qs(urlsplit(response['Location']).query)['code'][0]
response = self.post(
reverse('oauth2_provider:token'),
urlencode({
'grant_type': 'authorization_code',
'client_id': client_id,
'client_secret': client_secret,
'code': code,
'redirect_uri': 'https://example.com/callback',
'code_verifier': challenge_verifier,
}),
format=None,
content_type='application/x-www-form-urlencoded',
expected_code=200,
)
token_data = response.json()
self.assertIn('access_token', token_data)
access_token = token_data['access_token']
self.logout()
response = self.get(
reverse('api-user-profile'), HTTP_AUTHORIZATION=f'Bearer {access_token}'
)
profile = response.json()
self.assertIn('language', profile)
self.assertIn('theme', profile)
self.assertIn('widgets', profile)
class ApiAccessTests(InvenTreeAPITestCase):
"""Tests for various access scenarios with the InvenTree API."""
+48
View File
@@ -27,6 +27,8 @@ from drf_spectacular.utils import (
extend_schema_view,
)
from error_report.models import Error
from oauth2_provider.generators import generate_client_secret
from oauth2_provider.models import Application
from opentelemetry import trace
from pint._typing import UnitLike
from rest_framework import serializers, viewsets
@@ -45,6 +47,7 @@ import InvenTree.conversion
import InvenTree.models
import InvenTree.ready
from common.icons import get_icon_packs
from common.serializers import OAuth2ApplicationSerializer
from common.settings import get_global_setting
from data_exporter.mixins import DataExportViewMixin
from generic.states.api import urlpattern as generic_states_api_urls
@@ -56,10 +59,12 @@ from InvenTree.api import (
SimpleGenericMetadataView,
meta_path,
)
from InvenTree.apps import DEFAULT_OIDC_APP_ID
from InvenTree.config import CONFIG_LOOKUPS
from InvenTree.filters import ORDER_FILTER, SEARCH_ORDER_FILTER
from InvenTree.helpers import inheritors, str2bool
from InvenTree.helpers_api import (
CleanModelViewSet,
InvenTreeApiRouter,
RetrieveDestroyModelViewSet,
RetrieveUpdateDestroyModelViewSet,
@@ -1760,6 +1765,49 @@ class ObservabilityEnd(CreateAPI):
return Response({'status': 'ok'})
class ApplicationViewSet(CleanModelViewSet):
"""Manage a oAuth2 (provider side) application."""
queryset = Application.objects.all()
serializer_class = OAuth2ApplicationSerializer
def destroy(self, request, *args, **kwargs):
"""Delete an OAuth2 application.
Deletion of the built-in default OIDC client is not allowed.
"""
instance = self.get_object()
if instance.client_id == DEFAULT_OIDC_APP_ID:
raise PermissionDenied(
_('The built-in default OIDC client cannot be deleted.')
)
return super().destroy(request, *args, **kwargs)
@extend_schema(request=None, responses={200: OAuth2ApplicationSerializer()})
@action(detail=True, methods=['post'])
def regenerate(self, request, *args, **kwargs):
"""Regenerate the client secret."""
instance = self.get_object()
if instance.client_id == DEFAULT_OIDC_APP_ID:
raise PermissionDenied(
_('The built-in default OIDC client secret cannot be regenerated.')
)
secret = generate_client_secret()
instance.client_secret = secret
instance._raw_client_secret = secret
instance.save()
serializer = self.get_serializer(instance)
return Response(serializer.data)
# oAuth2 admin
admin_router.register('oauth2', ApplicationViewSet, basename='api-oauth2')
selection_urls = [
path(
'<int:pk>/',
@@ -10,6 +10,8 @@ from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from error_report.models import Error
from flags.state import flag_state
from oauth2_provider.generators import generate_client_secret
from oauth2_provider.models import Application
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from taggit.models import Tag
@@ -19,6 +21,7 @@ import common.models as common_models
import common.validators
import generic.states.custom
from importer.registry import register_importer
from InvenTree.apps import DEFAULT_OIDC_APP_ID
from InvenTree.helpers import get_objectreference
from InvenTree.helpers_model import construct_absolute_url
from InvenTree.mixins import DataImportExportSerializerMixin
@@ -1284,3 +1287,55 @@ class InstanceInfoSerializer(serializers.Serializer):
help_text=_('Number of parameters associated with this instance'),
read_only=True,
)
class OAuth2ApplicationSerializer(serializers.ModelSerializer):
"""Serializer for OAuth2 application records."""
class Meta:
"""Meta options for OAuth2ApplicationSerializer."""
model = Application
fields = [
'id',
'client_id',
'client_secret',
'name',
'client_type',
'authorization_grant_type',
'redirect_uris',
'post_logout_redirect_uris',
'skip_authorization',
'algorithm',
'is_builtin',
]
read_only_fields = ['id', 'client_id', 'client_secret', 'is_builtin']
is_builtin = serializers.SerializerMethodField()
@extend_schema_field(serializers.BooleanField())
def get_is_builtin(self, obj: Application) -> bool:
"""Indicate whether this OAuth2 application is the built-in InvenTree client."""
return obj.client_id == DEFAULT_OIDC_APP_ID
def create(self, validated_data):
"""Preserve the plaintext client secret for the create response before hashing."""
raw_secret = validated_data.get('client_secret', None)
if raw_secret is None:
raw_secret = generate_client_secret()
validated_data['client_secret'] = raw_secret
instance = Application(**validated_data)
instance._raw_client_secret = raw_secret
instance.save()
return instance
def to_representation(self, instance):
"""Expose the plaintext secret only for a newly-created OAuth app instance."""
data = super().to_representation(instance)
raw_secret = getattr(instance, '_raw_client_secret', None)
if raw_secret is not None:
data['client_secret'] = raw_secret
else:
data.pop('client_secret', None)
return data
@@ -4,6 +4,8 @@ from django.http import HttpRequest, JsonResponse
from django.urls import path, reverse_lazy
from django.utils.translation import gettext_lazy as _
from flags.state import flag_enabled
import InvenTree.helpers
from InvenTree.permissions import auth_exempt
from plugin import InvenTreePlugin
@@ -29,7 +31,17 @@ class InvenTreeWellKnown(WellKnownMixin, UrlsMixin, InvenTreePlugin):
# See https://www.w3.org/TR/passkey-endpoints/
data.append(('passkey-endpoints', reverse_lazy(f'plugin:{self.slug}:passkey')))
# placeholder for more
# Check if OIDC is enabled, and if so, add the relevant entries
try:
if flag_enabled('OIDC', request=request):
data.append((
'openid-configuration',
str(reverse_lazy('oauth2_provider:oidc-connect-discovery-info')),
))
except Exception: # pragma: no cover
# If the flag is not evaluated successfully, we can ignore it
pass
return data
@auth_exempt
@@ -4,7 +4,7 @@ import datetime
from django.utils.translation import gettext_lazy as _
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
from oauth2_provider.contrib.rest_framework import OAuth2ProtectedResourceAuthentication
from rest_framework import exceptions
from rest_framework.authentication import TokenAuthentication
@@ -42,5 +42,5 @@ class ApiTokenAuthentication(TokenAuthentication):
return (user, token)
class ExtendedOAuth2Authentication(OAuth2Authentication):
class ExtendedOAuth2Authentication(OAuth2ProtectedResourceAuthentication):
"""Custom implementation of OAuth2Authentication class to support custom scope rendering."""