From b5a291ac272873a700f9d036febb83eea04fc046 Mon Sep 17 00:00:00 2001 From: Conor Holden Date: Tue, 22 Oct 2024 17:30:28 +0200 Subject: [PATCH 01/16] :see_no_evil: add pycharm files to .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index bbee189..c8a4103 100644 --- a/.gitignore +++ b/.gitignore @@ -103,3 +103,6 @@ venv.bak/ # mypy .mypy_cache/ + +# Pycharfiles +.idea/ From 4496f50bcb07f6db2441def2002315d6195ba86c Mon Sep 17 00:00:00 2001 From: Conor Holden Date: Fri, 22 Nov 2024 16:14:03 +0100 Subject: [PATCH 02/16] :sparkles: add validator to claim_mapping --- mozilla_django_oidc_db/constants.py | 13 ++++++++++ ...alter_openidconnectconfig_claim_mapping.py | 24 +++++++++++++++++++ mozilla_django_oidc_db/models.py | 8 ++++--- pyproject.toml | 2 +- 4 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 mozilla_django_oidc_db/migrations/0005_alter_openidconnectconfig_claim_mapping.py diff --git a/mozilla_django_oidc_db/constants.py b/mozilla_django_oidc_db/constants.py index 306f4fb..cb41e08 100644 --- a/mozilla_django_oidc_db/constants.py +++ b/mozilla_django_oidc_db/constants.py @@ -11,3 +11,16 @@ OPEN_ID_CONFIG_PATH = ".well-known/openid-configuration" CONFIG_CLASS_SESSION_KEY = "_OIDCDB_CONFIG_CLASS" + +CLAIM_MAPPING_SCHEMA = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Claim Mapping", + "description": "Mapping from user-model fields to OIDC claims", + "type": "object", + "properties": {}, + "additionalProperties": { + "description": "mapping", + "type": "array", + "items": {"type": "string"}, + }, +} diff --git a/mozilla_django_oidc_db/migrations/0005_alter_openidconnectconfig_claim_mapping.py b/mozilla_django_oidc_db/migrations/0005_alter_openidconnectconfig_claim_mapping.py new file mode 100644 index 0000000..42228f4 --- /dev/null +++ b/mozilla_django_oidc_db/migrations/0005_alter_openidconnectconfig_claim_mapping.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.15 on 2024-10-25 14:15 + +from django.db import migrations +import django_jsonform.models.fields +import mozilla_django_oidc_db.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("mozilla_django_oidc_db", "0004_remove_openidconnectconfig_oidc_exempt_urls"), + ] + + operations = [ + migrations.AlterField( + model_name="openidconnectconfig", + name="claim_mapping", + field=django_jsonform.models.fields.JSONField( + default=mozilla_django_oidc_db.models.get_claim_mapping, + help_text="Mapping from user-model fields to OIDC claims", + verbose_name="claim mapping", + ), + ), + ] diff --git a/mozilla_django_oidc_db/models.py b/mozilla_django_oidc_db/models.py index 7044413..e347a54 100644 --- a/mozilla_django_oidc_db/models.py +++ b/mozilla_django_oidc_db/models.py @@ -11,10 +11,11 @@ from django.utils.encoding import force_str from django.utils.translation import gettext_lazy as _ -from django_jsonform.models.fields import ArrayField +from django_jsonform.models.fields import ArrayField, JSONField from solo import settings as solo_settings from solo.models import SingletonModel +from .constants import CLAIM_MAPPING_SCHEMA from .fields import ClaimField, ClaimFieldDefault from .typing import ClaimPath, DjangoView @@ -249,10 +250,11 @@ class OpenIDConnectConfig(OpenIDConnectConfigBase): help_text=_("The name of the OIDC claim that is used as the username"), ) - claim_mapping = models.JSONField( + claim_mapping = JSONField( _("claim mapping"), default=get_claim_mapping, - help_text=("Mapping from user-model fields to OIDC claims"), + help_text=_("Mapping from user-model fields to OIDC claims"), + schema=CLAIM_MAPPING_SCHEMA, ) groups_claim = ClaimField( verbose_name=_("groups claim"), diff --git a/pyproject.toml b/pyproject.toml index 55b6326..ccc89a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ classifiers = [ requires-python = ">=3.10" dependencies = [ "django>=4.2", - "django-jsonform", + "django-jsonform>=2.12", "django-solo", "glom", "mozilla-django-oidc>=3.0.0", From 5715b80451d85d0d5fd86bd6a8bea58b69eb29d6 Mon Sep 17 00:00:00 2001 From: Conor Holden Date: Tue, 22 Oct 2024 17:21:48 +0200 Subject: [PATCH 03/16] :sparkles:[#114] add setup configuration * Add setup configuraiton model and step * Add OIDCSetupConfigForm with only required fields * Create common create_missing_groups util --- mozilla_django_oidc_db/backends.py | 19 +--- mozilla_django_oidc_db/fields.py | 4 +- mozilla_django_oidc_db/forms.py | 14 +++ .../setup_configuration/__init__.py | 0 .../setup_configuration/models.py | 92 +++++++++++++++++++ .../setup_configuration/steps.py | 53 +++++++++++ mozilla_django_oidc_db/utils.py | 24 ++++- 7 files changed, 190 insertions(+), 16 deletions(-) create mode 100644 mozilla_django_oidc_db/setup_configuration/__init__.py create mode 100644 mozilla_django_oidc_db/setup_configuration/models.py create mode 100644 mozilla_django_oidc_db/setup_configuration/steps.py diff --git a/mozilla_django_oidc_db/backends.py b/mozilla_django_oidc_db/backends.py index eff54be..efb66bc 100644 --- a/mozilla_django_oidc_db/backends.py +++ b/mozilla_django_oidc_db/backends.py @@ -1,6 +1,5 @@ from __future__ import annotations -import fnmatch import logging from collections.abc import Collection from typing import Any, TypeAlias, cast @@ -25,7 +24,7 @@ from .jwt import verify_and_decode_token from .models import OpenIDConnectConfigBase, UserInformationClaimsSources from .typing import ClaimPath, JSONObject -from .utils import extract_content_type, obfuscate_claims +from .utils import create_missing_groups, extract_content_type, obfuscate_claims logger = logging.getLogger(__name__) @@ -385,18 +384,10 @@ def _set_user_groups( return # Create missing groups if required - existing_groups = set(Group.objects.filter(name__in=desired_group_names)) - existing_group_names = {group.name for group in existing_groups} - filtered_names = fnmatch.filter( - set(desired_group_names) - existing_group_names, sync_groups_glob - ) - groups_to_create = ( - [Group(name=name) for name in filtered_names] if sync_missing_groups else [] - ) - if groups_to_create: - # postgres sets the PK after bulk_create - Group.objects.bulk_create(groups_to_create) - existing_groups |= set(groups_to_create) + if sync_missing_groups: + existing_groups = create_missing_groups(desired_group_names, sync_groups_glob) + else: + existing_groups = set(Group.objects.filter(name__in=desired_group_names)) # at this point, existing_groups is the full collection of groups that should be # set on the user model, because: diff --git a/mozilla_django_oidc_db/fields.py b/mozilla_django_oidc_db/fields.py index b3c5ddb..9d6e318 100644 --- a/mozilla_django_oidc_db/fields.py +++ b/mozilla_django_oidc_db/fields.py @@ -26,7 +26,9 @@ def __init__(self, *bits: str): self.bits = list(bits) def __eq__(self, other) -> bool: - return self.bits == other.bits + if isinstance(other, ClaimFieldDefault): + return self.bits == other.bits + return False def __call__(self) -> list[str]: return self.bits diff --git a/mozilla_django_oidc_db/forms.py b/mozilla_django_oidc_db/forms.py index f1e8ef0..5ae7c1a 100644 --- a/mozilla_django_oidc_db/forms.py +++ b/mozilla_django_oidc_db/forms.py @@ -72,3 +72,17 @@ def clean(self): self.add_error(field, _("This field is required.")) return cleaned_data + + +class OIDCSetupConfigForm(OpenIDConnectConfigForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if self.fields: + self.fields["oidc_rp_sign_algo"].required = False + self.fields["oidc_nonce_size"].required = False + self.fields["oidc_state_size"].required = False + self.fields["userinfo_claims_source"].required = False + self.fields["username_claim"].required = False + self.fields["claim_mapping"].required = False + self.fields["sync_groups_glob_pattern"].required = False diff --git a/mozilla_django_oidc_db/setup_configuration/__init__.py b/mozilla_django_oidc_db/setup_configuration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mozilla_django_oidc_db/setup_configuration/models.py b/mozilla_django_oidc_db/setup_configuration/models.py new file mode 100644 index 0000000..6b55167 --- /dev/null +++ b/mozilla_django_oidc_db/setup_configuration/models.py @@ -0,0 +1,92 @@ +from typing import Optional, Union + +from django_setup_configuration.fields import DjangoModelRef +from django_setup_configuration.models import ConfigurationModel +from pydantic import AnyUrl, Discriminator, Tag +from typing_extensions import Annotated + +from mozilla_django_oidc_db.models import OpenIDConnectConfig + + +class OIDCFullEndpointConfig(ConfigurationModel): + oidc_op_authorization_endpoint: AnyUrl = DjangoModelRef( + OpenIDConnectConfig, "oidc_op_authorization_endpoint" + ) + oidc_op_token_endpoint: AnyUrl = DjangoModelRef( + OpenIDConnectConfig, "oidc_op_token_endpoint" + ) + oidc_op_user_endpoint: AnyUrl = DjangoModelRef( + OpenIDConnectConfig, "oidc_op_user_endpoint" + ) + + +class OIDCDiscoveryEndpoint(ConfigurationModel): + oidc_op_discovery_endpoint: AnyUrl = DjangoModelRef( + OpenIDConnectConfig, "oidc_op_discovery_endpoint", default=None + ) + + +def get_endpoint_endpoint_model(endpoint_data): + + if isinstance(endpoint_data, dict): + discovery_endpoint = endpoint_data.get("oidc_op_discovery_endpoint") + else: + discovery_endpoint = getattr(endpoint_data, "oidc_op_discovery_endpoint", None) + if discovery_endpoint: + return "discovery" + return "all" + + +EndpointConfigUnion = Annotated[ + Union[ + Annotated[OIDCFullEndpointConfig, Tag("all")], + Annotated[OIDCDiscoveryEndpoint, Tag("discovery")], + ], + Discriminator(get_endpoint_endpoint_model), +] + + +class AdminOIDCConfigurationModel(ConfigurationModel): + + # Json + claim_mapping: Optional[dict] = DjangoModelRef(OpenIDConnectConfig, "claim_mapping") + + # Arrays are overridden to make the typing simpler (the underlying Django field is an ArrayField, which is non-standard) + username_claim: Optional[list[str]] = DjangoModelRef( + OpenIDConnectConfig, "username_claim" + ) + groups_claim: Optional[list[str]] = DjangoModelRef( + OpenIDConnectConfig, "groups_claim" + ) + superuser_group_names: Optional[list[str]] = DjangoModelRef( + OpenIDConnectConfig, "superuser_group_names" + ) + default_groups: Optional[list[str]] = DjangoModelRef( + OpenIDConnectConfig, "superuser_group_names" + ) + oidc_rp_scopes_list: Optional[list[str]] = DjangoModelRef( + OpenIDConnectConfig, "oidc_rp_scopes_list" + ) + + endpoint_config: EndpointConfigUnion + + class Meta: + django_model_refs = { + OpenIDConnectConfig: [ + "oidc_rp_client_id", + "oidc_rp_client_secret", + "oidc_token_use_basic_auth", + "oidc_rp_sign_algo", + "oidc_rp_idp_sign_key", + "oidc_op_logout_endpoint", + "oidc_op_jwks_endpoint", + "oidc_use_nonce", + "oidc_nonce_size", + "oidc_state_size", + "oidc_keycloak_idp_hint", + "userinfo_claims_source", + "sync_groups", + "sync_groups_glob_pattern", + "make_users_staff", + ] + } diff --git a/mozilla_django_oidc_db/setup_configuration/steps.py b/mozilla_django_oidc_db/setup_configuration/steps.py new file mode 100644 index 0000000..7f55846 --- /dev/null +++ b/mozilla_django_oidc_db/setup_configuration/steps.py @@ -0,0 +1,53 @@ +from django_setup_configuration.configuration import BaseConfigurationStep +from django_setup_configuration.exceptions import ConfigurationRunFailed + +from mozilla_django_oidc_db.forms import OIDCSetupConfigForm +from mozilla_django_oidc_db.models import OpenIDConnectConfig +from mozilla_django_oidc_db.setup_configuration.models import ( + AdminOIDCConfigurationModel, +) +from mozilla_django_oidc_db.utils import create_missing_groups + + +class AdminOIDCConfigurationStep(BaseConfigurationStep[AdminOIDCConfigurationModel]): + """ + Configure admin login via OpenID Connect + """ + + verbose_name = "Configuration for admin login via OpenID Connect" + config_model = AdminOIDCConfigurationModel + namespace = "ADMIN_OIDC" + enable_setting = "ADMIN_OIDC_CONFIG_ENABLE" + + def execute(self, model: AdminOIDCConfigurationModel) -> None: + + config = OpenIDConnectConfig.get_solo() + + base_model_data = model.model_dump() + endpoint_config_data = base_model_data.pop("endpoint_config") + + all_settings = { + "sync_groups": config.sync_groups, + "oidc_use_nonce": config.oidc_use_nonce, + "enabled": True, + "claim_mapping": config.claim_mapping, # JSONFormField widget cannot handle blank values with object schema + "sync_groups_glob_pattern": config.sync_groups_glob_pattern, + **base_model_data, + **endpoint_config_data, + } + + if groups := all_settings.get("default_groups"): + all_settings["default_groups"] = create_missing_groups( + groups, all_settings["sync_groups_glob_pattern"] + ) + + form = OIDCSetupConfigForm( + instance=config, + data=all_settings, + ) + if not form.is_valid(): + raise ConfigurationRunFailed( + "Admin OIDC configuration field validation failed", + form.errors.as_json(), + ) + form.save() diff --git a/mozilla_django_oidc_db/utils.py b/mozilla_django_oidc_db/utils.py index 663f6b9..0b05de4 100644 --- a/mozilla_django_oidc_db/utils.py +++ b/mozilla_django_oidc_db/utils.py @@ -1,7 +1,10 @@ +import fnmatch import logging -from collections.abc import Collection +from collections.abc import Collection, Iterable from copy import deepcopy +from django.contrib.auth.models import Group + import requests from glom import Path, PathAccessError, assign, glom from requests.utils import _parse_content_type_header # type: ignore @@ -89,3 +92,22 @@ def do_op_logout(config: OpenIDConnectConfigBase, id_token: str) -> None: "status_code": response.status_code, }, ) + + +def create_missing_groups( + group_names: Iterable[str], sync_groups_glob: str = "*" +) -> set[Group]: + + existing_groups = set(Group.objects.filter(name__in=group_names)) + existing_group_names = {group.name for group in existing_groups} + + filtered_names = fnmatch.filter( + set(group_names) - existing_group_names, sync_groups_glob + ) + + groups_to_create = [Group(name=name) for name in filtered_names] + if groups_to_create: + # postgres sets the PK after bulk_create + Group.objects.bulk_create(groups_to_create) + existing_groups |= set(groups_to_create) + return existing_groups From a63657ea205b771156b7ffd435a28697e1ac5d23 Mon Sep 17 00:00:00 2001 From: Conor Holden Date: Tue, 22 Oct 2024 17:22:09 +0200 Subject: [PATCH 04/16] :white_check_mark:[#114] add setup configuration tests --- testapp/settings.py | 13 + tests/setupconfig/__init__.py | 0 ...test_configure_use_discovery_endpoint.yaml | 39 +++ tests/setupconfig/conftest.py | 35 +++ tests/setupconfig/files/defaults.yml | 8 + tests/setupconfig/files/discovery.yml | 6 + .../setupconfig/files/discovery_disabled.yml | 6 + tests/setupconfig/files/empty.yml | 2 + tests/setupconfig/files/full_setup.yml | 36 +++ tests/setupconfig/files/partial_endpoints.yml | 8 + tests/setupconfig/test_steps.py | 237 ++++++++++++++++++ 11 files changed, 390 insertions(+) create mode 100644 tests/setupconfig/__init__.py create mode 100644 tests/setupconfig/cassettes/test_steps/test_configure_use_discovery_endpoint.yaml create mode 100644 tests/setupconfig/conftest.py create mode 100644 tests/setupconfig/files/defaults.yml create mode 100644 tests/setupconfig/files/discovery.yml create mode 100644 tests/setupconfig/files/discovery_disabled.yml create mode 100644 tests/setupconfig/files/empty.yml create mode 100644 tests/setupconfig/files/full_setup.yml create mode 100644 tests/setupconfig/files/partial_endpoints.yml create mode 100644 tests/setupconfig/test_steps.py diff --git a/testapp/settings.py b/testapp/settings.py index 1ac9c6d..6fbc264 100644 --- a/testapp/settings.py +++ b/testapp/settings.py @@ -80,3 +80,16 @@ LOGIN_REDIRECT_URL = reverse_lazy("admin:index") STATIC_URL = "/static/" + +# Django setup configuration settings +try: + import django_setup_configuration + + INSTALLED_APPS += ["django_setup_configuration"] + + OIDC_DB_CONFIG_ENABLE = True + SETUP_CONFIGURATION_STEPS = [ + "mozilla_django_oidc_db.setup_configuration.steps.AdminOIDCConfigurationStep", + ] +except ImportError: + pass diff --git a/tests/setupconfig/__init__.py b/tests/setupconfig/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/setupconfig/cassettes/test_steps/test_configure_use_discovery_endpoint.yaml b/tests/setupconfig/cassettes/test_steps/test_configure_use_discovery_endpoint.yaml new file mode 100644 index 0000000..7323358 --- /dev/null +++ b/tests/setupconfig/cassettes/test_steps/test_configure_use_discovery_endpoint.yaml @@ -0,0 +1,39 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - python-requests/2.32.3 + method: GET + uri: http://localhost:8080/realms/test/.well-known/openid-configuration + response: + body: + string: '{"issuer":"http://localhost:8080/realms/test","authorization_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/auth","token_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/token","introspection_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/token/introspect","userinfo_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/userinfo","end_session_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/logout","frontchannel_logout_session_supported":true,"frontchannel_logout_supported":true,"jwks_uri":"http://localhost:8080/realms/test/protocol/openid-connect/certs","check_session_iframe":"http://localhost:8080/realms/test/protocol/openid-connect/login-status-iframe.html","grant_types_supported":["authorization_code","implicit","refresh_token","password","client_credentials","urn:openid:params:grant-type:ciba","urn:ietf:params:oauth:grant-type:device_code"],"acr_values_supported":["0","1"],"response_types_supported":["code","none","id_token","token","id_token + token","code id_token","code token","code id_token token"],"subject_types_supported":["public","pairwise"],"id_token_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"id_token_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"id_token_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"userinfo_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512","none"],"userinfo_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"userinfo_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"request_object_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512","none"],"request_object_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"request_object_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"response_modes_supported":["query","fragment","form_post","query.jwt","fragment.jwt","form_post.jwt","jwt"],"registration_endpoint":"http://localhost:8080/realms/test/clients-registrations/openid-connect","token_endpoint_auth_methods_supported":["private_key_jwt","client_secret_basic","client_secret_post","tls_client_auth","client_secret_jwt"],"token_endpoint_auth_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"introspection_endpoint_auth_methods_supported":["private_key_jwt","client_secret_basic","client_secret_post","tls_client_auth","client_secret_jwt"],"introspection_endpoint_auth_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"authorization_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"authorization_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"authorization_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"claims_supported":["aud","sub","iss","auth_time","name","given_name","family_name","preferred_username","email","acr"],"claim_types_supported":["normal"],"claims_parameter_supported":true,"scopes_supported":["openid","email","roles","phone","profile","address","kvk","web-origins","microprofile-jwt","acr","offline_access","bsn"],"request_parameter_supported":true,"request_uri_parameter_supported":true,"require_request_uri_registration":true,"code_challenge_methods_supported":["plain","S256"],"tls_client_certificate_bound_access_tokens":true,"revocation_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/revoke","revocation_endpoint_auth_methods_supported":["private_key_jwt","client_secret_basic","client_secret_post","tls_client_auth","client_secret_jwt"],"revocation_endpoint_auth_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"backchannel_logout_supported":true,"backchannel_logout_session_supported":true,"device_authorization_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/auth/device","backchannel_token_delivery_modes_supported":["poll","ping"],"backchannel_authentication_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/ext/ciba/auth","backchannel_authentication_request_signing_alg_values_supported":["PS384","ES384","RS384","ES256","RS256","ES512","PS256","PS512","RS512"],"require_pushed_authorization_requests":false,"pushed_authorization_request_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/ext/par/request","mtls_endpoint_aliases":{"token_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/token","revocation_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/revoke","introspection_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/token/introspect","device_authorization_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/auth/device","registration_endpoint":"http://localhost:8080/realms/test/clients-registrations/openid-connect","userinfo_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/userinfo","pushed_authorization_request_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/ext/par/request","backchannel_authentication_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/ext/ciba/auth"},"authorization_response_iss_parameter_supported":true}' + headers: + Cache-Control: + - no-cache, must-revalidate, no-transform, no-store + Content-Type: + - application/json;charset=UTF-8 + Referrer-Policy: + - no-referrer + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-XSS-Protection: + - 1; mode=block + content-length: + - '5847' + status: + code: 200 + message: OK +version: 1 diff --git a/tests/setupconfig/conftest.py b/tests/setupconfig/conftest.py new file mode 100644 index 0000000..0783f83 --- /dev/null +++ b/tests/setupconfig/conftest.py @@ -0,0 +1,35 @@ +import pytest +from django_setup_configuration.test_utils import build_step_config_from_sources + +from mozilla_django_oidc_db.setup_configuration.steps import AdminOIDCConfigurationStep + +""" +Key cloak credentials are setup for the keycloak docker-compose.yml. + +`oidc_rp_client_id` and `oidc_rp_client_secret` are taken from the keycloak fixture +in /docker/import/test-reaml.json + +See more info in /docker/README.md + +""" + + +@pytest.fixture +def setup_config_discovery_model(settings): + return build_step_config_from_sources( + AdminOIDCConfigurationStep, "tests/setupconfig/files/discovery.yml" + ) + + +@pytest.fixture +def setup_config_defaults_model(settings): + return build_step_config_from_sources( + AdminOIDCConfigurationStep, "tests/setupconfig/files/defaults.yml" + ) + + +@pytest.fixture +def setup_config_full_model(): + return build_step_config_from_sources( + AdminOIDCConfigurationStep, "tests/setupconfig/files/full_setup.yml" + ) diff --git a/tests/setupconfig/files/defaults.yml b/tests/setupconfig/files/defaults.yml new file mode 100644 index 0000000..f189ed0 --- /dev/null +++ b/tests/setupconfig/files/defaults.yml @@ -0,0 +1,8 @@ +ADMIN_OIDC_CONFIG_ENABLE: True +ADMIN_OIDC: + oidc_rp_client_id: client-id + oidc_rp_client_secret: secret + endpoint_config: + oidc_op_authorization_endpoint: http://localhost:8080/realms/test/protocol/openid-connect/auth + oidc_op_token_endpoint: http://localhost:8080/realms/test/protocol/openid-connect/token + oidc_op_user_endpoint: http://localhost:8080/realms/test/protocol/openid-connect/userinfo diff --git a/tests/setupconfig/files/discovery.yml b/tests/setupconfig/files/discovery.yml new file mode 100644 index 0000000..3994066 --- /dev/null +++ b/tests/setupconfig/files/discovery.yml @@ -0,0 +1,6 @@ +ADMIN_OIDC_CONFIG_ENABLE: True +ADMIN_OIDC: + oidc_rp_client_id: testid + oidc_rp_client_secret: 7DB3KUAAizYCcmZufpHRVOcD0TOkNO3I + endpoint_config: + oidc_op_discovery_endpoint: http://localhost:8080/realms/test/ diff --git a/tests/setupconfig/files/discovery_disabled.yml b/tests/setupconfig/files/discovery_disabled.yml new file mode 100644 index 0000000..d410413 --- /dev/null +++ b/tests/setupconfig/files/discovery_disabled.yml @@ -0,0 +1,6 @@ +ADMIN_OIDC_CONFIG_ENABLE: False +ADMIN_OIDC: + oidc_rp_client_id: testid + oidc_rp_client_secret: 7DB3KUAAizYCcmZufpHRVOcD0TOkNO3I + endpoint_config: + oidc_op_discovery_endpoint: http://localhost:8080/realms/test/ diff --git a/tests/setupconfig/files/empty.yml b/tests/setupconfig/files/empty.yml new file mode 100644 index 0000000..c950536 --- /dev/null +++ b/tests/setupconfig/files/empty.yml @@ -0,0 +1,2 @@ +ADMIN_OIDC_CONFIG_ENABLE: True +ADMIN_OIDC: {} diff --git a/tests/setupconfig/files/full_setup.yml b/tests/setupconfig/files/full_setup.yml new file mode 100644 index 0000000..3b9e08f --- /dev/null +++ b/tests/setupconfig/files/full_setup.yml @@ -0,0 +1,36 @@ +ADMIN_OIDC_CONFIG_ENABLE: True +ADMIN_OIDC: + oidc_rp_client_id: client-id + oidc_rp_client_secret: secret + oidc_rp_scopes_list: + - open_id + - email + - profile + - extra_scope + oidc_rp_sign_algo: RS256 + oidc_rp_idp_sign_key: key + oidc_op_jwks_endpoint: http://localhost:8080/realms/test/protocol/openid-connect/certs + endpoint_config: + oidc_op_authorization_endpoint: http://localhost:8080/realms/test/protocol/openid-connect/auth + oidc_op_token_endpoint: http://localhost:8080/realms/test/protocol/openid-connect/token + oidc_op_user_endpoint: http://localhost:8080/realms/test/protocol/openid-connect/userinfo + username_claim: + - claim_name + groups_claim: + - groups_claim_name + claim_mapping: + first_name: + - given_name + sync_groups: false + sync_groups_glob_pattern: local.groups.* + default_groups: + - local.groups.Admins + - local.groups.Read-only + make_users_staff: true + superuser_group_names: + - superuser + oidc_use_nonce: false + oidc_nonce_size: 48 + oidc_state_size: 48 + userinfo_claims_source: id_token + diff --git a/tests/setupconfig/files/partial_endpoints.yml b/tests/setupconfig/files/partial_endpoints.yml new file mode 100644 index 0000000..3518bde --- /dev/null +++ b/tests/setupconfig/files/partial_endpoints.yml @@ -0,0 +1,8 @@ +ADMIN_OIDC_CONFIG_ENABLE: True +ADMIN_OIDC: + oidc_rp_client_id: client-id + oidc_rp_client_secret: secret + endpoint_config: + oidc_op_authorization_endpoint: http://localhost:8080/realms/test/protocol/openid-connect/auth +# oidc_op_token_endpoint: http://localhost:8080/realms/test/protocol/openid-connect/token +# oidc_op_user_endpoint: http://localhost:8080/realms/test/protocol/openid-connect/userinfo diff --git a/tests/setupconfig/test_steps.py b/tests/setupconfig/test_steps.py new file mode 100644 index 0000000..f5d2eca --- /dev/null +++ b/tests/setupconfig/test_steps.py @@ -0,0 +1,237 @@ +from io import StringIO + +from django.core.management import CommandError, call_command + +import pytest +import requests +from django_setup_configuration.exceptions import ConfigurationRunFailed + +from mozilla_django_oidc_db.models import ( + OpenIDConnectConfig, + UserInformationClaimsSources, +) +from mozilla_django_oidc_db.setup_configuration.steps import AdminOIDCConfigurationStep + +from ..conftest import KEYCLOAK_BASE_URL + + +@pytest.fixture(autouse=True) +def clear_solo_cache(): + yield + OpenIDConnectConfig.clear_cache() + + +@pytest.mark.django_db +def test_configure(setup_config_full_model): + step = AdminOIDCConfigurationStep() + step.execute(setup_config_full_model) + + config = OpenIDConnectConfig.get_solo() + + assert config.enabled + assert config.oidc_rp_client_id == "client-id" + assert config.oidc_rp_client_secret == "secret" + assert config.oidc_rp_scopes_list == ["open_id", "email", "profile", "extra_scope"] + assert config.oidc_rp_sign_algo == "RS256" + assert config.oidc_rp_idp_sign_key == "key" + assert config.oidc_op_discovery_endpoint == "" + assert ( + config.oidc_op_jwks_endpoint + == f"{KEYCLOAK_BASE_URL}protocol/openid-connect/certs" + ) + assert ( + config.oidc_op_authorization_endpoint + == f"{KEYCLOAK_BASE_URL}protocol/openid-connect/auth" + ) + assert ( + config.oidc_op_token_endpoint + == f"{KEYCLOAK_BASE_URL}protocol/openid-connect/token" + ) + assert ( + config.oidc_op_user_endpoint + == f"{KEYCLOAK_BASE_URL}protocol/openid-connect/userinfo" + ) + assert config.username_claim == ["claim_name"] + assert config.groups_claim == ["groups_claim_name"] + assert config.claim_mapping == {"first_name": ["given_name"]} + assert not config.sync_groups + assert config.sync_groups_glob_pattern == "local.groups.*" + assert set(group.name for group in config.default_groups.all()) == { + "local.groups.Admins", + "local.groups.Read-only", + } + assert config.make_users_staff + assert config.superuser_group_names == ["superuser"] + assert not config.oidc_use_nonce + assert config.oidc_nonce_size == 48 + assert config.oidc_state_size == 48 + assert config.userinfo_claims_source == UserInformationClaimsSources.id_token + + +@pytest.mark.django_db +def test_required_settings(): + output = StringIO() + err = StringIO() + with pytest.raises(CommandError) as command_error: + call_command( + "setup_configuration", + yaml_file="tests/setupconfig/files/empty.yml", + stdout=output, + stderr=err, + ) + + assert "Prerequisites for configuration are not fulfilled:" in str( + command_error.value + ) + + assert "ADMIN_OIDC.oidc_rp_client_id" in str(command_error.value) + assert "ADMIN_OIDC.oidc_rp_client_secret" in str(command_error.value) + assert "ADMIN_OIDC.endpoint_config" in str(command_error.value) + + config = OpenIDConnectConfig.get_solo() + assert not config.enabled + + +@pytest.mark.django_db +def test_partial_endpoints_provided(): + """ + Test what if only one endpoint (not discovery) is provided + """ + output = StringIO() + err = StringIO() + with pytest.raises(CommandError) as command_error: + call_command( + "setup_configuration", + yaml_file="tests/setupconfig/files/partial_endpoints.yml", + stdout=output, + stderr=err, + ) + + assert "Prerequisites for configuration are not fulfilled:" in str( + command_error.value + ) + + assert "ADMIN_OIDC.endpoint_config.all.oidc_op_token_endpoint" in str( + command_error.value + ) + assert "ADMIN_OIDC.endpoint_config.all.oidc_op_user_endpoint" in str( + command_error.value + ) + + config = OpenIDConnectConfig.get_solo() + assert not config.enabled + + +@pytest.mark.django_db +def test_enable_setting(): + output = StringIO() + err = StringIO() + with pytest.raises(CommandError) as command_error: + call_command( + "setup_configuration", + yaml_file="tests/setupconfig/files/discovery_disabled.yml", + stdout=output, + stderr=err, + ) + + assert "No steps enabled, aborting." in str(command_error.value) + + config = OpenIDConnectConfig.get_solo() + assert not config.enabled + + +@pytest.mark.django_db +def test_configure_use_defaults(setup_config_defaults_model): + step = AdminOIDCConfigurationStep() + step.execute(setup_config_defaults_model) + + config = OpenIDConnectConfig.get_solo() + + assert config.enabled + assert config.oidc_rp_client_id == "client-id" + assert config.oidc_rp_client_secret == "secret" + assert config.oidc_rp_scopes_list == ["openid", "email", "profile"] + assert config.oidc_rp_sign_algo == "HS256" + assert config.oidc_rp_idp_sign_key == "" + assert config.oidc_op_discovery_endpoint == "" + assert config.oidc_op_jwks_endpoint == "" + + assert ( + config.oidc_op_authorization_endpoint + == f"{KEYCLOAK_BASE_URL}protocol/openid-connect/auth" + ) + assert ( + config.oidc_op_token_endpoint + == f"{KEYCLOAK_BASE_URL}protocol/openid-connect/token" + ) + assert ( + config.oidc_op_user_endpoint + == f"{KEYCLOAK_BASE_URL}protocol/openid-connect/userinfo" + ) + assert config.username_claim == ["sub"] + assert config.groups_claim == ["roles"] + assert config.claim_mapping == { + "last_name": ["family_name"], + "first_name": ["given_name"], + "email": ["email"], + } + assert config.sync_groups + assert config.sync_groups_glob_pattern == "*" + assert config.default_groups.all().count() == 0 + assert not config.make_users_staff + assert config.superuser_group_names == [] + assert config.oidc_use_nonce + assert config.oidc_nonce_size == 32 + assert config.oidc_state_size == 32 + assert ( + config.userinfo_claims_source == UserInformationClaimsSources.userinfo_endpoint + ) + + +@pytest.mark.vcr +@pytest.mark.django_db +def test_configure_use_discovery_endpoint(setup_config_discovery_model): + step = AdminOIDCConfigurationStep() + step.execute(setup_config_discovery_model) + + config = OpenIDConnectConfig.get_solo() + + assert config.enabled + assert config.oidc_op_discovery_endpoint == KEYCLOAK_BASE_URL + assert ( + config.oidc_op_jwks_endpoint + == f"{KEYCLOAK_BASE_URL}protocol/openid-connect/certs" + ) + assert ( + config.oidc_op_authorization_endpoint + == f"{KEYCLOAK_BASE_URL}protocol/openid-connect/auth" + ) + assert ( + config.oidc_op_token_endpoint + == f"{KEYCLOAK_BASE_URL}protocol/openid-connect/token" + ) + assert ( + config.oidc_op_user_endpoint + == f"{KEYCLOAK_BASE_URL}protocol/openid-connect/userinfo" + ) + + +@pytest.mark.django_db +def test_configure_discovery_failure(requests_mock, setup_config_discovery_model): + mock_kwargs = ( + {"exc": requests.ConnectTimeout}, + {"exc": requests.ConnectionError}, + {"status_code": 404}, + {"status_code": 403}, + {"status_code": 500}, + ) + for mock_config in mock_kwargs: + requests_mock.get( + f"{KEYCLOAK_BASE_URL}.well-known/openid-configuration", + **mock_config, + ) + + with pytest.raises(ConfigurationRunFailed): + AdminOIDCConfigurationStep().execute(setup_config_discovery_model) + + assert not OpenIDConnectConfig.get_solo().enabled From 71ddd88e26d127eb509c5a8fd5c348c7ae0b094a Mon Sep 17 00:00:00 2001 From: Conor Holden Date: Tue, 22 Oct 2024 17:23:20 +0200 Subject: [PATCH 05/16] :green_heart:[#114] update CI for setup configuration --- .github/workflows/ci.yml | 9 +++++++-- pyproject.toml | 3 +++ tox.ini | 10 +++++++++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 992386c..991ec6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,8 +18,10 @@ jobs: python: ['3.10', '3.11', '3.12'] django: ['4.2'] mozilla_django_oidc: ['4.0'] + setup_config_enabled: ['no', 'yes'] - name: Run the test suite (Python ${{ matrix.python }}, Django ${{ matrix.django }}, mozilla-django-oidc ${{ matrix.mozilla_django_oidc }}) + name: "Run the test suite (Python ${{ matrix.python }}, Django ${{ matrix.django }}, + mozilla-django-oidc ${{ matrix.mozilla_django_oidc }}, Setup Config: ${{ matrix.setup_config_enabled }}))" services: postgres: @@ -41,18 +43,21 @@ jobs: run: pip install tox tox-gh-actions - name: Run tests - run: tox + run: | + tox -- ${{ matrix.setup_config_enabled != 'yes' && '--ignore tests/setupconfig' || '' }} env: PYTHON_VERSION: ${{ matrix.python }} DJANGO: ${{ matrix.django }} MOZILLA_DJANGO_OIDC: ${{ matrix.mozilla_django_oidc }} PGUSER: postgres PGHOST: localhost + SETUP_CONFIG_ENABLED: ${{ matrix.setup_config_enabled }} - name: Publish coverage report uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} + flags: ${{ matrix.setup_config_enabled == 'yes' && 'setupconfig' || 'base' }} publish: name: Publish package to PyPI diff --git a/pyproject.toml b/pyproject.toml index ccc89a4..f1ee62c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,9 @@ Documentation = "https://mozilla-django-oidc-db.readthedocs.io/en/latest/" Changelog = "https://github.com/maykinmedia/mozilla-django-oidc-db/blob/master/CHANGELOG.rst" [project.optional-dependencies] +setupconfig = [ + "django-setup-configuration@git+https://github.com/maykinmedia/django-setup-configuration.git@ba0ed8a14acf0ffe05244f53b78fb343c6e5c3df", +] tests = [ "psycopg2", "pytest", diff --git a/tox.ini b/tox.ini index 2ea6c0b..2425fea 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{310,311,312}-django{42}-mozilla_django_oidc{40} + py{310,311,312}-django{42}-mozilla_django_oidc{40}-setup_config_{enabled,disabled} isort black docs @@ -17,10 +17,15 @@ DJANGO = 4.2: django42 MOZILLA_DJANGO_OIDC = 4.0: mozilla_django_oidc40 +SETUP_CONFIG_ENABLED = + yes: setup_config_enabled + no: setup_config_disabled + [testenv] extras = tests coverage + setup_config_enabled: setupconfig deps = django42: Django~=4.2.0 mozilla_django_oidc40: mozilla-django-oidc~=4.0.0 @@ -30,8 +35,10 @@ passenv = PGPASSWORD PGPORT PGHOST + SETUP_CONFIG_ENABLED setenv = PYTHONPATH = {toxinidir} + commands = pytest tests \ --cov --cov-report xml:reports/coverage-{envname}.xml \ @@ -52,6 +59,7 @@ basepython=python changedir=docs skipsdist=true extras = + setupconfig db docs tests From 25feb25b79f44157c35677e9aeb7ce9721ba094a Mon Sep 17 00:00:00 2001 From: Conor Holden Date: Tue, 22 Oct 2024 17:26:09 +0200 Subject: [PATCH 06/16] :memo:[#114] add setup configuration documentation --- CHANGELOG.rst | 9 ++++ docs/index.rst | 1 + docs/quickstart.rst | 6 +++ docs/setup_configuration.rst | 92 ++++++++++++++++++++++++++++++++++++ 4 files changed, 108 insertions(+) create mode 100644 docs/setup_configuration.rst diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 901237a..3e2d7c9 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,15 @@ Changelog ========= +0.20.0 (????) +============= + +New Features: + +* Add optional support for `django-setup-configuration`_ + +.. _django-setup-configuration: https://pypi.org/project/django-setup-configuration/ + 0.19.0 (2024-07-02) =================== diff --git a/docs/index.rst b/docs/index.rst index 132e0f9..4936494 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -33,6 +33,7 @@ Using ``email`` as the unique identifier is not recommended, as mentioned in the quickstart customizing + setup_configuration reference architecture changelog diff --git a/docs/quickstart.rst b/docs/quickstart.rst index f7595f9..e8ee57b 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -24,6 +24,12 @@ This will also install the following packages: - ``django-solo`` - ``django-jsonform`` +You can optionally install ``django-setup-configuration`` support with: + +.. code-block:: bash + + pip install mozilla-django-oidc-db[setupconfig] + Django settings --------------- diff --git a/docs/setup_configuration.rst b/docs/setup_configuration.rst new file mode 100644 index 0000000..e3205d1 --- /dev/null +++ b/docs/setup_configuration.rst @@ -0,0 +1,92 @@ +========================== +Django Setup Configuration +========================== + +There is optional support for`django-setup-configuration`_ that allows you to automatically configure the +OpenID Connect configuration the ``setup_configuration`` commmand. + +You must install the ``setupconfig`` dependency group: + +.. _django-setup-configuration: https://pypi.org/project/django-setup-configuration/ + + +.. code-block:: bash + + pip install mozilla-django-oidc-db[setupconfig] + + +You must then define the required and any optional django settings mentioned below and +put the ``AdminOIDCConfigurationStep`` in your django-setup-configuration steps: + +.. code-block:: python + + SETUP_CONFIGURATION_STEPS = [ + ... + "mozilla_django_oidc_db.setup_config.AdminOIDCConfigurationStep", + ... + ] + +Configuration Settings: +======================= + +* ``OIDC_DB_CONFIG_ENABLE``: enable setup configuration step + +* ``OIDC_DB_SETUP_CONFIG_ADMIN_AUTH``: Dictionary that maps OIDC fields to their values. + + +Example: + +.. code-block:: python + + OIDC_DB_SETUP_CONFIG_ADMIN_AUTH = { + "oidc_rp_client_id": "client-id", + "oidc_rp_client_secret": "secret", + "oidc_op_discovery_endpoint": "https://keycloak.local/protocol/openid-connect/", + } + + +Required Fields: +"""""""""""""""" + + +* ``oidc_rp_client_id``: OpenID Connect client ID from the OIDC Provider. +* ``oidc_rp_client_secret``: OpenID Connect secret from the OIDC Provider. + +The discovery endpoint can be configured to automatically fetch the other endpoints. Otherwise the endpoints must be set individually. + +* ``oidc_op_discovery_endpoint``: URL of your OpenID Connect provider discovery endpoint ending with a slash (`.well-known/...` will be added automatically). + + **OR** + +* ``oidc_op_authorization_endpoint``: URL of your OpenID Connect provider authorization endpoint +* ``oidc_op_token_endpoint``: URL of your OpenID Connect provider token endpoint +* ``oidc_op_user_endpoint``: URL of your OpenID Connect provider userinfo endpoint + + + +Optional Fields: +"""""""""""""""" + +* ``oidc_op_jwks_endpoint``: URL of your OpenID Connect provider JSON Web Key Set endpoint. + Required if ``RS256`` is used as signing algorithm. No default value. +* ``claim_mapping``: Mapping from user-model fields to OIDC claims. + Defaults to ``{"email": ["email"], "first_name": ["given_name"], "last_name": ["family_name"]}`` +* ``username_claim``: The name of the OIDC claim that is used as the username. Defaults to ``["sub"]`` +* ``groups_claim``: The name of the OIDC claim that holds the values to map to local user groups. Defaults to ``["roles"]`` +* ``default_groups``: The default groups to which every user logging in with OIDC will be assigned. No default values. +* ``superuser_group_names``: If any of these group names are present in the claims upon login, the user will be marked as a superuser. + If none of these groups are present the user will lose superuser permissions. Defaults to empty list. +* ``make_users_staff``: Users will be flagged as being a staff user automatically. + This allows users to login to the admin interface. Defaults to ``False``. +* ``oidc_use_nonce``: Controls whether the OpenID Connect client uses nonce verification. Defaults to ``True``. +* ``oidc_nonce_size``: Sets the length of the random string used for OpenID Connect nonce verification. Defaults to ``32``. +* ``oidc_state_size``: Sets the length of the random string used for OpenID Connect state verification. Defaults to ``32``. +* ``oidc_rp_idp_sign_key``: Key the Identity Provider uses to sign ID tokens in the case of an RSA sign algorithm. + Should be the signing key in PEM or DER format. No default. +* ``oidc_rp_scopes_list``: OpenID Connect scopes that are requested during login. Defaults to ``["openid", "email", "profile"]``. +* ``oidc_rp_sign_algo``: Algorithm the Identity Provider uses to sign ID tokens. Defaults to ``"HS256"``. +* ``sync_groups``: If checked, local user groups will be created for group names present in the groups claim, + if they do not exist yet locally. Defaults to ``True``. +* ``sync_groups_glob_pattern``: The glob pattern that groups must match to be synchronized to the local database. Defaults to ``"*"``. +* ``userinfo_claims_source``: Indicates the source from which the user information claims should be extracted + (``"userinfo_endpoint"`` or ``"id_token"``). Defaults to ``"userinfo_endpoint"``. From dfbfe29f16d23f331ac8ee868bde4d5ab92ea432 Mon Sep 17 00:00:00 2001 From: Conor Holden Date: Fri, 22 Nov 2024 17:55:51 +0100 Subject: [PATCH 07/16] :memo:[#114] update setup config docs --- docs/setup_configuration.rst | 48 ++++++++++++------- .../setup_configuration/steps.py | 4 +- testapp/settings.py | 1 - tests/setupconfig/files/defaults.yml | 4 +- tests/setupconfig/files/discovery.yml | 4 +- .../setupconfig/files/discovery_disabled.yml | 4 +- tests/setupconfig/files/empty.yml | 4 +- tests/setupconfig/files/full_setup.yml | 4 +- tests/setupconfig/files/partial_endpoints.yml | 4 +- tests/setupconfig/test_steps.py | 20 +++++--- 10 files changed, 58 insertions(+), 39 deletions(-) diff --git a/docs/setup_configuration.rst b/docs/setup_configuration.rst index e3205d1..6775ff2 100644 --- a/docs/setup_configuration.rst +++ b/docs/setup_configuration.rst @@ -22,50 +22,64 @@ put the ``AdminOIDCConfigurationStep`` in your django-setup-configuration steps: SETUP_CONFIGURATION_STEPS = [ ... - "mozilla_django_oidc_db.setup_config.AdminOIDCConfigurationStep", + "mozilla_django_oidc_db.setup_configuration.steps.AdminOIDCConfigurationStep", ... ] -Configuration Settings: -======================= +Configuration Settings YAML: +============================ -* ``OIDC_DB_CONFIG_ENABLE``: enable setup configuration step + +The setup configuration admin must contain the following base keys to use setup configuration: + +* ``OIDC_DB_CONFIG_ENABLE``: enable setup configuration step boolean * ``OIDC_DB_SETUP_CONFIG_ADMIN_AUTH``: Dictionary that maps OIDC fields to their values. Example: -.. code-block:: python +.. code-block:: YAML - OIDC_DB_SETUP_CONFIG_ADMIN_AUTH = { - "oidc_rp_client_id": "client-id", - "oidc_rp_client_secret": "secret", - "oidc_op_discovery_endpoint": "https://keycloak.local/protocol/openid-connect/", - } + OTHER_ENABLE: True + OTHER_CONFiG: + ... + OIDC_DB_CONFIG_ENABLE: True + OIDC_DB_SETUP_CONFIG_ADMIN_AUTH: + oidc_rp_client_id: client-id + oidc_rp_client_secret: secret + endpoint_config: + oidc_op_discovery_endpoint: https://keycloak.local/protocol/openid-connect/ + ... +Any field from the ``OpenIDConnectConfig`` can be added to ``OIDC_DB_SETUP_CONFIG_ADMIN_AUTH`` (except endpoints, see below) + Required Fields: """""""""""""""" * ``oidc_rp_client_id``: OpenID Connect client ID from the OIDC Provider. * ``oidc_rp_client_secret``: OpenID Connect secret from the OIDC Provider. +* ``endpoint_config``: Dictionary containing endpoint information -The discovery endpoint can be configured to automatically fetch the other endpoints. Otherwise the endpoints must be set individually. - -* ``oidc_op_discovery_endpoint``: URL of your OpenID Connect provider discovery endpoint ending with a slash (`.well-known/...` will be added automatically). + * ``oidc_op_discovery_endpoint``: URL of your OpenID Connect provider discovery endpoint ending with a slash (`.well-known/...` will be added automatically). - **OR** + **OR** -* ``oidc_op_authorization_endpoint``: URL of your OpenID Connect provider authorization endpoint -* ``oidc_op_token_endpoint``: URL of your OpenID Connect provider token endpoint -* ``oidc_op_user_endpoint``: URL of your OpenID Connect provider userinfo endpoint + * ``oidc_op_authorization_endpoint``: URL of your OpenID Connect provider authorization endpoint + * ``oidc_op_token_endpoint``: URL of your OpenID Connect provider token endpoint + * ``oidc_op_user_endpoint``: URL of your OpenID Connect provider userinfo endpoint +The endpoints must be provided in the ``endpoint_config`` dictionary. +You can add the discovery endpoint to automatically fetch the other endpoints. +Otherwise the endpoints must be specified individually. +Providing both will cause the validation to fail. Optional Fields: """""""""""""""" +All the following keys are placed in the ``OIDC_DB_SETUP_CONFIG_ADMIN_AUTH`` dictionary. * ``oidc_op_jwks_endpoint``: URL of your OpenID Connect provider JSON Web Key Set endpoint. Required if ``RS256`` is used as signing algorithm. No default value. diff --git a/mozilla_django_oidc_db/setup_configuration/steps.py b/mozilla_django_oidc_db/setup_configuration/steps.py index 7f55846..fb06044 100644 --- a/mozilla_django_oidc_db/setup_configuration/steps.py +++ b/mozilla_django_oidc_db/setup_configuration/steps.py @@ -16,8 +16,8 @@ class AdminOIDCConfigurationStep(BaseConfigurationStep[AdminOIDCConfigurationMod verbose_name = "Configuration for admin login via OpenID Connect" config_model = AdminOIDCConfigurationModel - namespace = "ADMIN_OIDC" - enable_setting = "ADMIN_OIDC_CONFIG_ENABLE" + namespace = "OIDC_DB_SETUP_CONFIG_ADMIN_AUTH" + enable_setting = "OIDC_DB_CONFIG_ENABLE" def execute(self, model: AdminOIDCConfigurationModel) -> None: diff --git a/testapp/settings.py b/testapp/settings.py index 6fbc264..ffe9591 100644 --- a/testapp/settings.py +++ b/testapp/settings.py @@ -87,7 +87,6 @@ INSTALLED_APPS += ["django_setup_configuration"] - OIDC_DB_CONFIG_ENABLE = True SETUP_CONFIGURATION_STEPS = [ "mozilla_django_oidc_db.setup_configuration.steps.AdminOIDCConfigurationStep", ] diff --git a/tests/setupconfig/files/defaults.yml b/tests/setupconfig/files/defaults.yml index f189ed0..3b32683 100644 --- a/tests/setupconfig/files/defaults.yml +++ b/tests/setupconfig/files/defaults.yml @@ -1,5 +1,5 @@ -ADMIN_OIDC_CONFIG_ENABLE: True -ADMIN_OIDC: +OIDC_DB_CONFIG_ENABLE: True +OIDC_DB_SETUP_CONFIG_ADMIN_AUTH: oidc_rp_client_id: client-id oidc_rp_client_secret: secret endpoint_config: diff --git a/tests/setupconfig/files/discovery.yml b/tests/setupconfig/files/discovery.yml index 3994066..a2b49a2 100644 --- a/tests/setupconfig/files/discovery.yml +++ b/tests/setupconfig/files/discovery.yml @@ -1,5 +1,5 @@ -ADMIN_OIDC_CONFIG_ENABLE: True -ADMIN_OIDC: +OIDC_DB_CONFIG_ENABLE: True +OIDC_DB_SETUP_CONFIG_ADMIN_AUTH: oidc_rp_client_id: testid oidc_rp_client_secret: 7DB3KUAAizYCcmZufpHRVOcD0TOkNO3I endpoint_config: diff --git a/tests/setupconfig/files/discovery_disabled.yml b/tests/setupconfig/files/discovery_disabled.yml index d410413..06c9a05 100644 --- a/tests/setupconfig/files/discovery_disabled.yml +++ b/tests/setupconfig/files/discovery_disabled.yml @@ -1,5 +1,5 @@ -ADMIN_OIDC_CONFIG_ENABLE: False -ADMIN_OIDC: +OIDC_DB_CONFIG_ENABLE: False +OIDC_DB_SETUP_CONFIG_ADMIN_AUTH: oidc_rp_client_id: testid oidc_rp_client_secret: 7DB3KUAAizYCcmZufpHRVOcD0TOkNO3I endpoint_config: diff --git a/tests/setupconfig/files/empty.yml b/tests/setupconfig/files/empty.yml index c950536..65f1871 100644 --- a/tests/setupconfig/files/empty.yml +++ b/tests/setupconfig/files/empty.yml @@ -1,2 +1,2 @@ -ADMIN_OIDC_CONFIG_ENABLE: True -ADMIN_OIDC: {} +OIDC_DB_CONFIG_ENABLE: True +OIDC_DB_SETUP_CONFIG_ADMIN_AUTH: {} diff --git a/tests/setupconfig/files/full_setup.yml b/tests/setupconfig/files/full_setup.yml index 3b9e08f..93ae072 100644 --- a/tests/setupconfig/files/full_setup.yml +++ b/tests/setupconfig/files/full_setup.yml @@ -1,5 +1,5 @@ -ADMIN_OIDC_CONFIG_ENABLE: True -ADMIN_OIDC: +OIDC_DB_CONFIG_ENABLE: True +OIDC_DB_SETUP_CONFIG_ADMIN_AUTH: oidc_rp_client_id: client-id oidc_rp_client_secret: secret oidc_rp_scopes_list: diff --git a/tests/setupconfig/files/partial_endpoints.yml b/tests/setupconfig/files/partial_endpoints.yml index 3518bde..907f0b3 100644 --- a/tests/setupconfig/files/partial_endpoints.yml +++ b/tests/setupconfig/files/partial_endpoints.yml @@ -1,5 +1,5 @@ -ADMIN_OIDC_CONFIG_ENABLE: True -ADMIN_OIDC: +OIDC_DB_CONFIG_ENABLE: True +OIDC_DB_SETUP_CONFIG_ADMIN_AUTH: oidc_rp_client_id: client-id oidc_rp_client_secret: secret endpoint_config: diff --git a/tests/setupconfig/test_steps.py b/tests/setupconfig/test_steps.py index f5d2eca..c682d8c 100644 --- a/tests/setupconfig/test_steps.py +++ b/tests/setupconfig/test_steps.py @@ -84,9 +84,13 @@ def test_required_settings(): command_error.value ) - assert "ADMIN_OIDC.oidc_rp_client_id" in str(command_error.value) - assert "ADMIN_OIDC.oidc_rp_client_secret" in str(command_error.value) - assert "ADMIN_OIDC.endpoint_config" in str(command_error.value) + assert "OIDC_DB_SETUP_CONFIG_ADMIN_AUTH.oidc_rp_client_id" in str( + command_error.value + ) + assert "OIDC_DB_SETUP_CONFIG_ADMIN_AUTH.oidc_rp_client_secret" in str( + command_error.value + ) + assert "OIDC_DB_SETUP_CONFIG_ADMIN_AUTH.endpoint_config" in str(command_error.value) config = OpenIDConnectConfig.get_solo() assert not config.enabled @@ -111,11 +115,13 @@ def test_partial_endpoints_provided(): command_error.value ) - assert "ADMIN_OIDC.endpoint_config.all.oidc_op_token_endpoint" in str( - command_error.value + assert ( + "OIDC_DB_SETUP_CONFIG_ADMIN_AUTH.endpoint_config.all.oidc_op_token_endpoint" + in str(command_error.value) ) - assert "ADMIN_OIDC.endpoint_config.all.oidc_op_user_endpoint" in str( - command_error.value + assert ( + "OIDC_DB_SETUP_CONFIG_ADMIN_AUTH.endpoint_config.all.oidc_op_user_endpoint" + in str(command_error.value) ) config = OpenIDConnectConfig.get_solo() From 49d035ce490fe615ed6c98942492a053fe6bb942 Mon Sep 17 00:00:00 2001 From: Conor Holden Date: Wed, 27 Nov 2024 10:27:16 +0100 Subject: [PATCH 08/16] :recycle:[#114] make config key names lowercase --- docs/setup_configuration.rst | 18 +++++++++--------- .../setup_configuration/steps.py | 4 ++-- pyproject.toml | 2 +- tests/setupconfig/files/defaults.yml | 4 ++-- tests/setupconfig/files/discovery.yml | 4 ++-- tests/setupconfig/files/discovery_disabled.yml | 4 ++-- tests/setupconfig/files/empty.yml | 4 ++-- tests/setupconfig/files/full_setup.yml | 4 ++-- tests/setupconfig/files/partial_endpoints.yml | 4 ++-- tests/setupconfig/test_steps.py | 17 ++++++----------- 10 files changed, 30 insertions(+), 35 deletions(-) diff --git a/docs/setup_configuration.rst b/docs/setup_configuration.rst index 6775ff2..8608d2e 100644 --- a/docs/setup_configuration.rst +++ b/docs/setup_configuration.rst @@ -30,22 +30,22 @@ Configuration Settings YAML: ============================ -The setup configuration admin must contain the following base keys to use setup configuration: +The setup configuration file must contain the following base keys to use this setup configuration step: -* ``OIDC_DB_CONFIG_ENABLE``: enable setup configuration step boolean +* ``oidc_db_config_enable``: enable setup configuration step boolean -* ``OIDC_DB_SETUP_CONFIG_ADMIN_AUTH``: Dictionary that maps OIDC fields to their values. +* ``oidc_db_config_admin_auth``: Dictionary that maps OIDC fields to their values. Example: .. code-block:: YAML - OTHER_ENABLE: True - OTHER_CONFiG: + other_enable: True + other_config: ... - OIDC_DB_CONFIG_ENABLE: True - OIDC_DB_SETUP_CONFIG_ADMIN_AUTH: + oidc_db_config_enable: True + oidc_db_config_admin_auth: oidc_rp_client_id: client-id oidc_rp_client_secret: secret endpoint_config: @@ -53,7 +53,7 @@ Example: ... -Any field from the ``OpenIDConnectConfig`` can be added to ``OIDC_DB_SETUP_CONFIG_ADMIN_AUTH`` (except endpoints, see below) +Any field from the ``OpenIDConnectConfig`` can be added to ``oidc_db_config_admin_auth`` (except endpoints, see below) Required Fields: """""""""""""""" @@ -79,7 +79,7 @@ Providing both will cause the validation to fail. Optional Fields: """""""""""""""" -All the following keys are placed in the ``OIDC_DB_SETUP_CONFIG_ADMIN_AUTH`` dictionary. +All the following keys are placed in the ``oidc_db_config_admin_auth`` dictionary. * ``oidc_op_jwks_endpoint``: URL of your OpenID Connect provider JSON Web Key Set endpoint. Required if ``RS256`` is used as signing algorithm. No default value. diff --git a/mozilla_django_oidc_db/setup_configuration/steps.py b/mozilla_django_oidc_db/setup_configuration/steps.py index fb06044..2a29942 100644 --- a/mozilla_django_oidc_db/setup_configuration/steps.py +++ b/mozilla_django_oidc_db/setup_configuration/steps.py @@ -16,8 +16,8 @@ class AdminOIDCConfigurationStep(BaseConfigurationStep[AdminOIDCConfigurationMod verbose_name = "Configuration for admin login via OpenID Connect" config_model = AdminOIDCConfigurationModel - namespace = "OIDC_DB_SETUP_CONFIG_ADMIN_AUTH" - enable_setting = "OIDC_DB_CONFIG_ENABLE" + namespace = "oidc_db_config_admin_auth" + enable_setting = "oidc_db_config_enable" def execute(self, model: AdminOIDCConfigurationModel) -> None: diff --git a/pyproject.toml b/pyproject.toml index f1ee62c..0e31c15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ Changelog = "https://github.com/maykinmedia/mozilla-django-oidc-db/blob/master/C [project.optional-dependencies] setupconfig = [ - "django-setup-configuration@git+https://github.com/maykinmedia/django-setup-configuration.git@ba0ed8a14acf0ffe05244f53b78fb343c6e5c3df", + "django-setup-configuration@git+https://github.com/maykinmedia/django-setup-configuration.git@c3cb480223d23d1220bd4aca0c57eb07aacaf637", ] tests = [ "psycopg2", diff --git a/tests/setupconfig/files/defaults.yml b/tests/setupconfig/files/defaults.yml index 3b32683..889c392 100644 --- a/tests/setupconfig/files/defaults.yml +++ b/tests/setupconfig/files/defaults.yml @@ -1,5 +1,5 @@ -OIDC_DB_CONFIG_ENABLE: True -OIDC_DB_SETUP_CONFIG_ADMIN_AUTH: +oidc_db_config_enable: True +oidc_db_config_admin_auth: oidc_rp_client_id: client-id oidc_rp_client_secret: secret endpoint_config: diff --git a/tests/setupconfig/files/discovery.yml b/tests/setupconfig/files/discovery.yml index a2b49a2..be8b924 100644 --- a/tests/setupconfig/files/discovery.yml +++ b/tests/setupconfig/files/discovery.yml @@ -1,5 +1,5 @@ -OIDC_DB_CONFIG_ENABLE: True -OIDC_DB_SETUP_CONFIG_ADMIN_AUTH: +oidc_db_config_enable: True +oidc_db_config_admin_auth: oidc_rp_client_id: testid oidc_rp_client_secret: 7DB3KUAAizYCcmZufpHRVOcD0TOkNO3I endpoint_config: diff --git a/tests/setupconfig/files/discovery_disabled.yml b/tests/setupconfig/files/discovery_disabled.yml index 06c9a05..45f19a9 100644 --- a/tests/setupconfig/files/discovery_disabled.yml +++ b/tests/setupconfig/files/discovery_disabled.yml @@ -1,5 +1,5 @@ -OIDC_DB_CONFIG_ENABLE: False -OIDC_DB_SETUP_CONFIG_ADMIN_AUTH: +oidc_db_config_enable: False +oidc_db_config_admin_auth: oidc_rp_client_id: testid oidc_rp_client_secret: 7DB3KUAAizYCcmZufpHRVOcD0TOkNO3I endpoint_config: diff --git a/tests/setupconfig/files/empty.yml b/tests/setupconfig/files/empty.yml index 65f1871..4d8d68b 100644 --- a/tests/setupconfig/files/empty.yml +++ b/tests/setupconfig/files/empty.yml @@ -1,2 +1,2 @@ -OIDC_DB_CONFIG_ENABLE: True -OIDC_DB_SETUP_CONFIG_ADMIN_AUTH: {} +oidc_db_config_enable: True +oidc_db_config_admin_auth: {} diff --git a/tests/setupconfig/files/full_setup.yml b/tests/setupconfig/files/full_setup.yml index 93ae072..cf06ee3 100644 --- a/tests/setupconfig/files/full_setup.yml +++ b/tests/setupconfig/files/full_setup.yml @@ -1,5 +1,5 @@ -OIDC_DB_CONFIG_ENABLE: True -OIDC_DB_SETUP_CONFIG_ADMIN_AUTH: +oidc_db_config_enable: True +oidc_db_config_admin_auth: oidc_rp_client_id: client-id oidc_rp_client_secret: secret oidc_rp_scopes_list: diff --git a/tests/setupconfig/files/partial_endpoints.yml b/tests/setupconfig/files/partial_endpoints.yml index 907f0b3..0f48892 100644 --- a/tests/setupconfig/files/partial_endpoints.yml +++ b/tests/setupconfig/files/partial_endpoints.yml @@ -1,5 +1,5 @@ -OIDC_DB_CONFIG_ENABLE: True -OIDC_DB_SETUP_CONFIG_ADMIN_AUTH: +oidc_db_config_enable: True +oidc_db_config_admin_auth: oidc_rp_client_id: client-id oidc_rp_client_secret: secret endpoint_config: diff --git a/tests/setupconfig/test_steps.py b/tests/setupconfig/test_steps.py index c682d8c..ced6803 100644 --- a/tests/setupconfig/test_steps.py +++ b/tests/setupconfig/test_steps.py @@ -84,13 +84,9 @@ def test_required_settings(): command_error.value ) - assert "OIDC_DB_SETUP_CONFIG_ADMIN_AUTH.oidc_rp_client_id" in str( - command_error.value - ) - assert "OIDC_DB_SETUP_CONFIG_ADMIN_AUTH.oidc_rp_client_secret" in str( - command_error.value - ) - assert "OIDC_DB_SETUP_CONFIG_ADMIN_AUTH.endpoint_config" in str(command_error.value) + assert "oidc_db_config_admin_auth.oidc_rp_client_id" in str(command_error.value) + assert "oidc_db_config_admin_auth.oidc_rp_client_secret" in str(command_error.value) + assert "oidc_db_config_admin_auth.endpoint_config" in str(command_error.value) config = OpenIDConnectConfig.get_solo() assert not config.enabled @@ -116,12 +112,11 @@ def test_partial_endpoints_provided(): ) assert ( - "OIDC_DB_SETUP_CONFIG_ADMIN_AUTH.endpoint_config.all.oidc_op_token_endpoint" + "oidc_db_config_admin_auth.endpoint_config.all.oidc_op_token_endpoint" in str(command_error.value) ) - assert ( - "OIDC_DB_SETUP_CONFIG_ADMIN_AUTH.endpoint_config.all.oidc_op_user_endpoint" - in str(command_error.value) + assert "oidc_db_config_admin_auth.endpoint_config.all.oidc_op_user_endpoint" in str( + command_error.value ) config = OpenIDConnectConfig.get_solo() From 9679244f3a854b85353b2ec9d8527fa4972844b6 Mon Sep 17 00:00:00 2001 From: Conor Holden Date: Fri, 29 Nov 2024 11:03:43 +0100 Subject: [PATCH 09/16] :memo:[#114] Apply documentation suggestions from code review Co-authored-by: Sidney Richards --- docs/setup_configuration.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/setup_configuration.rst b/docs/setup_configuration.rst index 8608d2e..4506714 100644 --- a/docs/setup_configuration.rst +++ b/docs/setup_configuration.rst @@ -2,8 +2,8 @@ Django Setup Configuration ========================== -There is optional support for`django-setup-configuration`_ that allows you to automatically configure the -OpenID Connect configuration the ``setup_configuration`` commmand. +There is optional support for ``django-setup-configuration`` that allows you to automatically configure the +OpenID Connect configuration using that package's ``setup_configuration`` command. You must install the ``setupconfig`` dependency group: @@ -30,7 +30,7 @@ Configuration Settings YAML: ============================ -The setup configuration file must contain the following base keys to use this setup configuration step: +The setup configuration source must contain the following base keys to use this setup configuration step (using ``yaml`` as an example): * ``oidc_db_config_enable``: enable setup configuration step boolean From 1d0bdbf223200c46f918ed7da8d6f247df188a4d Mon Sep 17 00:00:00 2001 From: Conor Holden Date: Fri, 29 Nov 2024 11:27:17 +0100 Subject: [PATCH 10/16] :recycle:[#114] streamline configuration step and model * Pin django-setup-configuration >= 0.4.0 * Use Pydantic model defaults, remove defaults from step * Remove now unneeded OIDCSetupConfigForm * Set non-default values for test_configure_use_defaults * Update Documentation --- docs/setup_configuration.rst | 11 +++++++++-- mozilla_django_oidc_db/forms.py | 14 -------------- .../setup_configuration/models.py | 19 ++++++++++--------- .../setup_configuration/steps.py | 19 +++++-------------- pyproject.toml | 2 +- tests/setupconfig/files/full_setup.yml | 1 + tests/setupconfig/test_steps.py | 2 +- 7 files changed, 27 insertions(+), 41 deletions(-) diff --git a/docs/setup_configuration.rst b/docs/setup_configuration.rst index 4506714..c472ff0 100644 --- a/docs/setup_configuration.rst +++ b/docs/setup_configuration.rst @@ -26,8 +26,8 @@ put the ``AdminOIDCConfigurationStep`` in your django-setup-configuration steps: ... ] -Configuration Settings YAML: -============================ +Setup Configuration Settings: +============================= The setup configuration source must contain the following base keys to use this setup configuration step (using ``yaml`` as an example): @@ -79,8 +79,15 @@ Providing both will cause the validation to fail. Optional Fields: """""""""""""""" + +.. warning:: + + The default values are always provided and will overwrite any settings changed in the admin. + Make sure updated settings are added to the configuration yaml. + All the following keys are placed in the ``oidc_db_config_admin_auth`` dictionary. +* ``enabled``: whether OIDC is enabled for admin login. Defaults to ``True``. * ``oidc_op_jwks_endpoint``: URL of your OpenID Connect provider JSON Web Key Set endpoint. Required if ``RS256`` is used as signing algorithm. No default value. * ``claim_mapping``: Mapping from user-model fields to OIDC claims. diff --git a/mozilla_django_oidc_db/forms.py b/mozilla_django_oidc_db/forms.py index 5ae7c1a..f1e8ef0 100644 --- a/mozilla_django_oidc_db/forms.py +++ b/mozilla_django_oidc_db/forms.py @@ -72,17 +72,3 @@ def clean(self): self.add_error(field, _("This field is required.")) return cleaned_data - - -class OIDCSetupConfigForm(OpenIDConnectConfigForm): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - if self.fields: - self.fields["oidc_rp_sign_algo"].required = False - self.fields["oidc_nonce_size"].required = False - self.fields["oidc_state_size"].required = False - self.fields["userinfo_claims_source"].required = False - self.fields["username_claim"].required = False - self.fields["claim_mapping"].required = False - self.fields["sync_groups_glob_pattern"].required = False diff --git a/mozilla_django_oidc_db/setup_configuration/models.py b/mozilla_django_oidc_db/setup_configuration/models.py index 6b55167..048f417 100644 --- a/mozilla_django_oidc_db/setup_configuration/models.py +++ b/mozilla_django_oidc_db/setup_configuration/models.py @@ -1,4 +1,4 @@ -from typing import Optional, Union +from typing import Union from django_setup_configuration.fields import DjangoModelRef from django_setup_configuration.models import ConfigurationModel @@ -48,23 +48,24 @@ def get_endpoint_endpoint_model(endpoint_data): class AdminOIDCConfigurationModel(ConfigurationModel): + # Change default to True + enabled: bool | None = DjangoModelRef(OpenIDConnectConfig, "enabled", default=True) + # Json - claim_mapping: Optional[dict] = DjangoModelRef(OpenIDConnectConfig, "claim_mapping") + claim_mapping: dict | None = DjangoModelRef(OpenIDConnectConfig, "claim_mapping") # Arrays are overridden to make the typing simpler (the underlying Django field is an ArrayField, which is non-standard) - username_claim: Optional[list[str]] = DjangoModelRef( + username_claim: list[str] | None = DjangoModelRef( OpenIDConnectConfig, "username_claim" ) - groups_claim: Optional[list[str]] = DjangoModelRef( - OpenIDConnectConfig, "groups_claim" - ) - superuser_group_names: Optional[list[str]] = DjangoModelRef( + groups_claim: list[str] | None = DjangoModelRef(OpenIDConnectConfig, "groups_claim") + superuser_group_names: list[str] | None = DjangoModelRef( OpenIDConnectConfig, "superuser_group_names" ) - default_groups: Optional[list[str]] = DjangoModelRef( + default_groups: list[str] | None = DjangoModelRef( OpenIDConnectConfig, "superuser_group_names" ) - oidc_rp_scopes_list: Optional[list[str]] = DjangoModelRef( + oidc_rp_scopes_list: list[str] | None = DjangoModelRef( OpenIDConnectConfig, "oidc_rp_scopes_list" ) diff --git a/mozilla_django_oidc_db/setup_configuration/steps.py b/mozilla_django_oidc_db/setup_configuration/steps.py index 2a29942..0a0f5fe 100644 --- a/mozilla_django_oidc_db/setup_configuration/steps.py +++ b/mozilla_django_oidc_db/setup_configuration/steps.py @@ -1,7 +1,7 @@ from django_setup_configuration.configuration import BaseConfigurationStep from django_setup_configuration.exceptions import ConfigurationRunFailed -from mozilla_django_oidc_db.forms import OIDCSetupConfigForm +from mozilla_django_oidc_db.forms import OpenIDConnectConfigForm from mozilla_django_oidc_db.models import OpenIDConnectConfig from mozilla_django_oidc_db.setup_configuration.models import ( AdminOIDCConfigurationModel, @@ -23,25 +23,16 @@ def execute(self, model: AdminOIDCConfigurationModel) -> None: config = OpenIDConnectConfig.get_solo() - base_model_data = model.model_dump() - endpoint_config_data = base_model_data.pop("endpoint_config") - - all_settings = { - "sync_groups": config.sync_groups, - "oidc_use_nonce": config.oidc_use_nonce, - "enabled": True, - "claim_mapping": config.claim_mapping, # JSONFormField widget cannot handle blank values with object schema - "sync_groups_glob_pattern": config.sync_groups_glob_pattern, - **base_model_data, - **endpoint_config_data, - } + all_settings = model.model_dump() + endpoint_config_data = all_settings.pop("endpoint_config") + all_settings.update(endpoint_config_data) if groups := all_settings.get("default_groups"): all_settings["default_groups"] = create_missing_groups( groups, all_settings["sync_groups_glob_pattern"] ) - form = OIDCSetupConfigForm( + form = OpenIDConnectConfigForm( instance=config, data=all_settings, ) diff --git a/pyproject.toml b/pyproject.toml index 0e31c15..9b5ff94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ Changelog = "https://github.com/maykinmedia/mozilla-django-oidc-db/blob/master/C [project.optional-dependencies] setupconfig = [ - "django-setup-configuration@git+https://github.com/maykinmedia/django-setup-configuration.git@c3cb480223d23d1220bd4aca0c57eb07aacaf637", + "django-setup-configuration>=0.4.0", ] tests = [ "psycopg2", diff --git a/tests/setupconfig/files/full_setup.yml b/tests/setupconfig/files/full_setup.yml index cf06ee3..0a4a63f 100644 --- a/tests/setupconfig/files/full_setup.yml +++ b/tests/setupconfig/files/full_setup.yml @@ -1,5 +1,6 @@ oidc_db_config_enable: True oidc_db_config_admin_auth: + enabled: False oidc_rp_client_id: client-id oidc_rp_client_secret: secret oidc_rp_scopes_list: diff --git a/tests/setupconfig/test_steps.py b/tests/setupconfig/test_steps.py index ced6803..8339501 100644 --- a/tests/setupconfig/test_steps.py +++ b/tests/setupconfig/test_steps.py @@ -28,7 +28,7 @@ def test_configure(setup_config_full_model): config = OpenIDConnectConfig.get_solo() - assert config.enabled + assert not config.enabled assert config.oidc_rp_client_id == "client-id" assert config.oidc_rp_client_secret == "secret" assert config.oidc_rp_scopes_list == ["open_id", "email", "profile", "extra_scope"] From da8600b4d09892f03017017a42c58b12602e597b Mon Sep 17 00:00:00 2001 From: Conor Holden Date: Fri, 29 Nov 2024 14:25:23 +0100 Subject: [PATCH 11/16] :recycle:[#114] streamline setup config tests * Set non default values in model in test_configure_use_defaults * Remove model fixtures in favour of file yml fixtures * Move from build_step_config_from_sources in fixture to execute_single_step in test * Remove model validaiton tests --- tests/setupconfig/conftest.py | 73 +++++++++--- .../setupconfig/files/discovery_disabled.yml | 6 - tests/setupconfig/files/empty.yml | 2 - tests/setupconfig/files/partial_endpoints.yml | 8 -- tests/setupconfig/test_steps.py | 106 ++++-------------- 5 files changed, 77 insertions(+), 118 deletions(-) delete mode 100644 tests/setupconfig/files/discovery_disabled.yml delete mode 100644 tests/setupconfig/files/empty.yml delete mode 100644 tests/setupconfig/files/partial_endpoints.yml diff --git a/tests/setupconfig/conftest.py b/tests/setupconfig/conftest.py index 0783f83..16dd9f4 100644 --- a/tests/setupconfig/conftest.py +++ b/tests/setupconfig/conftest.py @@ -1,7 +1,9 @@ import pytest -from django_setup_configuration.test_utils import build_step_config_from_sources -from mozilla_django_oidc_db.setup_configuration.steps import AdminOIDCConfigurationStep +from mozilla_django_oidc_db.models import ( + OpenIDConnectConfig, + UserInformationClaimsSources, +) """ Key cloak credentials are setup for the keycloak docker-compose.yml. @@ -14,22 +16,61 @@ """ -@pytest.fixture -def setup_config_discovery_model(settings): - return build_step_config_from_sources( - AdminOIDCConfigurationStep, "tests/setupconfig/files/discovery.yml" - ) +# Test files +@pytest.fixture() +def full_config_yml(): + return "tests/setupconfig/files/full_setup.yml" -@pytest.fixture -def setup_config_defaults_model(settings): - return build_step_config_from_sources( - AdminOIDCConfigurationStep, "tests/setupconfig/files/defaults.yml" - ) +@pytest.fixture() +def default_config_yml(): + return "tests/setupconfig/files/defaults.yml" + + +@pytest.fixture() +def discovery_endpoint_config_yml(): + return "tests/setupconfig/files/discovery.yml" @pytest.fixture -def setup_config_full_model(): - return build_step_config_from_sources( - AdminOIDCConfigurationStep, "tests/setupconfig/files/full_setup.yml" - ) +def set_config_to_non_default_values(): + """ + Set the current config to non-default values. + """ + + config = OpenIDConnectConfig.get_solo() + + # Will be always overwritten + config.oidc_rp_client_id = "client-id" + config.oidc_rp_client_secret = "secret" + config.oidc_op_authorization_endpoint = "http://localhost:8080/whatever" + config.oidc_op_token_endpoint = "http://localhost:8080/whatever" + config.oidc_op_user_endpoint = "http://localhost:8080/whatever" + + # Set some non-default values + config.oidc_op_discovery_endpoint = "http://localhost:8080/whatever" + config.enabled = False + + config.oidc_rp_scopes_list = [ + "not_open_id", + "not_email", + "not_profile", + "not_extra_scope", + ] + config.oidc_rp_sign_algo = "M1911" + config.oidc_rp_idp_sign_key = "name" + config.oidc_op_jwks_endpoint = "http://localhost:8080/whatever" + config.username_claim = ["claim_title"] + config.groups_claim = ["groups_claim_title"] + config.claim_mapping = {"first_title": ["given_title"]} + config.sync_groups = True + config.sync_groups_glob_pattern = "not_local.groups.*" + + config.make_users_staff = False + config.superuser_group_names = ["poweruser"] + config.oidc_use_nonce = True + config.oidc_nonce_size = 64 + config.oidc_state_size = 64 + config.userinfo_claims_source = UserInformationClaimsSources.userinfo_endpoint + + config.save() diff --git a/tests/setupconfig/files/discovery_disabled.yml b/tests/setupconfig/files/discovery_disabled.yml deleted file mode 100644 index 45f19a9..0000000 --- a/tests/setupconfig/files/discovery_disabled.yml +++ /dev/null @@ -1,6 +0,0 @@ -oidc_db_config_enable: False -oidc_db_config_admin_auth: - oidc_rp_client_id: testid - oidc_rp_client_secret: 7DB3KUAAizYCcmZufpHRVOcD0TOkNO3I - endpoint_config: - oidc_op_discovery_endpoint: http://localhost:8080/realms/test/ diff --git a/tests/setupconfig/files/empty.yml b/tests/setupconfig/files/empty.yml deleted file mode 100644 index 4d8d68b..0000000 --- a/tests/setupconfig/files/empty.yml +++ /dev/null @@ -1,2 +0,0 @@ -oidc_db_config_enable: True -oidc_db_config_admin_auth: {} diff --git a/tests/setupconfig/files/partial_endpoints.yml b/tests/setupconfig/files/partial_endpoints.yml deleted file mode 100644 index 0f48892..0000000 --- a/tests/setupconfig/files/partial_endpoints.yml +++ /dev/null @@ -1,8 +0,0 @@ -oidc_db_config_enable: True -oidc_db_config_admin_auth: - oidc_rp_client_id: client-id - oidc_rp_client_secret: secret - endpoint_config: - oidc_op_authorization_endpoint: http://localhost:8080/realms/test/protocol/openid-connect/auth -# oidc_op_token_endpoint: http://localhost:8080/realms/test/protocol/openid-connect/token -# oidc_op_user_endpoint: http://localhost:8080/realms/test/protocol/openid-connect/userinfo diff --git a/tests/setupconfig/test_steps.py b/tests/setupconfig/test_steps.py index 8339501..6314fdb 100644 --- a/tests/setupconfig/test_steps.py +++ b/tests/setupconfig/test_steps.py @@ -4,7 +4,11 @@ import pytest import requests -from django_setup_configuration.exceptions import ConfigurationRunFailed +from django_setup_configuration.exceptions import ( + ConfigurationRunFailed, + PrerequisiteFailed, +) +from django_setup_configuration.test_utils import execute_single_step from mozilla_django_oidc_db.models import ( OpenIDConnectConfig, @@ -22,9 +26,8 @@ def clear_solo_cache(): @pytest.mark.django_db -def test_configure(setup_config_full_model): - step = AdminOIDCConfigurationStep() - step.execute(setup_config_full_model) +def test_configure(full_config_yml): + execute_single_step(AdminOIDCConfigurationStep, yaml_source=full_config_yml) config = OpenIDConnectConfig.get_solo() @@ -69,82 +72,8 @@ def test_configure(setup_config_full_model): @pytest.mark.django_db -def test_required_settings(): - output = StringIO() - err = StringIO() - with pytest.raises(CommandError) as command_error: - call_command( - "setup_configuration", - yaml_file="tests/setupconfig/files/empty.yml", - stdout=output, - stderr=err, - ) - - assert "Prerequisites for configuration are not fulfilled:" in str( - command_error.value - ) - - assert "oidc_db_config_admin_auth.oidc_rp_client_id" in str(command_error.value) - assert "oidc_db_config_admin_auth.oidc_rp_client_secret" in str(command_error.value) - assert "oidc_db_config_admin_auth.endpoint_config" in str(command_error.value) - - config = OpenIDConnectConfig.get_solo() - assert not config.enabled - - -@pytest.mark.django_db -def test_partial_endpoints_provided(): - """ - Test what if only one endpoint (not discovery) is provided - """ - output = StringIO() - err = StringIO() - with pytest.raises(CommandError) as command_error: - call_command( - "setup_configuration", - yaml_file="tests/setupconfig/files/partial_endpoints.yml", - stdout=output, - stderr=err, - ) - - assert "Prerequisites for configuration are not fulfilled:" in str( - command_error.value - ) - - assert ( - "oidc_db_config_admin_auth.endpoint_config.all.oidc_op_token_endpoint" - in str(command_error.value) - ) - assert "oidc_db_config_admin_auth.endpoint_config.all.oidc_op_user_endpoint" in str( - command_error.value - ) - - config = OpenIDConnectConfig.get_solo() - assert not config.enabled - - -@pytest.mark.django_db -def test_enable_setting(): - output = StringIO() - err = StringIO() - with pytest.raises(CommandError) as command_error: - call_command( - "setup_configuration", - yaml_file="tests/setupconfig/files/discovery_disabled.yml", - stdout=output, - stderr=err, - ) - - assert "No steps enabled, aborting." in str(command_error.value) - - config = OpenIDConnectConfig.get_solo() - assert not config.enabled - - -@pytest.mark.django_db -def test_configure_use_defaults(setup_config_defaults_model): - step = AdminOIDCConfigurationStep() - step.execute(setup_config_defaults_model) +def test_configure_use_defaults(set_config_to_non_default_values, default_config_yml): + execute_single_step(AdminOIDCConfigurationStep, yaml_source=default_config_yml) config = OpenIDConnectConfig.get_solo() @@ -191,9 +120,10 @@ def test_configure_use_defaults(setup_config_defaults_model): @pytest.mark.vcr @pytest.mark.django_db -def test_configure_use_discovery_endpoint(setup_config_discovery_model): - step = AdminOIDCConfigurationStep() - step.execute(setup_config_discovery_model) +def test_configure_use_discovery_endpoint(discovery_endpoint_config_yml): + execute_single_step( + AdminOIDCConfigurationStep, yaml_source=discovery_endpoint_config_yml + ) config = OpenIDConnectConfig.get_solo() @@ -218,7 +148,7 @@ def test_configure_use_discovery_endpoint(setup_config_discovery_model): @pytest.mark.django_db -def test_configure_discovery_failure(requests_mock, setup_config_discovery_model): +def test_configure_discovery_failure(requests_mock, discovery_endpoint_config_yml): mock_kwargs = ( {"exc": requests.ConnectTimeout}, {"exc": requests.ConnectionError}, @@ -233,6 +163,10 @@ def test_configure_discovery_failure(requests_mock, setup_config_discovery_model ) with pytest.raises(ConfigurationRunFailed): - AdminOIDCConfigurationStep().execute(setup_config_discovery_model) + execute_single_step( + AdminOIDCConfigurationStep, yaml_source=discovery_endpoint_config_yml + ) - assert not OpenIDConnectConfig.get_solo().enabled + config = OpenIDConnectConfig.get_solo() + assert not config.enabled + assert config.oidc_op_discovery_endpoint == "" From d8ac68e7be8625ce1b0a7d88ce9be7404d1986cb Mon Sep 17 00:00:00 2001 From: Conor Holden Date: Fri, 29 Nov 2024 16:21:54 +0100 Subject: [PATCH 12/16] :memo:[#114] add setup configuration command example to documentation --- docs/setup_configuration.rst | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/setup_configuration.rst b/docs/setup_configuration.rst index c472ff0..4382ba2 100644 --- a/docs/setup_configuration.rst +++ b/docs/setup_configuration.rst @@ -37,7 +37,7 @@ The setup configuration source must contain the following base keys to use this * ``oidc_db_config_admin_auth``: Dictionary that maps OIDC fields to their values. -Example: +Example: *setup_config.yml* .. code-block:: YAML @@ -52,6 +52,12 @@ Example: oidc_op_discovery_endpoint: https://keycloak.local/protocol/openid-connect/ ... +This is file is then used with the setup configuration command setup the OIDC admin: + +.. code-block:: Bash + + python manage.py setup_configuration --yaml-file path/to/setup_config.yml + Any field from the ``OpenIDConnectConfig`` can be added to ``oidc_db_config_admin_auth`` (except endpoints, see below) From f0f462338dbd23b965699a7c48a87c64ba6f39a1 Mon Sep 17 00:00:00 2001 From: Conor Holden Date: Mon, 2 Dec 2024 13:29:53 +0100 Subject: [PATCH 13/16] :recycle:[#114] implement suggested changes --- .../setup_configuration/models.py | 16 ++++---- tests/setupconfig/conftest.py | 5 +++ tests/setupconfig/test_steps.py | 39 ++++++++++++------- 3 files changed, 37 insertions(+), 23 deletions(-) diff --git a/mozilla_django_oidc_db/setup_configuration/models.py b/mozilla_django_oidc_db/setup_configuration/models.py index 048f417..84f71f9 100644 --- a/mozilla_django_oidc_db/setup_configuration/models.py +++ b/mozilla_django_oidc_db/setup_configuration/models.py @@ -49,23 +49,21 @@ def get_endpoint_endpoint_model(endpoint_data): class AdminOIDCConfigurationModel(ConfigurationModel): # Change default to True - enabled: bool | None = DjangoModelRef(OpenIDConnectConfig, "enabled", default=True) + enabled: bool = DjangoModelRef(OpenIDConnectConfig, "enabled", default=True) # Json - claim_mapping: dict | None = DjangoModelRef(OpenIDConnectConfig, "claim_mapping") + claim_mapping: dict = DjangoModelRef(OpenIDConnectConfig, "claim_mapping") # Arrays are overridden to make the typing simpler (the underlying Django field is an ArrayField, which is non-standard) - username_claim: list[str] | None = DjangoModelRef( - OpenIDConnectConfig, "username_claim" - ) - groups_claim: list[str] | None = DjangoModelRef(OpenIDConnectConfig, "groups_claim") - superuser_group_names: list[str] | None = DjangoModelRef( + username_claim: list[str] = DjangoModelRef(OpenIDConnectConfig, "username_claim") + groups_claim: list[str] = DjangoModelRef(OpenIDConnectConfig, "groups_claim") + superuser_group_names: list[str] = DjangoModelRef( OpenIDConnectConfig, "superuser_group_names" ) - default_groups: list[str] | None = DjangoModelRef( + default_groups: list[str] = DjangoModelRef( OpenIDConnectConfig, "superuser_group_names" ) - oidc_rp_scopes_list: list[str] | None = DjangoModelRef( + oidc_rp_scopes_list: list[str] = DjangoModelRef( OpenIDConnectConfig, "oidc_rp_scopes_list" ) diff --git a/tests/setupconfig/conftest.py b/tests/setupconfig/conftest.py index 16dd9f4..9b5affc 100644 --- a/tests/setupconfig/conftest.py +++ b/tests/setupconfig/conftest.py @@ -4,6 +4,7 @@ OpenIDConnectConfig, UserInformationClaimsSources, ) +from mozilla_django_oidc_db.utils import create_missing_groups """ Key cloak credentials are setup for the keycloak docker-compose.yml. @@ -73,4 +74,8 @@ def set_config_to_non_default_values(): config.oidc_state_size = 64 config.userinfo_claims_source = UserInformationClaimsSources.userinfo_endpoint + config.default_groups.set(create_missing_groups(["OldAdmin", "OldUser"])) + config.save() + + assert config.default_groups.all().count() == 2 diff --git a/tests/setupconfig/test_steps.py b/tests/setupconfig/test_steps.py index 6314fdb..bd97540 100644 --- a/tests/setupconfig/test_steps.py +++ b/tests/setupconfig/test_steps.py @@ -148,25 +148,36 @@ def test_configure_use_discovery_endpoint(discovery_endpoint_config_yml): @pytest.mark.django_db -def test_configure_discovery_failure(requests_mock, discovery_endpoint_config_yml): - mock_kwargs = ( +@pytest.mark.parametrize( + "mock_kwargs", + ( {"exc": requests.ConnectTimeout}, {"exc": requests.ConnectionError}, {"status_code": 404}, {"status_code": 403}, {"status_code": 500}, + ), + ids=[ + "Connection Timeout", + "Connection Error", + "Status 404", + "Status 403", + "Status 500", + ], +) +def test_configure_discovery_failure( + requests_mock, discovery_endpoint_config_yml, mock_kwargs +): + requests_mock.get( + f"{KEYCLOAK_BASE_URL}.well-known/openid-configuration", + **mock_kwargs, ) - for mock_config in mock_kwargs: - requests_mock.get( - f"{KEYCLOAK_BASE_URL}.well-known/openid-configuration", - **mock_config, - ) - with pytest.raises(ConfigurationRunFailed): - execute_single_step( - AdminOIDCConfigurationStep, yaml_source=discovery_endpoint_config_yml - ) + with pytest.raises(ConfigurationRunFailed): + execute_single_step( + AdminOIDCConfigurationStep, yaml_source=discovery_endpoint_config_yml + ) - config = OpenIDConnectConfig.get_solo() - assert not config.enabled - assert config.oidc_op_discovery_endpoint == "" + config = OpenIDConnectConfig.get_solo() + assert not config.enabled + assert config.oidc_op_discovery_endpoint == "" From ecad85be3a1490357a76fc7f0afbcfb270479063 Mon Sep 17 00:00:00 2001 From: Conor Holden Date: Mon, 2 Dec 2024 14:13:26 +0100 Subject: [PATCH 14/16] :memo:[#114] make warning more clear --- docs/setup_configuration.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/setup_configuration.rst b/docs/setup_configuration.rst index 4382ba2..edc0318 100644 --- a/docs/setup_configuration.rst +++ b/docs/setup_configuration.rst @@ -88,8 +88,8 @@ Optional Fields: .. warning:: - The default values are always provided and will overwrite any settings changed in the admin. - Make sure updated settings are added to the configuration yaml. + Values that are not provided will use the default or empty value and will overwrite any setting changed in the admin. + Make sure settings that were manually changed in the admin are added to the configuration yaml. All the following keys are placed in the ``oidc_db_config_admin_auth`` dictionary. From ee32542e0792192d7ce86b4312114efa1526b869 Mon Sep 17 00:00:00 2001 From: Conor Holden Date: Mon, 2 Dec 2024 17:31:19 +0100 Subject: [PATCH 15/16] :recycle:[#114] rename dependency group --- docs/quickstart.rst | 6 ++++-- docs/setup_configuration.rst | 6 +++--- pyproject.toml | 2 +- tox.ini | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/quickstart.rst b/docs/quickstart.rst index e8ee57b..574559f 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -24,11 +24,11 @@ This will also install the following packages: - ``django-solo`` - ``django-jsonform`` -You can optionally install ``django-setup-configuration`` support with: +You can optionally install `django-setup-configuration`_ support with: .. code-block:: bash - pip install mozilla-django-oidc-db[setupconfig] + pip install mozilla-django-oidc-db[setup-configuration] Django settings --------------- @@ -273,3 +273,5 @@ and ``OIDCAuthenticationBackend.config_class`` to be this new class. .. _mozilla-django-oidc settings documentation: https://mozilla-django-oidc.readthedocs.io/en/stable/settings.html .. _OIDC spec: https://openid.net/specs/openid-connect-discovery-1_0.html#WellKnownRegistry + +.. _django-setup-configuration: https://pypi.org/project/django-setup-configuration/ diff --git a/docs/setup_configuration.rst b/docs/setup_configuration.rst index edc0318..be63a14 100644 --- a/docs/setup_configuration.rst +++ b/docs/setup_configuration.rst @@ -2,17 +2,17 @@ Django Setup Configuration ========================== -There is optional support for ``django-setup-configuration`` that allows you to automatically configure the +There is optional support for `django-setup-configuration`_ that allows you to automatically configure the OpenID Connect configuration using that package's ``setup_configuration`` command. -You must install the ``setupconfig`` dependency group: +You must install the ``setup-configuration`` dependency group: .. _django-setup-configuration: https://pypi.org/project/django-setup-configuration/ .. code-block:: bash - pip install mozilla-django-oidc-db[setupconfig] + pip install mozilla-django-oidc-db[setup-configuration] You must then define the required and any optional django settings mentioned below and diff --git a/pyproject.toml b/pyproject.toml index 9b5ff94..9587023 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ Documentation = "https://mozilla-django-oidc-db.readthedocs.io/en/latest/" Changelog = "https://github.com/maykinmedia/mozilla-django-oidc-db/blob/master/CHANGELOG.rst" [project.optional-dependencies] -setupconfig = [ +setup-configuration = [ "django-setup-configuration>=0.4.0", ] tests = [ diff --git a/tox.ini b/tox.ini index 2425fea..c5526e7 100644 --- a/tox.ini +++ b/tox.ini @@ -25,7 +25,7 @@ SETUP_CONFIG_ENABLED = extras = tests coverage - setup_config_enabled: setupconfig + setup_config_enabled: setup-configuration deps = django42: Django~=4.2.0 mozilla_django_oidc40: mozilla-django-oidc~=4.0.0 From d6c88464663ea5e99ff0833fb8f84de65bb4deec Mon Sep 17 00:00:00 2001 From: Conor Holden Date: Mon, 2 Dec 2024 18:15:20 +0100 Subject: [PATCH 16/16] :recycle:[#114] refactor setup configuration step * Explicitly use model values * Rename create_missing_groups to get_groups_by_name * Move sync_groups condition to get_groups_by_name * Fix sync_group doing nothing in step * Test sync_group & sync_groups_glob_pattern * Test idempotent and overwrite --- mozilla_django_oidc_db/backends.py | 9 +- .../setup_configuration/steps.py | 48 +++++-- mozilla_django_oidc_db/utils.py | 13 +- tests/setupconfig/conftest.py | 18 ++- tests/setupconfig/files/no_sync_groups.yml | 12 ++ tests/setupconfig/files/sync_groups.yml | 15 ++ tests/setupconfig/test_steps.py | 132 ++++++++++++++++-- 7 files changed, 212 insertions(+), 35 deletions(-) create mode 100644 tests/setupconfig/files/no_sync_groups.yml create mode 100644 tests/setupconfig/files/sync_groups.yml diff --git a/mozilla_django_oidc_db/backends.py b/mozilla_django_oidc_db/backends.py index efb66bc..96b467a 100644 --- a/mozilla_django_oidc_db/backends.py +++ b/mozilla_django_oidc_db/backends.py @@ -24,7 +24,7 @@ from .jwt import verify_and_decode_token from .models import OpenIDConnectConfigBase, UserInformationClaimsSources from .typing import ClaimPath, JSONObject -from .utils import create_missing_groups, extract_content_type, obfuscate_claims +from .utils import extract_content_type, get_groups_by_name, obfuscate_claims logger = logging.getLogger(__name__) @@ -384,10 +384,9 @@ def _set_user_groups( return # Create missing groups if required - if sync_missing_groups: - existing_groups = create_missing_groups(desired_group_names, sync_groups_glob) - else: - existing_groups = set(Group.objects.filter(name__in=desired_group_names)) + existing_groups = get_groups_by_name( + desired_group_names, sync_groups_glob, sync_missing_groups + ) # at this point, existing_groups is the full collection of groups that should be # set on the user model, because: diff --git a/mozilla_django_oidc_db/setup_configuration/steps.py b/mozilla_django_oidc_db/setup_configuration/steps.py index 0a0f5fe..02f9d9e 100644 --- a/mozilla_django_oidc_db/setup_configuration/steps.py +++ b/mozilla_django_oidc_db/setup_configuration/steps.py @@ -5,8 +5,9 @@ from mozilla_django_oidc_db.models import OpenIDConnectConfig from mozilla_django_oidc_db.setup_configuration.models import ( AdminOIDCConfigurationModel, + OIDCDiscoveryEndpoint, ) -from mozilla_django_oidc_db.utils import create_missing_groups +from mozilla_django_oidc_db.utils import get_groups_by_name class AdminOIDCConfigurationStep(BaseConfigurationStep[AdminOIDCConfigurationModel]): @@ -21,19 +22,46 @@ class AdminOIDCConfigurationStep(BaseConfigurationStep[AdminOIDCConfigurationMod def execute(self, model: AdminOIDCConfigurationModel) -> None: - config = OpenIDConnectConfig.get_solo() + all_settings = { + "enabled": model.enabled, + "oidc_rp_client_id": model.oidc_rp_client_id, + "oidc_rp_client_secret": model.oidc_rp_client_secret, + "oidc_rp_sign_algo": model.oidc_rp_sign_algo, + "oidc_rp_scopes_list": model.oidc_rp_scopes_list, + "oidc_op_jwks_endpoint": model.oidc_op_jwks_endpoint, + "oidc_token_use_basic_auth": model.oidc_token_use_basic_auth, + "oidc_rp_idp_sign_key": model.oidc_rp_idp_sign_key, + "oidc_op_logout_endpoint": model.oidc_op_logout_endpoint, + "oidc_use_nonce": model.oidc_use_nonce, + "oidc_nonce_size": model.oidc_nonce_size, + "oidc_state_size": model.oidc_state_size, + "oidc_keycloak_idp_hint": model.oidc_keycloak_idp_hint, + "userinfo_claims_source": model.userinfo_claims_source, + "username_claim": model.username_claim, + "claim_mapping": model.claim_mapping, + "groups_claim": model.groups_claim, + "sync_groups": model.sync_groups, + "sync_groups_glob_pattern": model.sync_groups_glob_pattern, + "make_users_staff": model.make_users_staff, + "superuser_group_names": model.superuser_group_names, + "default_groups": get_groups_by_name( + model.default_groups, model.sync_groups_glob_pattern, model.sync_groups + ), + } - all_settings = model.model_dump() - endpoint_config_data = all_settings.pop("endpoint_config") - all_settings.update(endpoint_config_data) - - if groups := all_settings.get("default_groups"): - all_settings["default_groups"] = create_missing_groups( - groups, all_settings["sync_groups_glob_pattern"] + if isinstance(model.endpoint_config, OIDCDiscoveryEndpoint): + all_settings.update( + oidc_op_discovery_endpoint=model.endpoint_config.oidc_op_discovery_endpoint, + ) + else: + all_settings.update( + oidc_op_authorization_endpoint=model.endpoint_config.oidc_op_authorization_endpoint, + oidc_op_token_endpoint=model.endpoint_config.oidc_op_token_endpoint, + oidc_op_user_endpoint=model.endpoint_config.oidc_op_user_endpoint, ) form = OpenIDConnectConfigForm( - instance=config, + instance=OpenIDConnectConfig.get_solo(), data=all_settings, ) if not form.is_valid(): diff --git a/mozilla_django_oidc_db/utils.py b/mozilla_django_oidc_db/utils.py index 0b05de4..039fb00 100644 --- a/mozilla_django_oidc_db/utils.py +++ b/mozilla_django_oidc_db/utils.py @@ -94,13 +94,20 @@ def do_op_logout(config: OpenIDConnectConfigBase, id_token: str) -> None: ) -def create_missing_groups( - group_names: Iterable[str], sync_groups_glob: str = "*" +def get_groups_by_name( + group_names: Iterable[str], sync_groups_glob: str, sync_missing_groups: bool ) -> set[Group]: + """ + Gets Django User groups by name. + + Optionally creates missing groups that match glob pattern. + """ existing_groups = set(Group.objects.filter(name__in=group_names)) - existing_group_names = {group.name for group in existing_groups} + if not sync_missing_groups: + return existing_groups + existing_group_names = {group.name for group in existing_groups} filtered_names = fnmatch.filter( set(group_names) - existing_group_names, sync_groups_glob ) diff --git a/tests/setupconfig/conftest.py b/tests/setupconfig/conftest.py index 9b5affc..bf80e86 100644 --- a/tests/setupconfig/conftest.py +++ b/tests/setupconfig/conftest.py @@ -4,7 +4,7 @@ OpenIDConnectConfig, UserInformationClaimsSources, ) -from mozilla_django_oidc_db.utils import create_missing_groups +from mozilla_django_oidc_db.utils import get_groups_by_name """ Key cloak credentials are setup for the keycloak docker-compose.yml. @@ -33,6 +33,16 @@ def discovery_endpoint_config_yml(): return "tests/setupconfig/files/discovery.yml" +@pytest.fixture() +def no_sync_groups_config_yml(): + return "tests/setupconfig/files/no_sync_groups.yml" + + +@pytest.fixture() +def sync_groups_config_yml(): + return "tests/setupconfig/files/sync_groups.yml" + + @pytest.fixture def set_config_to_non_default_values(): """ @@ -42,8 +52,8 @@ def set_config_to_non_default_values(): config = OpenIDConnectConfig.get_solo() # Will be always overwritten - config.oidc_rp_client_id = "client-id" - config.oidc_rp_client_secret = "secret" + config.oidc_rp_client_id = "different-client-id" + config.oidc_rp_client_secret = "different-secret" config.oidc_op_authorization_endpoint = "http://localhost:8080/whatever" config.oidc_op_token_endpoint = "http://localhost:8080/whatever" config.oidc_op_user_endpoint = "http://localhost:8080/whatever" @@ -74,7 +84,7 @@ def set_config_to_non_default_values(): config.oidc_state_size = 64 config.userinfo_claims_source = UserInformationClaimsSources.userinfo_endpoint - config.default_groups.set(create_missing_groups(["OldAdmin", "OldUser"])) + config.default_groups.set(get_groups_by_name(["OldAdmin", "OldUser"], "*", True)) config.save() diff --git a/tests/setupconfig/files/no_sync_groups.yml b/tests/setupconfig/files/no_sync_groups.yml new file mode 100644 index 0000000..590e699 --- /dev/null +++ b/tests/setupconfig/files/no_sync_groups.yml @@ -0,0 +1,12 @@ +oidc_db_config_enable: True +oidc_db_config_admin_auth: + oidc_rp_client_id: client-id + oidc_rp_client_secret: secret + endpoint_config: + oidc_op_authorization_endpoint: http://localhost:8080/realms/test/protocol/openid-connect/auth + oidc_op_token_endpoint: http://localhost:8080/realms/test/protocol/openid-connect/token + oidc_op_user_endpoint: http://localhost:8080/realms/test/protocol/openid-connect/userinfo + sync_groups: false + default_groups: + - SuperAdmins + - NormalUsers diff --git a/tests/setupconfig/files/sync_groups.yml b/tests/setupconfig/files/sync_groups.yml new file mode 100644 index 0000000..291eb6a --- /dev/null +++ b/tests/setupconfig/files/sync_groups.yml @@ -0,0 +1,15 @@ +oidc_db_config_enable: True +oidc_db_config_admin_auth: + oidc_rp_client_id: client-id + oidc_rp_client_secret: secret + endpoint_config: + oidc_op_authorization_endpoint: http://localhost:8080/realms/test/protocol/openid-connect/auth + oidc_op_token_endpoint: http://localhost:8080/realms/test/protocol/openid-connect/token + oidc_op_user_endpoint: http://localhost:8080/realms/test/protocol/openid-connect/userinfo + sync_groups: true + sync_groups_glob_pattern: local.groups.* + default_groups: + - local.groups.SuperAdmins + - local.WeirdAdmins + - local.groups.NormalUsers + - local.WeirdUsers diff --git a/tests/setupconfig/test_steps.py b/tests/setupconfig/test_steps.py index bd97540..9943c1f 100644 --- a/tests/setupconfig/test_steps.py +++ b/tests/setupconfig/test_steps.py @@ -1,13 +1,8 @@ -from io import StringIO - -from django.core.management import CommandError, call_command +from django.contrib.auth.models import Group import pytest import requests -from django_setup_configuration.exceptions import ( - ConfigurationRunFailed, - PrerequisiteFailed, -) +from django_setup_configuration.exceptions import ConfigurationRunFailed from django_setup_configuration.test_utils import execute_single_step from mozilla_django_oidc_db.models import ( @@ -17,6 +12,7 @@ from mozilla_django_oidc_db.setup_configuration.steps import AdminOIDCConfigurationStep from ..conftest import KEYCLOAK_BASE_URL +from .conftest import set_config_to_non_default_values @pytest.fixture(autouse=True) @@ -25,16 +21,17 @@ def clear_solo_cache(): OpenIDConnectConfig.clear_cache() -@pytest.mark.django_db -def test_configure(full_config_yml): - execute_single_step(AdminOIDCConfigurationStep, yaml_source=full_config_yml) - +def assert_full_values(): config = OpenIDConnectConfig.get_solo() - assert not config.enabled assert config.oidc_rp_client_id == "client-id" assert config.oidc_rp_client_secret == "secret" - assert config.oidc_rp_scopes_list == ["open_id", "email", "profile", "extra_scope"] + assert config.oidc_rp_scopes_list == [ + "open_id", + "email", + "profile", + "extra_scope", + ] assert config.oidc_rp_sign_algo == "RS256" assert config.oidc_rp_idp_sign_key == "key" assert config.oidc_op_discovery_endpoint == "" @@ -71,6 +68,70 @@ def test_configure(full_config_yml): assert config.userinfo_claims_source == UserInformationClaimsSources.id_token +@pytest.mark.django_db +def test_configure_full(full_config_yml): + + # create groups so they can be found + Group.objects.create(name="local.groups.Admins") + Group.objects.create(name="local.groups.Read-only") + + # test if idempotent + execute_single_step(AdminOIDCConfigurationStep, yaml_source=full_config_yml) + assert_full_values() + + execute_single_step(AdminOIDCConfigurationStep, yaml_source=full_config_yml) + assert_full_values() + + +@pytest.mark.django_db +def test_configure_overwrite(full_config_yml, set_config_to_non_default_values): + + # create groups so they can be found + Group.objects.create(name="local.groups.Admins") + Group.objects.create(name="local.groups.Read-only") + + config = OpenIDConnectConfig.get_solo() + + # assert different values + assert not config.enabled + assert config.oidc_rp_client_id == "different-client-id" + assert config.oidc_rp_client_secret == "different-secret" + assert config.oidc_rp_scopes_list == [ + "not_open_id", + "not_email", + "not_profile", + "not_extra_scope", + ] + assert config.oidc_rp_sign_algo == "M1911" + assert config.oidc_rp_idp_sign_key == "name" + assert config.oidc_op_discovery_endpoint == "http://localhost:8080/whatever" + assert config.oidc_op_jwks_endpoint == "http://localhost:8080/whatever" + assert config.oidc_op_authorization_endpoint == "http://localhost:8080/whatever" + assert config.oidc_op_token_endpoint == "http://localhost:8080/whatever" + assert config.oidc_op_user_endpoint == "http://localhost:8080/whatever" + assert config.username_claim == ["claim_title"] + assert config.groups_claim == ["groups_claim_title"] + assert config.claim_mapping == {"first_title": ["given_title"]} + assert config.sync_groups + assert config.sync_groups_glob_pattern == "not_local.groups.*" + assert set(group.name for group in config.default_groups.all()) == { + "OldAdmin", + "OldUser", + } + assert not config.make_users_staff + assert config.superuser_group_names == ["poweruser"] + assert config.oidc_use_nonce + assert config.oidc_nonce_size == 64 + assert config.oidc_state_size == 64 + assert ( + config.userinfo_claims_source == UserInformationClaimsSources.userinfo_endpoint + ) + + execute_single_step(AdminOIDCConfigurationStep, yaml_source=full_config_yml) + # assert values overwritten + assert_full_values() + + @pytest.mark.django_db def test_configure_use_defaults(set_config_to_non_default_values, default_config_yml): execute_single_step(AdminOIDCConfigurationStep, yaml_source=default_config_yml) @@ -181,3 +242,48 @@ def test_configure_discovery_failure( config = OpenIDConnectConfig.get_solo() assert not config.enabled assert config.oidc_op_discovery_endpoint == "" + + +@pytest.mark.django_db +def test_sync_groups_is_false(no_sync_groups_config_yml): + # create groups so they can be found + super_admin = Group.objects.create(name="SuperAdmins") + + result = execute_single_step( + AdminOIDCConfigurationStep, yaml_source=no_sync_groups_config_yml + ) + + assert not result.config_model.sync_groups + assert result.config_model.default_groups == ["SuperAdmins", "NormalUsers"] + + config = OpenIDConnectConfig.get_solo() + assert config.default_groups.all().count() == 1 + assert config.default_groups.get() == super_admin + + +@pytest.mark.django_db +def test_sync_groups_is_true(sync_groups_config_yml): + # create groups so they can be found + super_admin = Group.objects.create(name="local.groups.SuperAdmins") + weird_admin = Group.objects.create(name="local.WeirdAdmins") + + result = execute_single_step( + AdminOIDCConfigurationStep, yaml_source=sync_groups_config_yml + ) + + assert result.config_model.sync_groups + assert result.config_model.default_groups == [ + "local.groups.SuperAdmins", + "local.WeirdAdmins", + "local.groups.NormalUsers", + "local.WeirdUsers", + ] + assert result.config_model.sync_groups_glob_pattern == "local.groups.*" + + config = OpenIDConnectConfig.get_solo() + assert config.default_groups.all().count() == 3 + assert super_admin in config.default_groups.all() + assert weird_admin in config.default_groups.all() + assert config.default_groups.all().filter(name="local.groups.NormalUsers").exists() + # Does not match glob, is not created + assert not config.default_groups.all().filter(name="local.WeirdUsers").exists()