diff --git a/data/etc/apache2/conf-available/freedombox.conf b/data/etc/apache2/conf-available/freedombox.conf
index 645d11173..292189481 100644
--- a/data/etc/apache2/conf-available/freedombox.conf
+++ b/data/etc/apache2/conf-available/freedombox.conf
@@ -76,6 +76,18 @@
RequestHeader set X-Forwarded-Proto 'https' env=HTTPS
RequestHeader unset X-Forwarded-For
+
+ 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
+
+
+ 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
+
##
## Serve FreedomBox icon as /favicon.ico for apps that don't present their own
diff --git a/debian/control b/debian/control
index 5a45dd0b9..854dbeba5 100644
--- a/debian/control
+++ b/debian/control
@@ -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,
diff --git a/plinth/middleware.py b/plinth/middleware.py
index 13e39d7e9..aeee61853 100644
--- a/plinth/middleware.py
+++ b/plinth/middleware.py
@@ -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):
diff --git a/plinth/modules/names/__init__.py b/plinth/modules/names/__init__.py
index 2a80fa3b8..8e1e309ba 100644
--- a/plinth/modules/names/__init__.py
+++ b/plinth/modules/names/__init__.py
@@ -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:
diff --git a/plinth/modules/oidc/__init__.py b/plinth/modules/oidc/__init__.py
new file mode 100644
index 000000000..5c593cd0d
--- /dev/null
+++ b/plinth/modules/oidc/__init__.py
@@ -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()
diff --git a/plinth/modules/oidc/components.py b/plinth/modules/oidc/components.py
new file mode 100644
index 000000000..8449e960f
--- /dev/null
+++ b/plinth/modules/oidc/components.py
@@ -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)
diff --git a/plinth/modules/oidc/data/usr/share/freedombox/modules-enabled/oidc b/plinth/modules/oidc/data/usr/share/freedombox/modules-enabled/oidc
new file mode 100644
index 000000000..2060a32b2
--- /dev/null
+++ b/plinth/modules/oidc/data/usr/share/freedombox/modules-enabled/oidc
@@ -0,0 +1 @@
+plinth.modules.oidc
diff --git a/plinth/modules/oidc/tests/__init__.py b/plinth/modules/oidc/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/plinth/modules/oidc/tests/test_components.py b/plinth/modules/oidc/tests/test_components.py
new file mode 100644
index 000000000..4c8349a3b
--- /dev/null
+++ b/plinth/modules/oidc/tests/test_components.py
@@ -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
diff --git a/plinth/modules/oidc/urls.py b/plinth/modules/oidc/urls.py
new file mode 100644
index 000000000..d82ec98ca
--- /dev/null
+++ b/plinth/modules/oidc/urls.py
@@ -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 = []
diff --git a/plinth/modules/oidc/validators.py b/plinth/modules/oidc/validators.py
new file mode 100644
index 000000000..0003539c4
--- /dev/null
+++ b/plinth/modules/oidc/validators.py
@@ -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.
diff --git a/plinth/settings.py b/plinth/settings.py
index 436e5f277..50c303942 100644
--- a/plinth/settings.py
+++ b/plinth/settings.py
@@ -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',
diff --git a/plinth/tests/data/django_test_settings.py b/plinth/tests/data/django_test_settings.py
index 0f742f4e3..9808b00b8 100644
--- a/plinth/tests/data/django_test_settings.py
+++ b/plinth/tests/data/django_test_settings.py
@@ -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,
diff --git a/plinth/tests/test_middleware.py b/plinth/tests/test_middleware.py
index 47d46ab24..7895ba51a 100644
--- a/plinth/tests/test_middleware.py
+++ b/plinth/tests/test_middleware.py
@@ -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."""
diff --git a/plinth/urls.py b/plinth/urls.py
index 8842e7b88..59bc844d8 100644
--- a/plinth/urls.py
+++ b/plinth/urls.py
@@ -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[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)),
]
diff --git a/pyproject.toml b/pyproject.toml
index e04778dbf..436ab5e1c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -170,6 +170,7 @@ module = [
"django.*",
"gi.*",
"numpy.*",
+ "oauth2_provider.*",
"pam.*",
"pexpect.*",
"pgi.*",