FreedomBox/plinth/modules/oidc/components.py
Sunil Mohan Adapa 45076cc603
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>
2026-03-02 20:50:46 -05:00

170 lines
6.8 KiB
Python

# 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)