oidc: New app to implement OpenID Connect Provider

- Add a component to easily manage registration of client applications.

Tests:

- Package build is successful has dependency on python3-django-auto-toolkit

- python3-django-oauth-toolkit can be installed on stable, testing and unstable
containers

- /.well-known/openid-configuration and /.well-known/jwks.json are servered
properly.

- /o/ URLs don't require login to access

- When logging in list of claims includes 'sub', email, freedombox_groups.

- Logging in using IP address works. Also works with a port.

- Logging in using 127.0.0.1 address works. Also works with a port.

- Logging in using localhost works. Also works with a port.

- Logging in with IPv6 address works. Also works with a port.

- Logging in with IPv6 [::1] address works. Also works with a port.

- Logging in with IPv6 link-local address with zone ID is not possible (as
browsers don't support them).

- When authorization page is enabled, scopes show description as expected.

- When domain name is added/removed, all OIDC components are updated with
expected domains

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
Sunil Mohan Adapa 2025-09-29 10:56:58 -07:00 committed by James Valleroy
parent f0b1aa34ac
commit 45076cc603
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
16 changed files with 593 additions and 8 deletions

View File

@ -76,6 +76,18 @@
RequestHeader set X-Forwarded-Proto 'https' env=HTTPS
RequestHeader unset X-Forwarded-For
</Location>
<Location /.well-known/openid-configuration>
ProxyPass http://127.0.0.1:8000/freedombox/o/.well-known/openid-configuration
ProxyPreserveHost On
RequestHeader set X-Forwarded-Proto 'https' env=HTTPS
RequestHeader unset X-Forwarded-For
</Location>
<Location /.well-known/jwks.json>
ProxyPass http://127.0.0.1:8000/freedombox/o/.well-known/jwks.json
ProxyPreserveHost On
RequestHeader set X-Forwarded-Proto 'https' env=HTTPS
RequestHeader unset X-Forwarded-For
</Location>
##
## Serve FreedomBox icon as /favicon.ico for apps that don't present their own

2
debian/control vendored
View File

@ -35,6 +35,7 @@ Build-Depends:
python3-django-captcha,
# Explictly depend on ipware as it is optional dependecy of django-axes
python3-django-ipware,
python3-django-oauth-toolkit,
python3-django-stronghold,
python3-gi,
python3-markupsafe,
@ -108,6 +109,7 @@ Depends:
python3-django-captcha,
# Explictly depend on ipware as it is optional dependecy of django-axes
python3-django-ipware,
python3-django-oauth-toolkit,
python3-django-stronghold,
python3-gi,
python3-markupsafe,

View File

@ -4,6 +4,7 @@ Common Django middleware.
"""
import logging
import re
from django import urls
from django.conf import settings
@ -58,7 +59,9 @@ class SetupMiddleware(MiddlewareMixin):
except urls.Resolver404:
return
if not resolver_match.namespaces or not len(resolver_match.namespaces):
non_app_namespaces = {'oauth2_provider'}
if (not resolver_match.namespaces or not len(resolver_match.namespaces)
or (set(resolver_match.namespaces) & non_app_namespaces)):
# Requested URL does not belong to any application
return
@ -99,10 +102,17 @@ class AdminRequiredMiddleware(MiddlewareMixin):
hasattr(view_func, 'IS_NON_ADMIN'):
return
if not is_user_admin(request):
if not AdminRequiredMiddleware.check_user_group(
view_func, request):
raise PermissionDenied
public_urls = settings.STRONGHOLD_PUBLIC_URLS
if any(re.match(url, request.path_info) for url in public_urls):
return
if is_user_admin(request):
return
if AdminRequiredMiddleware.check_user_group(view_func, request):
return
raise PermissionDenied
class FirstSetupMiddleware(MiddlewareMixin):

View File

@ -251,6 +251,15 @@ def on_domain_added(sender: str, domain_type: str, name: str = '',
logger.info('Added domain %s of type %s with services %s', name,
domain_type, str(services))
# HACK: Call from here to here ensure that the on_domain_added can perform
# DomainName.list() iteration and get an updated list of domains. This
# won't happen if in the signal calling order, oidc module get notified
# first. A proper fix is being worked on as a complete overhaul to domain
# change notification mechanism.
from plinth.modules import oidc
oidc.on_domain_added(sender, domain_type, name, description, services,
**kwargs)
def on_domain_removed(sender: str, domain_type: str, name: str = '', **kwargs):
"""Remove domain from global list."""
@ -258,6 +267,14 @@ def on_domain_removed(sender: str, domain_type: str, name: str = '', **kwargs):
component_id = 'domain-' + sender + '-' + name
components.DomainName.get(component_id).remove()
logger.info('Removed domain %s of type %s', name, domain_type)
# HACK: Call from here to here ensure that the on_domain_remove can
# perform DomainName.list() iteration and get an updated list of
# domains. This won't happen if in the signal calling order, oidc
# module get notified first. A proper fix is being worked on as a
# complete overhaul to domain change notification mechanism.
from plinth.modules import oidc
oidc.on_domain_removed(sender, domain_type, name, **kwargs)
else:
for domain_name in components.DomainName.list():
if domain_name.domain_type.component_id == domain_type:

View File

@ -0,0 +1,79 @@
"""FreedomBox app for implementing a OpenID Connect Provider.
With this app, FreedomBox implements a full OpenID Connect Provider along with
OpenID Discovery. Only authorization code grant type is currently supported but
can be easily extended to support other grant types if necessary. See this code
comment for quick understand of the flow works:
https://github.com/oauthlib/oauthlib/blob/master/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py#L64
In the list of OpenID Connect claims provided to Relying Party, we override
django-oauth-toolkit's default 'sub' claim from being the user ID to username.
Additionally, we also provide basic profile information and 'freedombox_groups'
with list of all group names that the user account is part of.
"Clients" or "Applications": must be registered before they can be used with
the Identity Provider. Dynamic Client Registration is not supported yet. The
OpenIDConnect FreedomBox component will help with this registration.
Redirect URLs: After authorization is provided by the user, the URL of the
application to redirect to must be verified by the Identity Provider. This is
usually provided at the time of client registration. However, in FreedomBox,
since list of allowed domains keeps changing, the list of allowed redirect URLs
must keep changing as well. The OpenIDConnect component will also help with
that. Finally, there is overridden verification logic that ensures that
accessing protected applications using IP addresses or localhost domain names
in URLs is allowed.
The implement is done by implementing all the URLs in /freedombox/o. Most of
the implementation for these views and models is provided by
django-oauth-toolkit.
"""
import logging
from django.utils.translation import gettext_lazy as _
from plinth import app as app_module
from . import components
logger = logging.getLogger(__name__)
class OIDCApp(app_module.App):
"""FreedomBox app for OpenID Connect Provider."""
app_id = 'oidc'
_version = 1
def __init__(self) -> None:
"""Create components for the app."""
super().__init__()
info = app_module.Info(app_id=self.app_id, version=self._version,
is_essential=True,
name=_('OpenID Connect Provider'))
self.add(info)
def on_domain_added(sender: str, domain_type: str, name: str = '',
description: str = '',
services: str | list[str] | None = None, **kwargs):
"""Add domain to global list."""
if not domain_type or not name:
return
logger.info('Updating all OpenID Connect components for domain add - %s',
name)
components.OpenIDConnect.update_domains_for_all()
def on_domain_removed(sender: str, domain_type: str, name: str = '', **kwargs):
"""Remove domain from global list."""
if not domain_type or not name:
return
logger.info(
'Updating all OpenID Connect components for domain remove - %s', name)
components.OpenIDConnect.update_domains_for_all()

View File

@ -0,0 +1,169 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""App component for other apps to authenticate with OpenID Connect."""
from plinth import app as app_module
class OpenIDConnect(app_module.FollowerComponent):
"""Component to authentication with OpenID Connection."""
def __init__(self, component_id: str, client_id: str, name: str,
redirect_uris: list[str], skip_authorization: bool = False):
"""Initialize the OpenID Connect component.
name is a description name for the client that is only used for record
keeping.
client_id is ID to use when registring the app as client (relying
party) with FreedomBox's OpenID Connect Provider.
redirect_uris is list of string containing URIs that the user's agent
may be redirected to after successful authentication. If the URIs
contain {domain} in their string, it will be expanded to the list of
all domains configured in FreedomBox.
"""
super().__init__(component_id)
self.client_id = client_id
self.name = name
self.redirect_uris = redirect_uris
self.post_logout_redirect_uris = '' # None is not allowed
self._client_type = None
self._authorization_grant_type = None
self._algorithm = None
self.hash_client_secret = False
self.skip_authorization = skip_authorization
@property
def client_type(self):
"""Return the client type.
This is a property instead of a simple attribute to avoid importing
Application model during component.__init__(). This would require
Django to be configured.
"""
from oauth2_provider.models import Application
return self._client_type or Application.CLIENT_CONFIDENTIAL
@client_type.setter
def client_type(self, value):
"""Set the client type."""
self._client_type = value
@property
def authorization_grant_type(self):
"""Return the authorization grant type.
This is a property instead of a simple attribute to avoid importing
Application model during component.__init__(). This would require
Django to be configured.
"""
from oauth2_provider.models import Application
return (self._authorization_grant_type
or Application.GRANT_AUTHORIZATION_CODE)
@authorization_grant_type.setter
def authorization_grant_type(self, value):
"""Set the authorization grant type."""
self._authorization_grant_type = value
@property
def algorithm(self):
"""Return the algorithm.
This is a property instead of a simple attribute to avoid importing
Application model during component.__init__(). This would require
Django to be configured.
"""
from oauth2_provider.models import Application
return self._algorithm or Application.HS256_ALGORITHM
@algorithm.setter
def algorithm(self, value):
"""Set the algorithm."""
self._algorithm = value
def get_client_secret(self):
"""Return the client secret stored for the application."""
from oauth2_provider.models import Application
return Application.objects.get_by_natural_key(
self.client_id).client_secret
def setup(self, old_version: int) -> None:
"""Register the app as client."""
self._create_or_update_application()
@staticmethod
def update_domains_for_all():
"""For all app components, update redirect URIs and allowed origins."""
for app in app_module.App.list():
for component in app.components.values():
if isinstance(component, OpenIDConnect):
component._create_or_update_application()
def _create_or_update_application(self) -> None:
"""Register the app as client."""
from oauth2_provider.models import Application
try:
application = Application.objects.get_by_natural_key(
self.client_id)
self._update_application(application)
except Application.DoesNotExist:
self._create_application()
def _create_application(self) -> None:
"""Create a new application object."""
from oauth2_provider import generators
from oauth2_provider.models import Application
client_secret = generators.generate_client_secret()
Application.objects.create(
client_id=self.client_id, client_secret=client_secret, user=None,
redirect_uris=self._get_redirect_uris(),
post_logout_redirect_uris=self.post_logout_redirect_uris,
client_type=self.client_type,
authorization_grant_type=self.authorization_grant_type,
hash_client_secret=self.hash_client_secret, name=str(self.name),
algorithm=self.algorithm,
allowed_origins=self._get_allowed_origins(),
skip_authorization=self.skip_authorization)
def _update_application(self, application) -> None:
"""Update configuration for an existing application."""
application.user = None
application.redirect_uris = self._get_redirect_uris()
application.post_logout_redirect_uris = self.post_logout_redirect_uris
application.client_type = self.client_type
application.authorization_grant_type = self.authorization_grant_type
application.hash_client_secret = self.hash_client_secret
application.name = str(self.name)
application.algorithm = self.algorithm
application.allowed_origins = self._get_allowed_origins()
application.skip_authorization = self.skip_authorization
application.save()
def _get_redirect_uris(self) -> str:
"""Return an expanded list of redirect URIs."""
from plinth.modules.names.components import DomainName
final_uris = []
# redirect_uris list can't be empty. Otherwise, validations for
# 'localhost' and IP addresses won't work.
domains = set(DomainName.list_names()) | {'localhost'}
for uri in self.redirect_uris:
if '{domain}' in uri:
for domain in domains:
final_uris.append(uri.format(domain=domain))
else:
final_uris.append(uri)
return ' '.join(final_uris)
def _get_allowed_origins(self) -> str:
"""Return a list of all allowed origins for CORS header."""
from plinth.modules.names.components import DomainName
# redirect_uris list can't be empty. Otherwise, validations for
# 'localhost' and IP addresses won't work. Keep origins in line with
# redirect_uris.
domains = set(DomainName.list_names()) | {'localhost'}
origins = [f'https://{domain_name}' for domain_name in domains]
return ' '.join(origins)

View File

@ -0,0 +1 @@
plinth.modules.oidc

View File

View File

@ -0,0 +1,104 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Test the components provides by oidc app."""
from unittest.mock import patch
import pytest
from oauth2_provider.models import Application
from ..components import OpenIDConnect
@pytest.fixture(name='openid_connect')
def fixture_openid_connect():
"""Return an OpenIDConnect object."""
return OpenIDConnect(
'test-openidconnect', 'test-client', 'Test Client',
['https://testdomain.example/a', 'https://{domain}/b'])
def test_openidconnect_init():
"""Test initialization of openidconnect object."""
component = OpenIDConnect('test-openidconnect', 'test-client',
'Test Client', ['https://testdomain.example/a'])
assert component.component_id == 'test-openidconnect'
assert component.client_id == 'test-client'
assert component.name == 'Test Client'
assert component.redirect_uris == ['https://testdomain.example/a']
assert component.post_logout_redirect_uris == ''
assert component.client_type == Application.CLIENT_CONFIDENTIAL
assert component.authorization_grant_type == \
Application.GRANT_AUTHORIZATION_CODE
assert component.algorithm == Application.HS256_ALGORITHM
assert not component.hash_client_secret
assert not component.skip_authorization
component = OpenIDConnect('test-openidconnect', 'test-client',
'Test Client', ['https://testdomain.example/a'],
skip_authorization=True)
assert component.skip_authorization
@pytest.mark.django_db
def test_get_client_secret(openid_connect):
"""Test retrieving client secret."""
with pytest.raises(Application.DoesNotExist):
openid_connect.get_client_secret()
openid_connect.setup(old_version=0)
assert len(openid_connect.get_client_secret()) == 128
@pytest.mark.django_db
@patch('plinth.modules.names.components.DomainName.list_names')
def test_setup(list_names, openid_connect):
"""Test creating a DB object."""
list_names.return_value = ('a.example', 'b.example')
expected_redirect_uris = [
'https://testdomain.example/a', 'https://a.example/b',
'https://b.example/b', 'https://localhost/b'
]
expected_origins = [
'https://a.example', 'https://b.example', 'https://localhost'
]
# Test creating fresh DB entry
openid_connect.setup(old_version=0)
obj = Application.objects.get(client_id=openid_connect.client_id)
assert obj.client_id == openid_connect.client_id
assert len(obj.client_secret) == 128
assert not obj.user
assert set(obj.redirect_uris.split(' ')) == set(expected_redirect_uris)
assert obj.post_logout_redirect_uris == ''
assert obj.client_type == Application.CLIENT_CONFIDENTIAL
assert obj.authorization_grant_type == Application.GRANT_AUTHORIZATION_CODE
assert not obj.hash_client_secret
assert obj.name == 'Test Client'
assert obj.algorithm == Application.HS256_ALGORITHM
assert set(obj.allowed_origins.split(' ')) == set(expected_origins)
assert not obj.skip_authorization
# Test updating existing DB entry
list_names.return_value = ('c.example', )
expected_redirect_uris = ['https://c.example/c', 'https://localhost/c']
expected_origins = ['https://c.example', 'https://localhost']
openid_connect.redirect_uris = ['https://{domain}/c']
openid_connect.allowed_origins = expected_origins
openid_connect.post_logout_redirect_uris = 'a b'
openid_connect.client_type = Application.CLIENT_PUBLIC
openid_connect.authorization_grant_type = Application.GRANT_IMPLICIT
openid_connect.hash_client_secret = True
openid_connect.name = 'New name'
openid_connect.algorithm = Application.RS256_ALGORITHM
openid_connect.skip_authorization = True
openid_connect.setup(old_version=0)
obj = Application.objects.get(client_id=openid_connect.client_id)
assert set(obj.redirect_uris.split(' ')) == set(expected_redirect_uris)
assert set(obj.allowed_origins.split(' ')) == set(expected_origins)
assert obj.post_logout_redirect_uris == 'a b'
assert obj.client_type == Application.CLIENT_PUBLIC
assert obj.authorization_grant_type == Application.GRANT_IMPLICIT
assert obj.hash_client_secret
assert obj.name == 'New name'
assert obj.algorithm == Application.RS256_ALGORITHM
assert obj.skip_authorization

View File

@ -0,0 +1,31 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""URLs for the OpenID Connect module.
All the '/freedombox/o' URLs are implemented in this module by including them
from django-oauth-toolkit. However, they are included in plinth/urls.py instead
of here because FreedomBox module loading logic automatically namespaces the
URL names. This causes problems when metadata view tries to resolve URLs.
/.well-known/openid-configuration is proxied to
/freedombox/o/.well-known/openid-configuration by Apache2. Similarly,
/.well-known/jwks.json is proxied to /freedombox/o/.well-known/jwks.json.
Important URLs:
- /freedombox/o is the primary URL for identity provider.
- /freedombox/o/.well-known/openid-configuration is the way to discover
additional URLs (such as ./authorize and ./token) needed for OIDC to work.
- /freedombox/o/authorize is used to start the authorization process and get an
authorization code grant.
- /freedombox/o/token is used to get access token and refresh token using the
authorization code. It is also used to get a new access token using the refresh
token.
- /freedombox/o/userinfo provides the claims such as 'sub', 'email',
'freedombox_groups' using an access token.
"""
urlpatterns: list = []

View File

@ -0,0 +1,124 @@
"""Custom OpenID Connect validators."""
import ipaddress
import urllib.parse
from oauth2_provider import oauth2_validators
from plinth import action_utils
class OAuth2Validator(oauth2_validators.OAuth2Validator):
"""Add/override claims into Discovery and ID token.
Ensure that basic profile information is available to the clients. Make the
value of the 'sub' claim, as defined in OpenID Connect, to be the username
of the account instead of the Django account ID. The username is unique in
FreedomBox.
We wish for the applications using the Identity Provider to also
provide/deny resources based on the groups that the user is part of. For
this, we add an additional scope "freedombox_groups" and additional claim
"freedombox_groups". To define custom scopes and claims, we need to ensure
that the keys used are unique and will not clash with other
implementations. 'freedombox_' prefix seems reasonable. The value of this
claim is a list of all groups that the user account is part of.
"""
# Add a scope, as recommended in the oauth-toolkit documentation.
oidc_claim_scope = oauth2_validators.OAuth2Validator.oidc_claim_scope
oidc_claim_scope.update({'freedombox_groups': 'freedombox_groups'})
def get_additional_claims(self):
"""Override value of 'sub' claim and add other claims.
Only the 'sub' claim is filled by default by django-oauth-toolkit. The
rest, as needed, must be filled by us.
Use the 'second' form of get_additional_claims override as
documentation suggests so that the base code and automatically add the
list of claims returned here to list of supported claims.
"""
def _get_user_groups(request):
return list(request.user.groups.values_list('name', flat=True))
return {
'sub': lambda request: request.user.username,
'email': lambda request: request.user.email,
'preferred_username': lambda request: request.user.username,
'freedombox_groups': _get_user_groups,
}
def validate_redirect_uri(self, client_id, redirect_uri, request, *args,
**kwargs):
"""Additionally allow redirection to this server's IPs and domains."""
allowed_redirect_uris = request.client.redirect_uris.split()
if _validate_local_domains_and_ips(redirect_uri, request,
allowed_redirect_uris):
return True
return super().validate_redirect_uri(client_id, redirect_uri, request,
*args, **kwargs)
def _validate_local_domains_and_ips(redirect_uri, request,
allowed_redirect_uris):
"""Allow redirect to local domains and IPs.
See models.py:redirect_to_uri_allowed() in django-oauth-toolkit for
reference.
Path and query part of the redirect URL must always match with one of the
configured redirect URLs for this application. The query in the redirect
URI is allowed to be a subset of the allowed query.
Localhost domains (localhost, ip6-localhost, and ip6-loopback) are allowed
in redirect URLs. Scheme and port are not checked.
An IP address based redirect URL is accepted as long as it is to the same
IP address with which the FreedomBox's Identity Provider is being accessed.
Scheme is not checked. Changing IP address during OpenID Connect flow is
not allowed.
"""
request_host = request.headers.get('HTTP_HOST')
parsed_redirect_uri = urllib.parse.urlparse(redirect_uri)
redirect_uri_query_set = set(
urllib.parse.parse_qs(parsed_redirect_uri.query))
try:
ipaddress.ip_address(parsed_redirect_uri.hostname)
redirect_uri_is_ip = True
except ValueError:
redirect_uri_is_ip = False
redirect_uri_is_localhost = parsed_redirect_uri.hostname in (
'localhost', 'ip6-localhost', 'ip6-loopback',
action_utils.get_hostname())
for allowed_uri in allowed_redirect_uris:
parsed_allowed_uri = urllib.parse.urlparse(allowed_uri)
# Path must match one of the allowed paths
if parsed_redirect_uri.path != parsed_allowed_uri.path:
continue
# Query must be a subset of allowed query
allowed_query_set = set(urllib.parse.parse_qs(
parsed_allowed_uri.query))
if not allowed_query_set.issubset(redirect_uri_query_set):
continue
# If the redirect is to an IP address, it is only allowed if the IDP
# itself is being accessed with that IP address.
if (redirect_uri_is_ip and request_host
and parsed_redirect_uri.netloc == request_host):
return True
# If the redirect is to a 'localhost' like address, a port mismatch is
# allowed.
if redirect_uri_is_localhost:
return True
return False # Special criteria didn't match, do usual checks.

View File

@ -27,6 +27,7 @@ See: https://docs.djangoproject.com/en/dev/ref/settings/
"""
import django
from django.utils.translation import gettext_lazy as _
ALLOWED_HOSTS = ['*']
@ -125,6 +126,7 @@ INSTALLED_APPS = [
'django.contrib.contenttypes',
'django.contrib.messages',
'django.contrib.sessions',
'oauth2_provider',
'stronghold',
'plinth',
]
@ -171,6 +173,21 @@ MIDDLEWARE = (
'axes.middleware.AxesMiddleware',
)
OAUTH2_PROVIDER = {
'OIDC_ENABLED': True,
'OAUTH2_VALIDATOR_CLASS': 'plinth.modules.oidc.validators.OAuth2Validator',
'SCOPES': {
'openid':
_('Uniquely identify your user account with username'),
'email':
_('View email address'),
'profile':
_('View basic profile information (such as name and email)'),
'freedombox_groups':
_('View permissions that account has')
},
}
PASSWORD_HASHERS = [
'plinth.hashers.Argon2PasswordHasherLowMemory',
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
@ -192,7 +209,7 @@ SESSION_FILE_PATH = '/var/lib/plinth/sessions'
# Overridden based on configuration key server_dir
STATIC_URL = '/freedombox/static/'
# STRONGHOLD_PUBLIC_URLS=(r'^captcha/', )
STRONGHOLD_PUBLIC_URLS = (r'^/o/', )
STRONGHOLD_PUBLIC_NAMED_URLS = (
'captcha-image',

View File

@ -35,6 +35,7 @@ INSTALLED_APPS = [
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.messages',
'oauth2_provider',
'stronghold',
'plinth',
]
@ -51,6 +52,8 @@ ROOT_URLCONF = 'plinth.tests.data.urls'
SECRET_KEY = 'django_tests_secret_key'
STRONGHOLD_PUBLIC_URLS = (r'^/o/', )
TEMPLATES = [{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'APP_DIRS': True,

View File

@ -257,6 +257,16 @@ class TestAdminMiddleware:
response = middleware.process_view(web_request, **kwargs)
assert response is None
@staticmethod
def test_that_stronghold_allowed_urls_are_allowed_for_normal_user(
middleware, kwargs):
web_request = RequestFactory().get('/o/authorize/')
web_request.user = Mock()
web_request.user.groups.filter().exists = Mock(return_value=False)
web_request.session = MagicMock()
response = middleware.process_view(web_request, **kwargs)
assert response is None
class TestCommonErrorMiddleware:
"""Test cases for common error middleware."""

View File

@ -3,7 +3,8 @@
Django URLconf file containing all urls
"""
from captcha import views as cviews
from django.urls import include, re_path
from django.urls import include, path, re_path
from oauth2_provider import urls as oauth2_urls
from stronghold.decorators import public
from . import views
@ -46,5 +47,9 @@ urlpatterns = [
# Notifications
re_path(r'^notification/(?P<id>[A-Za-z0-9-=]+)/dismiss/$',
views.notification_dismiss, name='notification_dismiss')
views.notification_dismiss, name='notification_dismiss'),
# OpenID Provider related URLs. They need to be under 'oauth2_provider:'
# namespace otherwise .well-known/openid-configuration will fail.
path('o/', include(oauth2_urls)),
]

View File

@ -170,6 +170,7 @@ module = [
"django.*",
"gi.*",
"numpy.*",
"oauth2_provider.*",
"pam.*",
"pexpect.*",
"pgi.*",