diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6df9afa --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +pyproject.toml export-subst diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..5ab37b7 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,85 @@ +name: Lint source code + +on: + push: + branches: [ $default-branch ] + pull_request: + +jobs: + + black: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: psf/black@stable + with: + version: "~= 24.0" + + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Needed for versioningit to find the repo version + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: python -m pip install --upgrade pip build + - name: Build python package + run: python -m build + + mypy: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Needed for versioningit to find the repo version + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: python -m pip install .[drf] + + - name: Install dependencies + run: python -m pip install --upgrade pip + -r requirements/requirements-dev.in + -r requirements/requirements-test.in + + - name: Run mypy + run: mypy --version && ./run_mypy.sh + + + linting: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Needed for versioningit to find the repo version + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: python -m pip download .[drf] + + - name: Install dependencies + run: python -m pip install --upgrade pip + -r requirements/requirements-dev.in + -r requirements/requirements-test.in + + - name: Run flake8 + run : flake8 --version && flake8 --extend-ignore=E501,E503,E203 --max-line-len=88 . + + - name: Run isort + run : isort --profile black . diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ee145fc..987d72c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/ambv/black - rev: 22.3.0 + rev: 24.10.0 hooks: - id: black - repo: https://github.com/pre-commit/pre-commit-hooks @@ -15,4 +15,10 @@ repos: - id: isort args: ["--profile", "black"] name: isort (python) - + - repo: local + hooks: + - id: mypy + name: Mypy + entry: ./run_mypy.sh + language: script + pass_filenames: false diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 7147041..e04e1e3 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,9 +1,52 @@ # Dev setup +## Publishing (test pypy) + +First create an account on [test pypi]() and generate a token. + +Clean your worktree and tag your release to generate a valid version number (otherwise pypi will reject your release) : + +``` +git stash # clean your worktree +git tag 0.0.18rc1 +git stash pop # restore your worktree +``` + +Then, publish using the Makefile to build and push the library : + +``` +make clean && make build && make publish-test +``` + +## Publishing (production) + +Make sure that you are on the maintainer list of the [pypi project](https://pypi.org/project/django-pyoidc/) and generate an API token for this project. + +Clean your worktree and tag your release : + +``` +git stash # clean your worktree +git tag 0.0.1 # tag the release +git stash pop # tag your release +``` + +Build the python package : + +``` +make clean && make build +``` + +Publish it : + +``` +make publish +``` + + ## Installation ```bash -pip install -r requirements.txt -r requirements-test.txt +pip install -r requirements/requirements.txt -r requirements/requirements-test.txt ``` ## Enable pre-commit @@ -20,27 +63,45 @@ Run a live documentation server : sphinx-autobuild docs docs/_build/html ``` +## Running static type checking (mypy) + +First install the dev dependencies : + +``` +pip install -r requirements/requirements.txt -r requirements/requirements-dev.txt +``` + +Then run mypy : + +``` +mypy django_pyoidc +``` + ## Running Tests -Check database settings in tests/test_settings.py, target a real PostgreSQL Host (You need a PostgreSQL version 12 or greater). +Check database settings in `tests/test_settings.py`, target a real PostgreSQL Host (You need a PostgreSQL version 12 or greater), for e2e tests check the `tests/e2e/settings.py` file. ``` -python3 runtests.py +python3 run_tests.py # for unit tests +python3 run_e2e_tests.py # for end to end tests ``` ## Adding a dependency -Add the dependency to either `requirements.in` or `requirements-test.in`. +Add the dependency to either `requirements/requirements.in`, `requirements/requirements-test.in` or `requirements/requirements-dev.in` +depending on the usage of the dependency. Then run : ``` pip install pip-tools -pip-compile --output-file=requirements.txt pyproject.toml # freeze package versions -pip-compile --output-file=requirements-test.txt requirements-test.in +make update_all_deps ``` -FIXME: possible alternative for tests requirements would be: +## Building local packages + +You can build the package locally by running : + ``` -python -m piptools compile --extra test -o requirements-test.txt pyproject.toml +python -m build ``` diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index fd2bcec..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,3 +0,0 @@ -include LICENSE -include django_pyoidc/VERSION -include django_pyoidc/**/*.py diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cafef46 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +.PHONY: update_all_deps build clean publish-test + +update_all_deps : requirements/requirements.txt requirements/requirements-dev.txt requirements/requirements-test.txt + + +requirements/requirements.txt : pyproject.toml + pip-compile -o $@ $< --extra drf + +requirements/requirements-dev.txt : requirements/requirements-dev.in requirements/requirements/requirements.txt + pip-compile -o $@ $< + +requirements/requirements-test.txt : requirements/requirements-test.in requirements/requirements-dev.in requirements/requirements.txt + pip-compile $< + +publish-test: + hatch publish -r test -u __token__ + +publish: + hatch publish -r main -u __token__ + +build: + hatch build + +clean: + @rm -rf dist/ diff --git a/README.md b/README.md index 6d2df5c..5e8b0b9 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Makina Django OIDC +# django-pyoidc

@@ -90,19 +90,22 @@ Now you can pick an identity provider from the [available providers](https://dja Create a file named `oidc.py` next to your settings file and initialize your provider there : +FIXME: Here config as settings only OR using custom provider + ```python from django_pyoidc.providers.keycloak import KeycloakProvider my_oidc_provider = KeycloakProvider( op_name="keycloak", - client_secret="s3cret", - client_id="my_client_id", keycloak_base_uri="http://keycloak.local:8080/auth/", # we use the auth/ path prefix option on Keycloak keycloak_realm="Demo", + client_secret="s3cret", + client_id="my_client_id", logout_redirect="http://app.local:8082/", failure_redirect="http://app.local:8082/", success_redirect="http://app.local:8082/", redirect_requires_https=False, + login_uris_redirect_allowed_hosts=["app.local:8082"], ) ``` @@ -112,7 +115,7 @@ You can then add to your django configuration the following line : from .oidc_providers import my_oidc_provider DJANGO_PYOIDC = { - **my_oidc_provider.get_config(allowed_hosts=["app.local:8082"]), + **my_oidc_provider.get_config(), } ``` @@ -153,4 +156,3 @@ This project is sponsored by Makina Corpus. If you require assistance on your pr - [@gbip](https://www.github.com/gbip) - [@regilero](https://github.com/regilero) - diff --git a/django_pyoidc/VERSION b/django_pyoidc/VERSION index 43b2961..9789c4c 100644 --- a/django_pyoidc/VERSION +++ b/django_pyoidc/VERSION @@ -1 +1 @@ -0.0.13 +0.0.14 diff --git a/django_pyoidc/__init__.py b/django_pyoidc/__init__.py index 25497cb..86ed935 100644 --- a/django_pyoidc/__init__.py +++ b/django_pyoidc/__init__.py @@ -1,4 +1,4 @@ -from typing import Dict +from typing import Any, Dict from django.contrib.auth import get_user_model from django.core.exceptions import SuspiciousOperation @@ -7,7 +7,7 @@ from django_pyoidc.utils import extract_claim_from_tokens -def get_user_by_email(tokens: Dict): +def get_user_by_email(tokens: Dict[str, Any]) -> Any: User = get_user_model() username = None @@ -71,5 +71,6 @@ def get_user_by_email(tokens: Dict): email=email, username=django_username, ) - user.backend = "django.contrib.auth.backends.ModelBackend" + if hasattr(user, "backend"): + user.backend = "django.contrib.auth.backends.ModelBackend" return user diff --git a/django_pyoidc/admin.py b/django_pyoidc/admin.py index 74efde8..f1a30bd 100644 --- a/django_pyoidc/admin.py +++ b/django_pyoidc/admin.py @@ -3,10 +3,12 @@ from django.conf import settings from django.contrib import admin +from django_pyoidc.models import OIDCSession + SessionStore = import_module(settings.SESSION_ENGINE).SessionStore -class OIDCSessionAdmin(admin.ModelAdmin): +class OIDCSessionAdmin(admin.ModelAdmin): # type: ignore[type-arg] # https://github.com/typeddjango/django-stubs/issues/507 readonly_fields = ( "state", "session_state", @@ -24,12 +26,11 @@ class OIDCSessionAdmin(admin.ModelAdmin): "created_at", ] - def has_session_management(self, obj) -> bool: + @admin.display(boolean=True) + def has_session_management(self, obj: OIDCSession) -> bool: return obj.session_state is not None - def session_is_active(self, obj) -> bool: + @admin.display(boolean=True) + def session_is_active(self, obj: OIDCSession) -> bool: s = SessionStore() return obj.cache_session_key is not None and s.exists(obj.cache_session_key) - - has_session_management.boolean = True - session_is_active.boolean = True diff --git a/django_pyoidc/client.py b/django_pyoidc/client.py index 56cb604..27aa0ed 100644 --- a/django_pyoidc/client.py +++ b/django_pyoidc/client.py @@ -1,59 +1,101 @@ import logging +from typing import Optional, TypeVar, Union # import oic from oic.extension.client import Client as ClientExtension from oic.oic.consumer import Consumer from oic.utils.authn.client import CLIENT_AUTHN_METHOD +from django_pyoidc.exceptions import ( + InvalidOIDCConfigurationException, + InvalidSIDException, +) from django_pyoidc.session import OIDCCacheSessionBackendForDjango -from django_pyoidc.utils import OIDCCacheBackendForDjango, get_setting_for_sso_op +from django_pyoidc.settings import OIDCSettings, OIDCSettingsFactory, OidcSettingValue +from django_pyoidc.utils import OIDCCacheBackendForDjango logger = logging.getLogger(__name__) +T = TypeVar("T") -class OIDCClient: - def __init__(self, op_name, session_id=None): - self._op_name = op_name - self.session_cache_backend = OIDCCacheSessionBackendForDjango(self._op_name) - self.general_cache_backend = OIDCCacheBackendForDjango(self._op_name) +class OIDCClient: + def __init__(self, op_name: str, session_id: Optional[str] = None): + self.opsettings = OIDCSettingsFactory.get(op_name) - consumer_config = { - # "debug": True, - "response_type": "code" - } + self.session_cache_backend = OIDCCacheSessionBackendForDjango(self.opsettings) + self.general_cache_backend = OIDCCacheBackendForDjango(self.opsettings) + client_id = self.opsettings.get("client_id") + client_secret = self.opsettings.get("client_secret", None) + consumer_config = self.opsettings.get( + "client_consumer_config_dict", + { + # "debug": True, + "response_type": "code" + }, + ) client_config = { - "client_id": get_setting_for_sso_op(op_name, "OIDC_CLIENT_ID"), - "client_authn_method": CLIENT_AUTHN_METHOD, + "client_id": client_id, + "client_authn_method": self.opsettings.get( + "client_authn_method", CLIENT_AUTHN_METHOD + ), } - self.consumer = Consumer( session_db=self.session_cache_backend, consumer_config=consumer_config, client_config=client_config, - ) + ) # type: ignore[no-untyped-call] # oic.oic.consumer.Consumer is not typed yet + # used in token introspection - self.client_extension = ClientExtension(**client_config) + self.client_extension = ClientExtension(**client_config) # type: ignore[no-untyped-call] # oic.extension.client.Client is not typed yet - provider_info_uri = get_setting_for_sso_op( - op_name, "OIDC_PROVIDER_DISCOVERY_URI" - ) - client_secret = get_setting_for_sso_op(op_name, "OIDC_CLIENT_SECRET") + provider_discovery_uri: str = self.opsettings.get("provider_discovery_uri", None) # type: ignore[assignment] # we can assume that the configuration is ok self.client_extension.client_secret = client_secret - if session_id: - self.consumer.restore(session_id) - else: - - cache_key = self.general_cache_backend.generate_hashed_cache_key( - provider_info_uri - ) + if session_id is not None: try: - config = self.general_cache_backend[cache_key] + self.consumer.restore(session_id) # type: ignore[no-untyped-call] # Consumer.restore is not typed yet except KeyError: - config = self.consumer.provider_config(provider_info_uri) - # shared microcache for provider config - # FIXME: Setting for duration - self.general_cache_backend.set(cache_key, config, 60) - self.consumer.client_secret = client_secret + # This is an error as for example during the first communication round trips between + # the op and the client we'll have to find state elements in the oidc session + raise InvalidSIDException( + f"OIDC consumer failed to restore oidc session {session_id}." + ) + return + + if provider_discovery_uri is None: + raise InvalidOIDCConfigurationException( + "No provider discovery uri provided." + ) + else: + if self.opsettings.get("oidc_cache_provider_metadata", False): + cache_key = self.general_cache_backend.generate_hashed_cache_key( + provider_discovery_uri + ) + try: + config = self.general_cache_backend[cache_key] + # this will for example register endpoints on the consumer object + self.consumer.handle_provider_config(config, provider_discovery_uri) # type: ignore[arg-type] # provider_discovery_uri is from the cache + except KeyError: + # This make an HTTP call on provider discovery uri + config = self.consumer.provider_config(provider_discovery_uri) + # shared microcache for provider config + ttl: int = self.opsettings.get("oidc_cache_provider_metadata_ttl") # type: ignore[assignment] # we can assume the configuration is right + self.general_cache_backend.set( + cache_key, + config, + ttl, + ) + else: + # This make an HTTP call on provider discovery uri + self.consumer.provider_config(provider_discovery_uri) + self.consumer.client_secret = client_secret + + def get_settings(self) -> OIDCSettings: + return self.opsettings + + def get_setting( + self, name: str, default: Optional[T] = None + ) -> Optional[Union[OidcSettingValue, T]]: + return self.opsettings.get(name, default) diff --git a/django_pyoidc/drf/authentication.py b/django_pyoidc/drf/authentication.py index 5ed994d..6923f9b 100644 --- a/django_pyoidc/drf/authentication.py +++ b/django_pyoidc/drf/authentication.py @@ -1,18 +1,18 @@ import functools import logging +from typing import Any, Optional, Tuple -from django.conf import settings from django.core.exceptions import PermissionDenied from rest_framework import exceptions from rest_framework.authentication import BaseAuthentication +from rest_framework.request import Request +from typing_extensions import override from django_pyoidc.client import OIDCClient from django_pyoidc.engine import OIDCEngine -from django_pyoidc.utils import ( - OIDCCacheBackendForDjango, - check_audience, - get_setting_for_sso_op, -) +from django_pyoidc.exceptions import ExpiredToken +from django_pyoidc.settings import OIDCSettingsFactory +from django_pyoidc.utils import OIDCCacheBackendForDjango, check_audience logger = logging.getLogger(__name__) @@ -22,57 +22,31 @@ class OidcAuthException(Exception): class OIDCBearerAuthentication(BaseAuthentication): - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any): super(OIDCBearerAuthentication, self).__init__(*args, **kwargs) - self.op_name = self.extract_drf_opname() - self.general_cache_backend = OIDCCacheBackendForDjango(self.op_name) - self.engine = OIDCEngine(self.op_name) + self.opsettings = OIDCSettingsFactory.get("drf") + self.general_cache_backend = OIDCCacheBackendForDjango(self.opsettings) + self.engine = OIDCEngine(self.opsettings) @functools.cached_property - def client(self): - return OIDCClient(self.op_name) + def client(self) -> OIDCClient: + return OIDCClient("drf") - @classmethod - def extract_drf_opname(cls): - """ - Given a list of opnames and setting in DJANGO_PYOIDC conf, extract the one having USED_BY_REST_FRAMEWORK=True. - """ - op = None - found = False - for op_name, configs in settings.DJANGO_PYOIDC.items(): - if ( - "USED_BY_REST_FRAMEWORK" in configs - and configs["USED_BY_REST_FRAMEWORK"] - ): - if found: - raise RuntimeError( - "Several DJANGO_PYOIDC sections are declared as USED_BY_REST_FRAMEWORK, only one should be used." - ) - found = True - op = op_name - if found: - return op - else: - raise RuntimeError( - "No DJANGO_PYOIDC sections are declared with USED_BY_REST_FRAMEWORK configuration option." - ) - - def extract_access_token(self, request) -> str: + def extract_access_token(self, request: Request) -> str: val = request.headers.get("Authorization") if not val: msg = "Request missing the authorization header." raise OidcAuthException(msg) val = val.strip() bearer_name, access_token_jwt = val.split(maxsplit=1) - requested_bearer_name = get_setting_for_sso_op( - self.op_name, "OIDC_API_BEARER_NAME", "Bearer" - ) - if not bearer_name.lower() == requested_bearer_name.lower(): - msg = f"Bad authorization header, invalid Keyword for the bearer, expecting {requested_bearer_name}." + requested_bearer_name = self.opsettings.get("oidc_api_bearer_name", "Bearer") + if not bearer_name.lower() == requested_bearer_name.lower(): # type: ignore[union-attr] # we can assume that this setting is a string + msg = f"Bad authorization header, invalid Keyword for the bearer, expecting {requested_bearer_name} (check setting oidc_api_bearer_name)." raise OidcAuthException(msg) return access_token_jwt - def authenticate(self, request): + @override + def authenticate(self, request: Request) -> Optional[Tuple[Any, Any]]: """ Returns two-tuple of (user, token) if authentication succeeds, or None otherwise. @@ -90,9 +64,20 @@ def authenticate(self, request): # This introspection of the token is made by the SSO server # so it is quite slow, but there's a cache added based on the token expiration - access_token_claims = self.engine.introspect_access_token( - access_token_jwt, client=self.client - ) + # it could also call a user defined validator if 'use_introspection_on_access_tokens' + # is False. or it could return None if the two previous are not defined. + try: + access_token_claims = self.engine.introspect_access_token( + access_token_jwt, client=self.client + ) + except ExpiredToken: + msg = "Inactive access token." + raise exceptions.AuthenticationFailed(msg) + + if not access_token_claims: + exceptions.AuthenticationFailed( + "Access token claims failed to be extracted." + ) logger.debug(access_token_claims) if not access_token_claims.get("active"): msg = "Inactive access token." @@ -104,7 +89,7 @@ def authenticate(self, request): logger.debug("Request has valid access token.") # FIXME: Add a setting to disable - client_id = get_setting_for_sso_op(self.op_name, "OIDC_CLIENT_ID") + client_id: str = self.opsettings.get("client_id") # type: ignore[assignment] # we can assume that client_id is correctly configured if not check_audience(client_id, access_token_claims): raise PermissionDenied( f"Invalid result for acces token audiences check for {client_id}." @@ -115,7 +100,8 @@ def authenticate(self, request): tokens={ "access_token_jwt": access_token_jwt, "access_token_claims": access_token_claims, - } + }, + client=self.client, ) if not user: diff --git a/django_pyoidc/drf/schema.py b/django_pyoidc/drf/schema.py index 1699a54..d18bc3a 100644 --- a/django_pyoidc/drf/schema.py +++ b/django_pyoidc/drf/schema.py @@ -1,35 +1,28 @@ import logging -from urllib.parse import urljoin logger = logging.getLogger(__name__) try: from drf_spectacular.extensions import OpenApiAuthenticationExtension - from django_pyoidc.utils import get_setting_for_sso_op + from django_pyoidc.settings import OIDCSettingsFactory - class OIDCScheme(OpenApiAuthenticationExtension): + class OIDCScheme(OpenApiAuthenticationExtension): # type: ignore[no-untyped-call] # drf_spectacular.plumbing.OpenApiGeneratorExtension.__init_subclass__ is untyped target_class = "django_pyoidc.drf.authentication.OIDCBearerAuthentication" name = "openIdConnect" match_subclasses = True priority = -1 - def get_security_definition(self, auto_schema): - from django_pyoidc.drf.authentication import OIDCBearerAuthentication + def get_security_definition(self, auto_schema): # type: ignore[no-untyped-def] # we do not want to type third party libraries + # from django_pyoidc.drf.authentication import OIDCBearerAuthentication - op = OIDCBearerAuthentication.extract_drf_opname() - well_known_url = get_setting_for_sso_op(op, "OIDC_PROVIDER_DISCOVERY_URI") - if not well_known_url.endswith(".well-known/openid-configuration"): - if not well_known_url.endswith("/"): - well_known_url += "/" - well_known_url = urljoin( - well_known_url, ".well-known/openid-configuration" - ) + opsettings = OIDCSettingsFactory.get("drf") + well_known_url = opsettings.get("provider_discovery_uri") - header_name = get_setting_for_sso_op(op, "OIDC_API_BEARER_NAME", "Bearer") + header_name = opsettings.get("oidc_api_bearer_name", "Bearer") if header_name != "Bearer": logger.warning( - "The configuration for 'OIDC_API_BEARER_NAME' will cause issue with swagger UI :" + "The configuration for 'oidc_api_bearer_name' will cause issue with swagger UI :" "it is not yet possible to change the header name for swagger UI, you should stick to" "'Bearer'." ) diff --git a/django_pyoidc/engine.py b/django_pyoidc/engine.py index 78138bc..240a7a6 100644 --- a/django_pyoidc/engine.py +++ b/django_pyoidc/engine.py @@ -1,78 +1,108 @@ import datetime import logging +from typing import Any, Dict, MutableMapping, Optional, Union from django_pyoidc import get_user_by_email from django_pyoidc.client import OIDCClient -from django_pyoidc.utils import ( - OIDCCacheBackendForDjango, - get_setting_for_sso_op, - import_object, -) +from django_pyoidc.exceptions import ExpiredToken, TokenError +from django_pyoidc.settings import OIDCSettings +from django_pyoidc.utils import OIDCCacheBackendForDjango, import_object logger = logging.getLogger(__name__) class OIDCEngine: - def __init__(self, op_name: str): - self.op_name = op_name - self.general_cache_backend = OIDCCacheBackendForDjango(self.op_name) + def __init__(self, opsettings: OIDCSettings): + self.opsettings = opsettings + self.general_cache_backend = OIDCCacheBackendForDjango(opsettings) - def call_function(self, setting_name, *args, **kwargs): - function_path = get_setting_for_sso_op(self.op_name, setting_name) - if function_path: + def call_function(self, setting_func_name: str, *args: Any, **kwargs: Any) -> Any: + function_path: Optional[str] = self.opsettings.get(setting_func_name) # type: ignore[assignment] # we can assume that the configuration is right + if function_path is not None: func = import_object(function_path, "") return func(*args, **kwargs) - def call_get_user_function(self, tokens={}): - if get_setting_for_sso_op(self.op_name, "HOOK_GET_USER"): + def call_get_user_function( + self, client: OIDCClient, tokens: Optional[Dict[str, Any]] = None + ) -> Any: + if tokens is None: + tokens = {} + if self.opsettings.get("hook_get_user") is not None: logger.debug("OIDC, Calling user hook on get_user") - return self.call_function("HOOK_GET_USER", tokens) + return self.call_function("hook_get_user", client=client, tokens=tokens) else: + logger.debug("OIDC, Calling get_user_by_email") return get_user_by_email(tokens) - def introspect_access_token(self, access_token_jwt: str, client: OIDCClient): + def introspect_access_token( + self, access_token_jwt: Optional[str], client: OIDCClient + ) -> Any: """ - Perform a cached intropesction call to extract claims from encoded jwt of the access_token + Perform a cached introspection call to extract claims from encoded jwt of the access_token """ # FIXME: allow a non-cached mode by global settings - access_token_claims = None - # FIXME: in what case could we not have an access token available? - # should we raise an error then? - if access_token_jwt is not None: - cache_key = self.general_cache_backend.generate_hashed_cache_key( - access_token_jwt - ) - try: - access_token_claims = self.general_cache_backend["cache_key"] - except KeyError: - # CACHE MISS + if access_token_jwt is None: + raise TokenError("Nothing in access_token_jwt.") + + if self.opsettings.get("use_introspection_on_access_tokens"): + return self._call_introspection(access_token_jwt, client) + else: + return self.call_validate_tokens_hook(access_token_jwt, client) - # RFC 7662: token introspection: ask SSO to validate and render the jwt as json - # this means a slow web call - request_args = { - "token": access_token_jwt, - "token_type_hint": "access_token", - } - client_auth_method = client.consumer.registration_response.get( - "introspection_endpoint_auth_method", "client_secret_basic" - ) - introspection = client.client_extension.do_token_introspection( - request_args=request_args, - authn_method=client_auth_method, - endpoint=client.consumer.introspection_endpoint, - ) - access_token_claims = introspection.to_dict() + def _call_introspection( + self, access_token_jwt: str, client: OIDCClient + ) -> MutableMapping[str, Union[str, bool]]: + cache_key = self.general_cache_backend.generate_hashed_cache_key( + access_token_jwt + ) + try: + access_token_claims = self.general_cache_backend[cache_key] + except KeyError: + # CACHE MISS - # store it in cache - current = datetime.datetime.now().strftime("%s") - if "exp" not in access_token_claims: - raise RuntimeError("No expiry set on the access token.") - access_token_expiry = access_token_claims["exp"] - exp = int(access_token_expiry) - int(current) - logger.debug( - f"Token expiry: {exp} - current is {current} " - f"and expiry is set to {access_token_expiry} in the token" - ) - self.general_cache_backend.set(cache_key, access_token_claims, exp) + # RFC 7662: token introspection: ask SSO to validate and render the jwt as json + # this means a slow http call + request_args = { + "token": access_token_jwt, + "token_type_hint": "access_token", + } + client_auth_method = client.consumer.registration_response.get( + "introspection_endpoint_auth_method", "client_secret_basic" + ) # type: ignore[no-untyped-call] # oic is untyped + introspection = client.client_extension.do_token_introspection( + request_args=request_args, + authn_method=client_auth_method, + endpoint=client.consumer.introspection_endpoint, # type: ignore + ) + access_token_claims = introspection.to_dict() + if "active" in access_token_claims and not access_token_claims["active"]: + # there will not be other claims, like expiry, this is simply an expired token + logger.info("access token introspection failed, expired token.") + raise ExpiredToken("Inactive access token.") + # store it in cache + current = datetime.datetime.now().strftime("%s") + if "exp" not in access_token_claims: + raise RuntimeError("No expiry set on the access token.") + access_token_expiry = access_token_claims["exp"] + exp = int(access_token_expiry) - int(current) + logger.debug( + f"Token expiry: {exp} - current is {current} " + f"and expiry is set to {access_token_expiry} in the token" + ) + self.general_cache_backend.set(cache_key, access_token_claims, exp) return access_token_claims + + def call_validate_tokens_hook( + self, access_token_jwt: str, client: OIDCClient + ) -> Any: + if self.opsettings.get("hook_validate_access_token") is not None: + logger.debug("OIDC, Calling hook_validate_access_token.") + return self.call_function( + "hook_validate_access_token", access_token_jwt, client + ) + else: + logger.debug( + "No way to extract claims from access token. 'use_introspection_on_access_tokens' is false and no user 'hook_validate_access_token' is defined. Empty dict return for access token." + ) + return None diff --git a/django_pyoidc/exceptions.py b/django_pyoidc/exceptions.py index fc9ec8d..5ee4732 100644 --- a/django_pyoidc/exceptions.py +++ b/django_pyoidc/exceptions.py @@ -2,5 +2,17 @@ class InvalidSIDException(Exception): pass +class TokenError(Exception): + pass + + +class ExpiredToken(Exception): + pass + + class ClaimNotFoundError(Exception): pass + + +class InvalidOIDCConfigurationException(Exception): + pass diff --git a/django_pyoidc/models.py b/django_pyoidc/models.py index f2615bf..85bce8e 100644 --- a/django_pyoidc/models.py +++ b/django_pyoidc/models.py @@ -2,6 +2,8 @@ class OIDCSession(models.Model): + objects: models.Manager["OIDCSession"] + id = models.BigAutoField(primary_key=True) # Used by pyoidc to save the client when no session state is available @@ -17,7 +19,7 @@ class OIDCSession(models.Model): cache_session_key = models.TextField() created_at = models.DateTimeField(auto_now_add=True) - def __str__(self): + def __str__(self) -> str: if self.session_state: return f"Session with id : {self.session_state}" else: diff --git a/django_pyoidc/providers/__init__.py b/django_pyoidc/providers/__init__.py index 816fcc9..bd10875 100644 --- a/django_pyoidc/providers/__init__.py +++ b/django_pyoidc/providers/__init__.py @@ -4,3 +4,4 @@ from .keycloak_18 import Keycloak18Provider # noqa from .lemonldapng import LemonLDAPngProvider # noqa from .lemonldapng2 import LemonLDAPng2Provider # noqa +from .provider import Provider # noqa diff --git a/django_pyoidc/providers/base.py b/django_pyoidc/providers/base.py deleted file mode 100644 index 729c98d..0000000 --- a/django_pyoidc/providers/base.py +++ /dev/null @@ -1,116 +0,0 @@ -from typing import Any, Dict, List - -from django.urls import path, reverse_lazy - - -class Provider: - """ - This is the base `Provider` class that is used to implement common provider configuration patterns. You should not - use this class directly. Instead, you should but subclass it to implement the configuration logic. - """ - - def __init__( - self, - op_name: str, - provider_discovery_uri: str, - logout_redirect: str, - failure_redirect: str, - success_redirect: str, - redirect_requires_https: bool, - client_secret: str, - client_id: str, - ): - """ - Parameters: - op_name (str): the name of the sso provider that you are using - logout_redirect (str): the URI where a user should be redirected to on logout success - failure_redirect (str): the URI where a user should be redirected to on login failure - success_redirect (str): the URI a user should be redirected to on login success if no redirection url where provided - redirect_requires_https (bool): set to True to disallow redirecting user to non-https uri on login success - client_secret (str): the OIDC client secret - client_id (str): the OIDC client ID - """ - self.op_name = op_name - self.provider_discovery_uri = provider_discovery_uri - self.logout_redirect = logout_redirect - self.failure_redirect = failure_redirect - self.success_redirect = success_redirect - self.redirect_requires_https = redirect_requires_https - self.client_secret = client_secret - self.client_id = client_id - - def get_config( - self, allowed_hosts, cache_backend: str = "default" - ) -> Dict[str, Dict[str, Any]]: - """ - Parameters: - allowed_hosts(:obj:`list`) : A list of allowed domains that can be redirected to. A good idea is to this to - :setting:`ALLOWED_HOSTS `. See :ref:`Redirect the user after login` for more details. - cache_backend(:obj:`str`, optional): Defaults to 'default'. The cache backend that should be used to store - this provider sessions. Take a look at :ref:`Cache Management` - - Returns: - dict: A dictionary with all the settings that `django-pyoidc` expects to work properly - """ - return { - self.op_name: { - "POST_LOGIN_URI_FAILURE": self.failure_redirect, - "POST_LOGIN_URI_SUCCESS": self.success_redirect, - "POST_LOGOUT_REDIRECT_URI": self.logout_redirect, - "OIDC_CALLBACK_PATH": reverse_lazy(self.callback_uri_name), - "REDIRECT_REQUIRES_HTTPS": self.redirect_requires_https, - "LOGIN_URIS_REDIRECT_ALLOWED_HOSTS": allowed_hosts, - "OIDC_CLIENT_SECRET": self.client_secret, - "OIDC_CLIENT_ID": self.client_id, - "OIDC_PROVIDER_DISCOVERY_URI": self.provider_discovery_uri, - "OIDC_LOGOUT_REDIRECT_PARAMETER_NAME": None, - "CACHE_DJANGO_BACKEND": cache_backend, - } - } - - @property - def login_uri_name(self): - """ - The login viewname (use in :func:`django:django.urls.reverse` django directive for example) of this configuration - """ - return f"{self.op_name}-login" - - @property - def logout_uri_name(self): - """ - The logout viewname (use in :func:`django:django.urls.reverse` django directive for example) of this configuration - """ - return f"{self.op_name}-logout" - - @property - def callback_uri_name(self): - """ - The callback viewname (use in :func:`django:django.urls.reverse` django directive for example) of this configuration - """ - return f"{self.op_name}-callback" - - def get_urlpatterns(self) -> List[Any]: - """ - Returns: - A list of urllpatterns to be included using :func:`django:django.urls.include` in your urllconfiguration - """ - from django_pyoidc.views import OIDCCallbackView, OIDCLoginView, OIDCLogoutView - - result = [ - path( - "callback", - OIDCCallbackView.as_view(op_name=self.op_name), - name=self.callback_uri_name, - ), - path( - "login", - OIDCLoginView.as_view(op_name=self.op_name), - name=self.login_uri_name, - ), - path( - "logout", - OIDCLogoutView.as_view(op_name=self.op_name), - name=self.logout_uri_name, - ), - ] - return result diff --git a/django_pyoidc/providers/keycloak_10.py b/django_pyoidc/providers/keycloak_10.py index 3a18a6b..5c956da 100644 --- a/django_pyoidc/providers/keycloak_10.py +++ b/django_pyoidc/providers/keycloak_10.py @@ -1,9 +1,13 @@ """ -toto +Base Keycloak Provider class. """ -from typing import Any, Dict -from django_pyoidc.providers.base import Provider +from typing import Any, Optional +from urllib.parse import urlparse + +from typing_extensions import override + +from django_pyoidc.providers.provider import Provider, ProviderConfig class Keycloak10Provider(Provider): @@ -11,16 +15,67 @@ class Keycloak10Provider(Provider): Provide Django settings/urlconf based on keycloak behaviour (v10 to v18) """ - def __init__(self, keycloak_base_uri: str, keycloak_realm: str, *args, **kwargs): - self.keycloak_base_uri = keycloak_base_uri + def __init__( + self, + keycloak_base_uri: Optional[str] = None, + keycloak_realm: Optional[str] = None, + *args: Any, + op_name: str, + **kwargs: Any, + ): + if keycloak_base_uri is None or keycloak_realm is None: + # Usage of this provider SHOULD be providing keycloak_base_uri and keycloak_realm so we generate provider_discovery_uri + # but the contrary works (you provide provider_discovery_uri and we extract base_uri and realm). + if ( + "provider_discovery_uri" not in kwargs + or not kwargs["provider_discovery_uri"] + ): + raise TypeError( + "Keycloak10Provider requires keycloak_base_uri and keycloak_realm or provider_discovery_uri." + ) + url = urlparse(kwargs["provider_discovery_uri"]) + base_path = url.path + if "/realms/" in base_path: + parts = base_path.split("/realms/") + base_path = parts[0] + keycloak_realm = parts[1] + extra_string = ".well-known/openid-configuration" + if keycloak_realm is not None and keycloak_realm.endswith(extra_string): + keycloak_realm = keycloak_realm[: -len(extra_string)] + extra_string = ".well-known/openid-configuration/" + if keycloak_realm is not None and keycloak_realm.endswith(extra_string): + keycloak_realm = keycloak_realm[: -len(extra_string)] + extra_string = "/" + if keycloak_realm is not None and keycloak_realm.endswith(extra_string): + keycloak_realm = keycloak_realm[: -len(extra_string)] + if ( + keycloak_realm is not None + and "/" in keycloak_realm + or keycloak_realm is None + ): + raise RuntimeError( + "Cannot extract the keycloak realm from the provided url." + ) + else: + raise RuntimeError( + "Provided 'provider_discovery_uri' url is not a valid Keycloak metadata url, it does not contains /realms/." + ) + keycloak_base_uri = f"{url.scheme}{url.netloc}{base_path}" + + if keycloak_base_uri is not None: + self.keycloak_base_uri = keycloak_base_uri + if self.keycloak_base_uri[-1] == "/": + self.keycloak_base_uri = self.keycloak_base_uri[:-1] self.keycloak_realm = keycloak_realm - provider_discovery_uri = f"{keycloak_base_uri}/{keycloak_realm}" - super().__init__(*args, **kwargs, provider_discovery_uri=provider_discovery_uri) - - def get_config(self, allowed_hosts, **kwargs) -> Dict[str, Dict[str, Any]]: - result = super().get_config(allowed_hosts, **kwargs) - # result[self.op_name]["SCOPE"] = "full-dedicated" - result[self.op_name][ - "OIDC_LOGOUT_QUERY_STRING_REDIRECT_PARAMETER" - ] = "redirect_uri" + provider_discovery_uri = ( + f"{self.keycloak_base_uri}/realms/{self.keycloak_realm}" + ) + kwargs["provider_discovery_uri"] = provider_discovery_uri + super().__init__(op_name=op_name, *args, **kwargs) + + @override + def get_default_config(self) -> ProviderConfig: + result = super().get_default_config() + + result["oidc_logout_query_string_redirect_parameter"] = "redirect_uri" return result diff --git a/django_pyoidc/providers/keycloak_17.py b/django_pyoidc/providers/keycloak_17.py index 5e21043..ada612b 100644 --- a/django_pyoidc/providers/keycloak_17.py +++ b/django_pyoidc/providers/keycloak_17.py @@ -1,9 +1,7 @@ -from typing import Any, Dict - from django_pyoidc.providers.keycloak_10 import Keycloak10Provider class Keycloak17Provider(Keycloak10Provider): """ Provide Django settings/urlconf based on keycloak behaviour (v17) - """ \ No newline at end of file + """ diff --git a/django_pyoidc/providers/keycloak_18.py b/django_pyoidc/providers/keycloak_18.py index 45c5828..17d1e04 100644 --- a/django_pyoidc/providers/keycloak_18.py +++ b/django_pyoidc/providers/keycloak_18.py @@ -1,6 +1,7 @@ -from typing import Any, Dict +from typing_extensions import override from django_pyoidc.providers.keycloak_17 import Keycloak17Provider +from django_pyoidc.providers.provider import ProviderConfig class Keycloak18Provider(Keycloak17Provider): @@ -8,10 +9,11 @@ class Keycloak18Provider(Keycloak17Provider): Provide Django settings/urlconf based on keycloak behaviour (v18) """ - def get_config(self, allowed_hosts) -> Dict[str, Dict[str, Any]]: - result = super().get_config(allowed_hosts) - # logout redirection query string parameter name altered, fromredirect_uri to post_logout_redirect_uri - result[self.op_name][ - "OIDC_LOGOUT_QUERY_STRING_REDIRECT_PARAMETER" - ] = "post_logout_redirect_uri" + @override + def get_default_config(self) -> ProviderConfig: + result = super().get_default_config() + # logout redirection query string parameter name altered, from redirect_uri to post_logout_redirect_uri + result["oidc_logout_query_string_redirect_parameter"] = ( + "post_logout_redirect_uri" + ) return result diff --git a/django_pyoidc/providers/lemonldapng2.py b/django_pyoidc/providers/lemonldapng2.py index fa899cd..8fd6d98 100644 --- a/django_pyoidc/providers/lemonldapng2.py +++ b/django_pyoidc/providers/lemonldapng2.py @@ -1,6 +1,6 @@ -from typing import Any, Dict +from typing_extensions import override -from django_pyoidc.providers.base import Provider +from django_pyoidc.providers.provider import Provider, ProviderConfig class LemonLDAPng2Provider(Provider): @@ -8,10 +8,8 @@ class LemonLDAPng2Provider(Provider): Provide Django settings/urlconf based on LemonLDAP-ng behaviour (v2) """ - def get_config(self, allowed_hosts) -> Dict[str, Dict[str, Any]]: - result = super().get_config(allowed_hosts) - # logout is by default asking for confirmation unless you pass confirm=1 - result[self.op_name]["LOGOUT_QUERY_STRING_EXTRA_PARAMETERS_DICT"] = { - "confirm": 1 - } + @override + def get_default_config(self) -> ProviderConfig: + result = super().get_default_config() + result["oidc_logout_query_string_extra_parameters_dict"] = {"confirm": 1} return result diff --git a/django_pyoidc/providers/provider.py b/django_pyoidc/providers/provider.py new file mode 100644 index 0000000..5452fba --- /dev/null +++ b/django_pyoidc/providers/provider.py @@ -0,0 +1,127 @@ +from typing import Any, Dict, List, Optional, TypedDict + +from django.urls import path + +ProviderConfig = TypedDict( + "ProviderConfig", + { + # most important ---- + "client_id": Optional[str], + "client_secret": Optional[str], + "oidc_cache_provider_metadata": Optional[str], + "oidc_callback_path": str, + # less important --- + "provider_discovery_uri": str, + "oidc_logout_redirect_parameter_name": str, + # Use introspection in API-Bearer mode? + "use_introspection_on_access_tokens": bool, + # Rare usages --- + "client_authn_method": Optional[bool], + "oidc_logout_query_string_redirect_parameter": Optional[str], + "oidc_logout_query_string_extra_parameters_dict": Optional[Dict[str, Any]], + # "client_consumer_config_dict": None, + # some providers may return even more stuff (...) --- + }, +) + + +class Provider: + """ + This is the base `Provider` class that is used to implement common provider configuration patterns. You should not + use this class directly. Instead, you should but subclass it to implement the configuration logic. + """ + + def __init__(self, *args: Any, op_name: str, **kwargs: Any): + """ + Parameters: + op_name (str): the name of the sso provider that you are using + """ + + self.op_name = op_name + + if "provider_discovery_uri" in kwargs: + self.provider_discovery_uri = kwargs["provider_discovery_uri"] + else: + self.provider_discovery_uri = None + + if "oidc_logout_redirect_parameter_name" in kwargs: + self.oidc_logout_redirect_parameter_name = kwargs[ + "oidc_logout_redirect_parameter_name" + ] + else: + self.oidc_logout_redirect_parameter_name = "post_logout_redirect" + + if "oidc_callback_path" in kwargs: + self.oidc_callback_path = kwargs["oidc_callback_path"] + else: + self.oidc_callback_path = "/oidc-callback/" + + def get_default_config(self) -> ProviderConfig: + """Get the default configuration settings for this provider. + + This configuration defaults are used to provide default values for OIDCSettings. + User can override these defaults by playing with OIDCSettings arguments. + """ + return ProviderConfig( + # most important ---- + client_id=None, + client_secret=None, + oidc_cache_provider_metadata=None, + oidc_callback_path=self.oidc_callback_path, + # less important --- + provider_discovery_uri=self.provider_discovery_uri, + oidc_logout_redirect_parameter_name=self.oidc_logout_redirect_parameter_name, + # Use introspection in API-Bearer mode? + use_introspection_on_access_tokens=self.op_name == "drf", + # Rare usages --- + client_authn_method=None, + oidc_logout_query_string_redirect_parameter=None, + oidc_logout_query_string_extra_parameters_dict=None, + ) + + @property + def login_uri_name(self) -> str: + """ + The login viewname (use in :func:`django:django.urls.reverse` django directive for example) of this configuration + """ + return f"{self.op_name}-login" + + @property + def logout_uri_name(self) -> str: + """ + The logout viewname (use in :func:`django:django.urls.reverse` django directive for example) of this configuration + """ + return f"{self.op_name}-logout" + + @property + def callback_uri_name(self) -> str: + """ + The callback viewname (use in :func:`django:django.urls.reverse` django directive for example) of this configuration + """ + return f"{self.op_name}-callback" + + def get_urlpatterns(self) -> List[Any]: + """ + Returns: + A list of urllpatterns to be included using :func:`django:django.urls.include` in your url configuration + """ + from django_pyoidc.views import OIDCCallbackView, OIDCLoginView, OIDCLogoutView + + result = [ + path( + f"{self.oidc_callback_path}", + OIDCCallbackView.as_view(op_name=self.op_name), + name=self.callback_uri_name, + ), + path( + f"{self.oidc_callback_path}-login", + OIDCLoginView.as_view(op_name=self.op_name), + name=self.login_uri_name, + ), + path( + f"{self.oidc_callback_path}-logout", + OIDCLogoutView.as_view(op_name=self.op_name), + name=self.logout_uri_name, + ), + ] + return result diff --git a/django_pyoidc/py.typed b/django_pyoidc/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/django_pyoidc/session.py b/django_pyoidc/session.py index ec00702..35cd1be 100644 --- a/django_pyoidc/session.py +++ b/django_pyoidc/session.py @@ -1,26 +1,28 @@ import base64 import logging -from typing import Dict, List, Union +from typing import Any, Dict, List, MutableMapping, Union -import jsonpickle +import jsonpickle # type: ignore[import-untyped] from Cryptodome.PublicKey.RSA import RsaKey, import_key from django.core.cache import BaseCache, caches -from jsonpickle.handlers import BaseHandler +from jsonpickle.handlers import BaseHandler # type: ignore[import-untyped] from oic.utils.session_backend import SessionBackend from django_pyoidc.models import OIDCSession -from django_pyoidc.utils import get_settings_for_sso_op +from django_pyoidc.settings import OIDCSettings logger = logging.getLogger(__name__) # From https://github.com/alehuo/pyoidc-redis-session-backend/blob/master/pyoidc_redis_session_backend/__init__.py -class RSAKeyHandler(BaseHandler): - def flatten(self, obj: RsaKey, data): +class RSAKeyHandler(BaseHandler): # type: ignore + def flatten( + self, obj: RsaKey, data: MutableMapping[str, Any] + ) -> MutableMapping[str, Any]: data["rsa_key"] = base64.b64encode(obj.export_key()).decode("utf-8") return data - def restore(self, obj): + def restore(self, obj: Dict[str, str]) -> RsaKey: return import_key(base64.b64decode(obj["rsa_key"])) @@ -28,15 +30,14 @@ def restore(self, obj): class OIDCCacheSessionBackendForDjango(SessionBackend): - """Implement Session backend using django cache""" + """Implement Session backend using django cache.""" - def __init__(self, op_name): - self.storage: BaseCache = caches[ - get_settings_for_sso_op(op_name)["CACHE_DJANGO_BACKEND"] - ] - self.op_name = op_name + def __init__(self, opsettings: OIDCSettings): + cache_key: str = opsettings.get("cache_django_backend") # type: ignore[assignment] # we can assume that the configuration is right + self.storage: BaseCache = caches[cache_key] + self.op_name = opsettings.get("op_name") - def get_key(self, key): + def get_key(self, key: str) -> str: return f"{self.op_name}-{key}" def __setitem__(self, key: str, value: Dict[str, Union[str, bool]]) -> None: @@ -55,15 +56,20 @@ def __contains__(self, key: str) -> bool: return self.storage.get(self.get_key(key)) is not None def get_by_uid(self, uid: str) -> List[str]: - result = OIDCSession.objects.filter(uid=uid).values_list("sid", flat=True) + # FIXME : maybe .filter(cache_session_key=uid) ? + result = OIDCSession.objects.filter(cache_session_key=uid).values_list( + "cache_session_key", flat=True + ) logger.debug(f"Fetched the following sid : {result} for {uid=}") - return result + return list(result) def get_by_sub(self, sub: str) -> List[str]: - result = OIDCSession.objects.filter(sub=sub).values_list("sid", flat=True) + result = OIDCSession.objects.filter(sub=sub).values_list( + "cache_session_key", flat=True + ) logger.debug(f"Fetched fhe following sid : {result} for {sub=}") - return result + return list(result) def get(self, attr: str, val: str) -> List[str]: logger.debug(f"Fetch SID for sessions where [{attr}] = {val}") diff --git a/django_pyoidc/settings.py b/django_pyoidc/settings.py new file mode 100644 index 0000000..fd6a97c --- /dev/null +++ b/django_pyoidc/settings.py @@ -0,0 +1,265 @@ +import logging +from functools import lru_cache +from importlib import import_module +from typing import Any, Dict, List, Optional, TypedDict, TypeVar, Union + +from django.conf import settings as django_settings +from django.urls import reverse_lazy + +from django_pyoidc.exceptions import InvalidOIDCConfigurationException + +logger = logging.getLogger(__name__) + +T = TypeVar("T") + +TypedOidcSettings = TypedDict( + "TypedOidcSettings", + { + "cache_django_backend": str, + "oidc_cache_provider_metadata": bool, + "oidc_cache_provider_metadata_ttl": int, + "use_introspection_on_access_tokens": bool, + }, +) + +OidcSettingValue = Union[bool, int, str, List[str]] + + +class OIDCSettings: + + GLOBAL_SETTINGS: TypedOidcSettings = { + "cache_django_backend": "default", + "oidc_cache_provider_metadata": False, + "oidc_cache_provider_metadata_ttl": 120, + "use_introspection_on_access_tokens": True, + } + + def __repr__(self) -> str: + repr_str = f"Oidc Settings for {self.op_name}" + for key, val in self.OP_SETTINGS.items(): + if val is None: + repr_str += f"\n + {key}: None" + else: + repr_str += f"\n + {key}: {val}" + return repr_str + + def __init__(self, op_name: str): + """ + Parameters: + op_name (str): the name of the sso provider that you are using + Other settings are loaded from Django setting DJANGO_PYOIDC, prefixed by the op_name + It may for example contain: + client_secret (str): the OIDC client secret + client_id (str): the OIDC client ID + provider_discovery_uri (str): URL of the SSO server (the .well-known/openid-configuration part is added to this path). + Some providers like the keycloak provider can instead generate this settings by combining 'keycloak_base_uri' (str) and + 'keycloak_realm' (str) settings. + oidc_callback_path (str): the path used to call this library during the login round-trips, the default is "/oidc-callback/". + callback_uri_name (str): the route giving the path for oidc_callback_path that you can use instead of oidc_callback_path + post_logout_redirect_uri (str): the URI where a user should be redirected to on logout success + post_login_uri_failure (str): the URI where a user should be redirected to on login failure + post_login_uri_success (str): the URI a user should be redirected to on login success if no redirection url where provided + login_redirection_requires_https (bool): set to True to disallow redirecting user to non-https uri on login success + login_uris_redirect_allowed_hosts(:obj:`list`) : A list of allowed domains that can be redirected to. + A good idea is to this to use :setting:`ALLOWED_HOSTS `. + See :ref:`Redirect the user after login` for more details. + oidc_cache_provider_metadata (bool): default to False; if True calls to the provider_discovery_uri will be cached, + removing a lot of HTTP traffic. The provider metadata is the same for all your users, so when you havce a lot of + concurrent OIDC related operations this cache can be useful even with a short duration. + oidc_cache_provider_metadata_ttl (int): validity of the metadata cache in seconds, default is 120 (2 minutes). + you can use a long TTL (you know the SSO metadata does not move a lot) or a shorter one (microcache). + cache_django_backend(:obj:`str`, optional): Defaults to 'default'. The cache backend that should be used to store + this provider sessions. Take a look at :ref:`Cache Management` + hook_user_login (str): path to a function hook to be run after successful login. + hook_user_logout (str): path to a function hook to be run during logout(before local session removal and redirection to SSO + remote logout). + hook_validate_access_token (str): path to a function hook to extract access tokens claims from the raw jwt. + this is not used if 'use_introspection_on_access_tokens' is True + use_introspection_on_access_tokens (bool): extract access tokens claims by sending the access token to the sso server on + the introspection route. This delegates validation of the token to the SSO server. If you do not use hook_validate_access_token + or use_introspection_on_access_tokens you will just have the raw jwt for the access token, that you can use to send HTTP queries + on behalf of the user. + """ + + self.op_name = op_name + if self.op_name not in django_settings.DJANGO_PYOIDC: + raise InvalidOIDCConfigurationException( + f"{self.op_name} provider name must be configured in DJANGO_PYOIDC settings." + ) + + op_definition = { + k.lower(): v for k, v in django_settings.DJANGO_PYOIDC[self.op_name].items() + } + + # fix potential bad settings declaration (or aliases) + op_definition = self._fix_settings(op_definition) + if "provider_class" in op_definition: + provider_class = op_definition["provider_class"] + # allow usage of simple names like "keycloak" instead of "django_pyoidc.providers.keycloak" + if "." not in provider_class: + provider_class = f"django_pyoidc.providers.{provider_class}" + else: + provider_class = "django_pyoidc.providers.Provider" + + provider_module_path, provider_class = provider_class.rsplit(".", 1) + provider_real_class = getattr( + import_module(provider_module_path), provider_class + ) + + # This call can fail if required attributes are not set + provider = provider_real_class(op_name=self.op_name, **op_definition) + + # Init a local final operator settings with user given values + self.OP_SETTINGS = op_definition + # get some defaults and variation set by the provider + # For example it could be a newly computed value (provider_discovery_uri from uri and realm for keycloak) + # or some defaults altered + provider_default_settings = provider.get_default_config() + # Then merge the two, so for all settings we have, with priority + # * user defined specific values (if not empty) + # * provider computed or default value (if not empty) + # * then later if a get() is made on this value we'll have the globals applied + for key, val in provider_default_settings.items(): + key = key.lower() + if key not in self.OP_SETTINGS or self.OP_SETTINGS[key] is None: + self.OP_SETTINGS[key] = val + + self.OP_SETTINGS["op_name"] = self.op_name + self._validate_settings() + + def _fix_settings(self, op_definition: Dict[str, Any]) -> Dict[str, Any]: + """Workarounds over specific settings and aliases.""" + + # pyoidc wants the discovery uri WITHOUT the well-known part '.well-known/openid-configuration' + if ( + "provider_discovery_uri" in op_definition + and op_definition["provider_discovery_uri"] + ): + discovery = op_definition["provider_discovery_uri"] + extra_string = ".well-known/openid-configuration" + if discovery.endswith(extra_string): + discovery = discovery[: -len(extra_string)] + op_definition["provider_discovery_uri"] = discovery + extra_string = ".well-known/openid-configuration/" + if discovery.endswith(extra_string): + discovery = discovery[: -len(extra_string)] + op_definition["provider_discovery_uri"] = discovery + extra_string = "/" + if discovery.endswith(extra_string): + discovery = discovery[: -len(extra_string)] + op_definition["provider_discovery_uri"] = discovery + + # Special path manipulations + if "oidc_callback_path" in op_definition: + op_definition["oidc_callback_path"] = op_definition["oidc_callback_path"] + if "callback_uri_name" in op_definition: + op_definition["oidc_callback_path"] = reverse_lazy( + op_definition["callback_uri_name"] + ) + del op_definition["callback_uri_name"] + # else: do not set defaults. + # The Provider objet will define a defaut callback path if not set. + + # allow simpler names + # * "logout_redirect" for "post_logout_redirect_uri" + # * "failure_redirect" for "post_login_uri_failure" + # * "success_redirect" for "post_login_uri_success" + # * "redirect_requires_https" for "login_redirection_requires_https" + if "post_logout_redirect_uri" not in op_definition: + if "logout_redirect" in op_definition: + op_definition["post_logout_redirect_uri"] = op_definition[ + "logout_redirect" + ] + del op_definition["logout_redirect"] + else: + op_definition["post_logout_redirect_uri"] = "/" + + if "post_login_uri_failure" not in op_definition: + if "failure_redirect" in op_definition: + op_definition["post_login_uri_failure"] = op_definition[ + "failure_redirect" + ] + del op_definition["failure_redirect"] + else: + op_definition["post_login_uri_failure"] = "/" + + if "post_login_uri_success" not in op_definition: + if "success_redirect" in op_definition: + op_definition["post_login_uri_success"] = op_definition[ + "success_redirect" + ] + del op_definition["success_redirect"] + else: + op_definition["post_login_uri_success"] = "/" + + if "login_redirection_requires_https" not in op_definition: + if "redirect_requires_https" in op_definition: + op_definition["login_redirection_requires_https"] = op_definition[ + "redirect_requires_https" + ] + del op_definition["redirect_requires_https"] + else: + op_definition["login_redirection_requires_https"] = True + + return op_definition + + def _validate_settings(self) -> None: + if ( + "hook_validate_access_token" in self.OP_SETTINGS + and "use_introspection_on_access_tokens" in self.OP_SETTINGS + and self.OP_SETTINGS["use_introspection_on_access_tokens"] + ): + raise InvalidOIDCConfigurationException( + "You cannot define hook_validate_access_token if you use use_introspection_on_access_tokens." + ) + + # client_id is required + if "client_id" not in self.OP_SETTINGS or self.OP_SETTINGS["client_id"] is None: + raise InvalidOIDCConfigurationException( + f"Provider definition does not contain any 'client_id' entry. Check your DJANGO_PYOIDC['{self.op_name}'] settings." + ) + # we do not enforce client_secret (in case someone wrongly use a public client) + if ( + "client_secret" not in self.OP_SETTINGS + or self.OP_SETTINGS["client_secret"] is None + ): + logger.warning( + f"OIDC settings for {self.op_name} has no client_secret. You are maybe using a public OIDC client, you should not." + ) + + def set(self, key: str, value: Optional[OidcSettingValue] = None) -> None: + self.OP_SETTINGS[key] = value + + def get( + self, name: str, default: Optional[Union[OidcSettingValue, T]] = None + ) -> Optional[Union[OidcSettingValue, T]]: + "Get attr value for op or global, given last arg is the default value if None." + res = self._get_attr(name) + if res is None: + return default + return res + + def _get_attr(self, key: str) -> Optional[OidcSettingValue]: + """Retrieve attr, if op value is None a check on globals is made. + + Note that op value is already a computation of provider defaults and user defined settings. + """ + key = key.lower() + if key in self.OP_SETTINGS and self.OP_SETTINGS[key] is not None: + return self.OP_SETTINGS[key] + else: + if key in self.GLOBAL_SETTINGS.keys(): + return self.GLOBAL_SETTINGS[key] # type: ignore[literal-required] # we check that the key is available the line before + return None + + +class OIDCSettingsFactory: + @classmethod + @lru_cache(maxsize=10) + def get(cls, op_name: str) -> OIDCSettings: + """ + lru_cache will return a singleton for each argument value. + So this is a memoized function. + """ + settings = OIDCSettings(op_name=op_name) + return settings diff --git a/django_pyoidc/test_paul.py b/django_pyoidc/test_paul.py new file mode 100644 index 0000000..bdf062d --- /dev/null +++ b/django_pyoidc/test_paul.py @@ -0,0 +1,84 @@ +# type: ignore +import datetime + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AbstractUser +from rest_framework import authentication, exceptions + +from django_pyoidc.utils import OIDCCacheBackendForDjango +from django_pyoidc.views import OIDClient + + +class BaseOIDCAuthentication(authentication.BaseAuthentication): + def __init__(self): + # fixme : no multi-provider support here + self.op_name = "default" + self.general_cache_backend = OIDCCacheBackendForDjango(self.op_name) + self.client = OIDClient(self.op_name) + + def authenticate(self, request): + token = self.get_access_token(request) + if token is None: + return + cache_key = self.general_cache_backend.generate_hashed_cache_key(token) + try: + access_token_claims = self.general_cache_backend["cache_key"] + except KeyError: + access_token_claims = self._introspect_access_token(token) + if "active" not in access_token_claims: + raise exceptions.AuthenticationFailed( + "Invalid identity provider reponse" + ) + if not access_token_claims["active"]: + raise exceptions.AuthenticationFailed("Account disabled") + print(f"{access_token_claims=}") + print(f"{token=}") + # store it in cache + current = datetime.datetime.now().strftime("%s") + if "exp" not in access_token_claims: + raise exceptions.AuthenticationFailed( + "No expiry set on the access token." + ) + access_token_expiry = access_token_claims["exp"] + exp = int(access_token_expiry) - int(current) + self.general_cache_backend.set(cache_key, access_token_claims, exp) + return self.get_user(token, access_token_claims), None + + def _introspect_access_token(self, token): + request_args = { + "token": token, + "token_type_hint": "access_token", + } + client_auth_method = self.client.consumer.registration_response.get( + "introspection_endpoint_auth_method", "client_secret_basic" + ) + introspection = self.client.client_extension.do_token_introspection( + request_args=request_args, + authn_method=client_auth_method, + endpoint=self.client.consumer.introspection_endpoint, + # http_args={"headers" : {"content-type":"application/x-www-form-urlencoded"}} + ) + print(f"{introspection=}") + return introspection.to_dict() + + def get_access_token(self, request): + # fixme : hardcoded header key in the following function call + header = authentication.get_authorization_header(request) + if not header: + return None + header = header.decode(authentication.HTTP_HEADER_ENCODING) + print(f"{header=}") + return header + + def get_user(self, token: str, access_token_claims) -> AbstractUser: + raise NotImplementedError( + f"Do no use {self.__class__.__name__} directly : inherit from it" + f"and override get_user()" + ) + + +class DefaultOIDCAuthentication(BaseOIDCAuthentication): + def get_user(self, token: str, access_token_claims) -> AbstractUser: + User = get_user_model() + user, _ = User.objects.get_or_create(id=token["sub"]) + return user diff --git a/django_pyoidc/utils.py b/django_pyoidc/utils.py index c743db9..f3a0167 100644 --- a/django_pyoidc/utils.py +++ b/django_pyoidc/utils.py @@ -1,28 +1,19 @@ import hashlib import logging from importlib import import_module -from typing import Any, Dict, Union +from typing import Any, Dict, Mapping, MutableMapping, Optional, Union -from django.conf import settings from django.core.cache import BaseCache, caches from django_pyoidc.exceptions import ClaimNotFoundError +from django_pyoidc.settings import OIDCSettings logger = logging.getLogger(__name__) -def get_setting_for_sso_op(op_name: str, key: str, default=None): - if key in settings.DJANGO_PYOIDC[op_name]: - return settings.DJANGO_PYOIDC[op_name][key] - else: - return default - - -def get_settings_for_sso_op(op_name: str): - return settings.DJANGO_PYOIDC[op_name] - - -def import_object(path, def_name): +def import_object( + path: str, def_name: str +) -> Any: # FIXME : this function is hard to type correctly try: mod, cls = path.split(":", 1) except ValueError: @@ -32,7 +23,9 @@ def import_object(path, def_name): return getattr(import_module(mod), cls) -def extract_claim_from_tokens(claim: str, tokens: dict, raise_exception=True) -> Any: +def extract_claim_from_tokens( + claim: str, tokens: Dict[str, Any], raise_exception: bool = True +) -> Any: """Given a dictionnary of tokens claims, extract the given claim. This function will seek in "info_token_claims", then "id_token_claims" @@ -53,7 +46,7 @@ def extract_claim_from_tokens(claim: str, tokens: dict, raise_exception=True) -> return value -def check_audience(client_id: str, access_token_claims: dict) -> bool: +def check_audience(client_id: str, access_token_claims: Dict[str, Any]) -> bool: """Verify that the current client_id is present in 'aud' claim. Audences are stored in 'aud' claim. @@ -79,15 +72,13 @@ def check_audience(client_id: str, access_token_claims: dict) -> bool: class OIDCCacheBackendForDjango: """Implement General cache for OIDC using django cache""" - def __init__(self, op_name): - self.op_name = op_name - self.enabled = get_setting_for_sso_op( - self.op_name, "OIDC_CACHE_PROVIDER_METADATA", False - ) + def __init__(self, opsettings: OIDCSettings): + self.op_name = opsettings.get("op_name") + + self.enabled = opsettings.get("oidc_cache_provider_metadata", False) if self.enabled: - self.storage: BaseCache = caches[ - get_setting_for_sso_op(self.op_name, "CACHE_DJANGO_BACKEND") - ] + cache_key: str = opsettings.get("cache_django_backend") # type: ignore[assignment] # we can assume that the configuration is right + self.storage: BaseCache = caches[cache_key] def generate_hashed_cache_key(self, value: str) -> str: h = hashlib.new("sha256") @@ -95,17 +86,21 @@ def generate_hashed_cache_key(self, value: str) -> str: cache_key = h.hexdigest() return cache_key - def clear(self): - return self.storage.clear() + def clear(self) -> Optional[int]: + if self.enabled: + self.storage.clear() + return None + else: + return 0 - def get_key(self, key): + def get_key(self, key: str) -> str: return f"oidc-{self.op_name}-{key}" - def set(self, key: str, value: Dict[str, Union[str, bool]], expiry: int) -> None: + def set(self, key: str, value: Mapping[str, Union[str, bool]], expiry: int) -> None: if self.enabled: self.storage.set(self.get_key(key), value, expiry) - def __getitem__(self, key: str) -> Dict[str, Union[str, bool]]: + def __getitem__(self, key: str) -> MutableMapping[str, Union[str, bool]]: if self.enabled: data = self.storage.get(self.get_key(key)) if data is None: diff --git a/django_pyoidc/views.py b/django_pyoidc/views.py index 145fbf6..e0c97a6 100644 --- a/django_pyoidc/views.py +++ b/django_pyoidc/views.py @@ -1,11 +1,13 @@ import logging from importlib import import_module +from typing import Any, Dict, Optional, TypeVar, Union # import oic from django.conf import settings from django.contrib import auth, messages +from django.contrib.auth.models import AbstractUser from django.core.exceptions import PermissionDenied, SuspiciousOperation -from django.http import HttpResponse +from django.http import HttpRequest, HttpResponse from django.shortcuts import redirect, resolve_url from django.utils.decorators import method_decorator from django.utils.http import url_has_allowed_host_and_scheme @@ -13,43 +15,64 @@ from django.views.decorators.csrf import csrf_exempt from jwt import JWT from jwt.exceptions import JWTDecodeError +from oic.utils.http_util import BadRequest from django_pyoidc.client import OIDCClient from django_pyoidc.engine import OIDCEngine from django_pyoidc.exceptions import InvalidSIDException from django_pyoidc.models import OIDCSession -from django_pyoidc.utils import get_setting_for_sso_op, import_object +from django_pyoidc.settings import OIDCSettings, OIDCSettingsFactory, OidcSettingValue +from django_pyoidc.utils import import_object SessionStore = import_module(settings.SESSION_ENGINE).SessionStore logger = logging.getLogger(__name__) +T = TypeVar("T") + class OIDCMixin: - op_name = None + op_name: str = "" + opsettings: OIDCSettings class OIDCView(View, OIDCMixin): - def get(self, *args, **kwargs): + def __init__(self, **kwargs: Any) -> None: + for key, value in kwargs.items(): + setattr(self, key, value) + if key == "op_name": + self.opsettings = OIDCSettingsFactory.get(self.op_name) + self.allowed_hosts = self.get_setting( + "login_uris_redirect_allowed_hosts" + ) + + def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None: + super().setup(request, *args, **kwargs) if self.op_name is None: raise Exception( "Please set 'op_name' when initializing with 'as_view()'\nFor example : OIDCView.as_view(op_name='example')" ) # FIXME - def get_setting(self, name, default=None): - return get_setting_for_sso_op(self.op_name, name, default) + def get_setting( + self, name: str, default: Optional[T] = None + ) -> Optional[Union[T, OidcSettingValue]]: + return self.opsettings.get(name, default) - def call_function(self, setting_name, *args, **kwargs): - function_path = get_setting_for_sso_op(self.op_name, setting_name) - if function_path: + def call_function(self, setting_func_name: str, *args: Any, **kwargs: Any) -> Any: + function_path: Optional[str] = self.opsettings.get(setting_func_name) # type: ignore[assignment] # we can assume that the configuration is right + if function_path is not None: func = import_object(function_path, "") return func(*args, **kwargs) - def call_callback_function(self, request, user): + def call_user_login_callback_function( + self, request: HttpRequest, user: AbstractUser + ) -> Any: logger.debug("OIDC, Calling user hook on login") - self.call_function("HOOK_USER_LOGIN", request, user) + self.call_function("hook_user_login", request, user) - def call_logout_function(self, user_request, logout_request_args): + def call_logout_function( + self, user_request: HttpRequest, logout_request_args: Dict[str, Any] + ) -> Any: """Function called right before local session removal and before final redirection to the SSO server. Parameters: @@ -60,20 +83,21 @@ def call_logout_function(self, user_request, logout_request_args): Returns: dict: extra query string arguments to add to the SSO disconnection url """ - return self.call_function("HOOK_USER_LOGOUT", user_request, logout_request_args) + return self.call_function("hook_user_logout", user_request, logout_request_args) - def get_next_url(self, request, redirect_field_name): + def get_next_url( + self, request: HttpRequest, redirect_field_name: str + ) -> Optional[str]: """ Adapted from https://github.com/mozilla/mozilla-django-oidc/blob/71e4af8283a10aa51234de705d34cd298e927f97/mozilla_django_oidc/views.py#L132 """ next_url = request.GET.get(redirect_field_name) - # print(f"{next_url=}") if next_url: is_safe = url_has_allowed_host_and_scheme( next_url, - allowed_hosts=self.get_setting("LOGIN_URIS_REDIRECT_ALLOWED_HOSTS"), - require_https=self.get_setting( - "LOGIN_REDIRECTION_REQUIRES_HTTPS", True + allowed_hosts=self.allowed_hosts, # type: ignore[arg-type] # let's just assume that this settings is correctly set + require_https=self.get_setting( # type: ignore[arg-type] # We can reasonably assume that this setting is a bool + "login_redirection_requires_https", True ), ) if is_safe: @@ -86,44 +110,52 @@ class OIDCLoginView(OIDCView): When receiving a GET request, this views redirects the user to the SSO identified by `op_name`. This view is named ``-login`` if you used ``get_urlpatterns``. - This view supports the *http query parameter* ``next`` (ie ``?next=http://...``) to specify which url the user should be redirected to on success. - The redirection behaviour is configured with the following settings : + First, an OIDC redirection is made to the sso, with a callback (redirection) set to a local url defined by the setting: + + * :ref:`oidc_callback_path` local path to be redirected after authentication on the sso, to finalize the local auth. - * :ref:`LOGIN_REDIRECTION_REQUIRES_HTTPS` controls if non https URIs are accepted. - * :ref:`LOGIN_URIS_REDIRECT_ALLOWED_HOSTS` controls if which hosts the user can be redirected to. - * :ref:`POST_LOGIN_URI_SUCCESS` defines the redirection URI when no 'next' redirect uri were provided in the HTTP request. + After this somewhat internal redirection where the local auth is validated and the session created, a final redirection + will be made. + The final redirection behaviour is configured with the following settings : + + * :ref:`login_redirection_requires_https` controls if non https URIs are accepted. + * :ref:`login_uris_redirect_allowed_hosts` controls which hosts the user can be redirected to. + * :ref:`post_login_uri_success` defines the redirection URI when no 'next' redirect uri were provided in the HTTP request. """ http_method_names = ["get"] - def get(self, request, *args, **kwargs): - super().get(request, *args, **kwargs) + def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + + sid = request.session.get("oidc_sid") + if sid: + client = OIDCClient(self.op_name, session_id=sid) + else: + client = OIDCClient(self.op_name) - client = OIDCClient(self.op_name) client.consumer.consumer_config["authz_page"] = self.get_setting( - "OIDC_CALLBACK_PATH" + "oidc_callback_path" ) - redirect_uri = self.get_next_url(request, "next") + next_redirect_uri = self.get_next_url(request, "next") - if not redirect_uri: - redirect_uri = str( + if not next_redirect_uri: + next_redirect_uri = str( self.get_setting( - "POST_LOGIN_URI_SUCCESS", request.build_absolute_uri("/") + "post_login_uri_success", request.build_absolute_uri("/") ) ) - request.session["oidc_login_next"] = redirect_uri + request.session["oidc_login_next"] = next_redirect_uri - sid, location = client.consumer.begin( + sid, location = client.consumer.begin( # type: ignore[no-untyped-call] # oic package is untyped scope=["openid"], response_type="code", use_nonce=True, path=self.request.build_absolute_uri("/"), ) request.session["oidc_sid"] = sid - return redirect(location) @@ -134,25 +166,25 @@ class OIDCLogoutView(OIDCView): It supports both ``GET`` and ``POST`` http methods. - The response is a redirection to the SSO logout endpoint, if a provider configuration :ref:`POST_LOGOUT_REDIRECT_URI` exists it as used as + The response is a redirection to the SSO logout endpoint, if a provider configuration :ref:`post_logout_redirect_uri` exists it as used as post logout redirection argument on the SSO redirection link. """ http_method_names = ["get", "post"] - def post_logout_url(self, request): + def post_logout_url(self, request: HttpRequest) -> str: """Return the post logout url defined in settings.""" return str( self.get_setting( - "POST_LOGOUT_REDIRECT_URI", request.build_absolute_uri("/") + "post_logout_redirect_uri", request.build_absolute_uri("/") ) ) - def get(self, request): + def get(self, request: HttpRequest) -> HttpResponse: return self.post(request) - def post(self, request): + def post(self, request: HttpRequest) -> HttpResponse: """Log out the user.""" url = self.post_logout_url(request) # If this url is not already an absolute url @@ -168,26 +200,27 @@ def post(self, request): client = None sid = request.session.get("oidc_sid") - redirect_arg_name = self.get_setting( + redirect_arg_name: str = self.get_setting( "LOGOUT_QUERY_STRING_REDIRECT_PARAMETER", "post_logout_redirect_uri", - ) + ) # type: ignore[assignment] # we can assume that the configuration is right request_args = { redirect_arg_name: post_logout_url, - "client_id": self.get_setting("OIDC_CLIENT_ID"), + "client_id": self.get_setting("client_id"), } # Allow some more parameters for some actors - extra_logout_args = self.get_setting( - "LOGOUT_QUERY_STRING_EXTRA_PARAMETERS_DICT", + extra_logout_args: Dict[str, Any] = self.get_setting( # type: ignore[assignment] # we can assume that the configuration is right + "oidc_logout_query_string_extra_parameters_dict", {}, ) request_args.update(extra_logout_args) - if sid: try: client = OIDCClient(self.op_name, session_id=sid) - except Exception as e: # FIXME : Finer exception handling (KeyError,ParseError,CommunicationError) + except ( + Exception + ) as e: # FIXME : Finer exception handling (KeyError,ParseError,CommunicationError) logger.error("OIDC Logout call error when loading OIDC state: ") logger.exception(e) @@ -238,7 +271,7 @@ class OIDCBackChannelLogoutView(OIDCView): http_method_names = ["post"] - def logout_sessions_by_sid(self, client: OIDCClient, sid: str, body): + def logout_sessions_by_sid(self, client: OIDCClient, sid: str, body: str) -> None: validated_sid = client.consumer.backchannel_logout( request_args={"logout_token": body} ) @@ -248,25 +281,25 @@ def logout_sessions_by_sid(self, client: OIDCClient, sid: str, body): for session in sessions: self._logout_session(session) - def logout_sessions_by_sub(self, client: OIDCClient, sub: str, body): + def logout_sessions_by_sub(self, client: OIDCClient, sub: str, body: str) -> None: sessions = OIDCSession.objects.filter(sub=sub) for session in sessions: client.consumer.backchannel_logout(request_args={"logout_token": body}) self._logout_session(session) - def _logout_session(self, session: OIDCSession): + def _logout_session(self, session: OIDCSession) -> None: s = SessionStore() s.delete(session.cache_session_key) session.delete() - logger.debug(f"Backchannel logout request received and validated for {session}") + logger.info(f"Backchannel logout request received and validated for {session}") - def post(self, request): + def post(self, request: HttpRequest) -> HttpResponse: if request.content_type != "application/x-www-form-urlencoded": return HttpResponse("", status=415) result = HttpResponse("") try: body = request.body.decode("utf-8")[13:] - decoded = JWT().decode(body, do_verify=False) + decoded = JWT().decode(body, do_verify=False) # type: ignore[no-untyped-call] # jwt.JWT is not typed yet sid = decoded.get("sid") sub = decoded.get("sub") @@ -305,48 +338,52 @@ class OIDCCallbackView(OIDCView): http_method_names = ["get"] - def __init__(self, **kwargs): + def __init__(self, **kwargs: Any): super().__init__(**kwargs) - self.engine = OIDCEngine(self.op_name) + self.engine = OIDCEngine(self.opsettings) - def success_url(self, request): + def success_url(self, request: HttpRequest) -> str: # Pull the next url from the session or settings --we don't need to # sanitize here because it should already have been sanitized. next_url = self.request.session.get("oidc_login_next", None) return next_url or resolve_url( - self.get_setting("POST_LOGIN_URI_SUCCESS", request.build_absolute_uri("/")) + self.get_setting("post_login_uri_success", request.build_absolute_uri("/")) # type: ignore[arg-type] # we can assume that this setting is correctly configured ) - def login_failure(self, request): + def login_failure(self, request: HttpRequest) -> HttpResponse: return redirect( str( self.get_setting( - "POST_LOGIN_URI_FAILURE", request.build_absolute_uri("/") + "post_login_uri_failure", request.build_absolute_uri("/") ) ) ) - def _introspect_access_token(self, access_token_jwt): - """ - Perform a cached intropesction call to extract claims from encoded jwt of the access_token - """ - return - - def get(self, request, *args, **kwargs): - super().get(request, *args, **kwargs) + def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: try: if "oidc_sid" in request.session: self.client = OIDCClient( self.op_name, session_id=request.session["oidc_sid"] ) - aresp, atr, idt = self.client.consumer.parse_authz( + parsing_result = self.client.consumer.parse_authz( query=request.GET.urlencode() ) + if isinstance(parsing_result, BadRequest): + logger.error( + "OIDC login process failure; cannot parse OIDC response" + ) + return self.login_failure(request) + + aresp, atr, idt = parsing_result + + if aresp is None: + logger.error("OIDC login process failure; empty OIDC response") + return self.login_failure(request) if aresp["state"] == request.session["oidc_sid"]: state = aresp["state"] - session_state = aresp.get("session_state") + session_state = aresp.get("session_state") # type: ignore[no-untyped-call] # oic is untyped yet # pyoidc will make the next steps in OIDC login protocol try: @@ -362,7 +399,7 @@ def get(self, request, *args, **kwargs): # Collect data from userinfo endpoint try: - userinfo = self.client.consumer.get_user_info(state=state) + userinfo = self.client.consumer.get_user_info(state=state) # type: ignore[no-untyped-call] # oic is untyped yet except Exception as e: logger.exception(e) logger.error( @@ -371,11 +408,12 @@ def get(self, request, *args, **kwargs): return self.login_failure(request) # TODO: add a setting to allow/disallow session storage of the tokens - # FIXME: token introspection for access_token deserialization? access_token_jwt = ( tokens["access_token"] if "access_token" in tokens else None ) + # this will call token instrospection or user defined validator + # or return None access_token_claims = self.engine.introspect_access_token( access_token_jwt, self.client ) @@ -387,15 +425,18 @@ def get(self, request, *args, **kwargs): # tokens["id_token_jwt"] if "id_token_jwt" in tokens else None # ) userinfo_claims = userinfo.to_dict() - + tokens = { + "info_token_claims": userinfo_claims, + "access_token_jwt": access_token_jwt, + "access_token_claims": access_token_claims, + "id_token_claims": id_token_claims, + } + # simplify check code, if any dict is None remove the entry + filtered_tokens = {k: v for k, v in tokens.items() if v is not None} # Call user hook user = self.engine.call_get_user_function( - tokens={ - "info_token_claims": userinfo_claims, - "access_token_jwt": access_token_jwt, - "access_token_claims": access_token_claims, - "id_token_claims": id_token_claims, - } + tokens=filtered_tokens, + client=self.client, ) if not user or not user.is_authenticated: @@ -408,10 +449,10 @@ def get(self, request, *args, **kwargs): OIDCSession.objects.create( state=state, sub=userinfo["sub"], - cache_session_key=request.session.session_key, + cache_session_key=request.session.session_key, # type: ignore[misc] # we call auth.login right before, so session_key is set to a value session_state=session_state, ) - self.call_callback_function(request, user) + self.call_user_login_callback_function(request, user) redir = self.success_url(request) return redirect(redir) else: diff --git a/docs/how-to.rst b/docs/how-to.rst index e3e0f00..548b5b1 100644 --- a/docs/how-to.rst +++ b/docs/how-to.rst @@ -36,7 +36,7 @@ If you used a provider, the best way to achieve that is by modifying the configu .. code-block:: python DJANGO_PYOIDC = { - **my_oidc_provider.get_config(allowed_hosts=["myhost"]), + FIXME **my_oidc_provider.get_config(login_uris_redirect_allowed_hosts=["myhost"]), } DJANGO_PYOIDC[my_oidc_provider.op_name]["HOOK_USER_LOGIN"] = ".oidc:login_function" # <- my_app is a placeholder, alter it for your root module @@ -57,7 +57,7 @@ Customize how token data is mapped to User attributes By default, this library only uses the **email** field in a userinfo token to retrieve/create users. -However you can implement more complex behaviour by specifying a :ref:`HOOK_GET_USER` in your provider +However you can implement more complex behaviour by specifying a :ref:`hook_get_user` in your provider configuration. In this guide we will look at the ``groups`` attribute in a userinfo token and set the :attr:`is_staff ` attribute depending on the value. @@ -115,7 +115,7 @@ We can see that here we want to lookup the ``groups`` key and test if ``admins`` return user -To have this function called instead of the default one, you need to modify your settings so that :ref:`HOOK_GET_USER` points to the function that we just wrote. +To have this function called instead of the default one, you need to modify your settings so that :ref:`hook_get_user` points to the function that we just wrote. The value of this setting should be : ``.oidc:login_function`` (see :ref:`Hook settings` for more information on this syntax). @@ -126,10 +126,10 @@ Using a provider, edith your configuration like this : .. code-block:: python DJANGO_PYOIDC = { - **my_oidc_provider.get_config(allowed_hosts=["myhost"]), + FIXME **my_oidc_provider.get_config(login_uris_redirect_allowed_hosts=["myhost"]), } - DJANGO_PYOIDC[my_oidc_provider.op_name]["HOOK_GET_USER"] = ".oidc:get_user" # <- my_app is a placeholder, alter it for your root module + DJANGO_PYOIDC[my_oidc_provider.op_name]["hook_get_user"] = ".oidc:get_user" # <- my_app is a placeholder, alter it for your root module @@ -138,7 +138,7 @@ Add application-wide access control rules based on audiences Open ID Connect supports a system of audience which can be used to indicate the list of applications a user has access to. -In order to implement access control based on the audience, you need to hook the :ref:`HOOK_GET_USER` to add your own logic. +In order to implement access control based on the audience, you need to hook the :ref:`hook_get_user` to add your own logic. In this guide, we will start from what we did in :ref:`Customize how token data is mapped to User attributes` and add audience based access control. @@ -159,7 +159,7 @@ TODO: audience check outside of get_user, settings based audiences = id_token["aud"] # Perform audience check - if settings.DJANGO_PYOIDC["keycloak"]["OIDC_CLIENT_ID"] not in audiences: + if settings.DJANGO_PYOIDC["keycloak"]["client_id"] not in audiences: raise PermissionDenied("You do not have access to this application") User = get_user_model() @@ -179,7 +179,7 @@ Django provides a rich authentication system that handles groups and permissions In this guide we will map Keycloak groups to Django groups. This allows one to manage group level permissions using Django system, while keeping all the advantages of an Identity Provider to manage a user base. -In order to add users to groups on login, you need to hook the :ref:`HOOK_GET_USER`. +In order to add users to groups on login, you need to hook the :ref:`hook_get_user`. We will start from what we did in :ref:`Customize how token data is mapped to User attributes` and add group management. @@ -242,7 +242,7 @@ Here is an example of a login button redirecting the user to the page named "pro query_string = urllib.parse.urlencode({"next": reverse("profile")}) return redirect(f"{base_url}?{query_string}") -However you will need to tweak the settings according to your use-case. You should take a look at :ref:`LOGIN_REDIRECTION_REQUIRES_HTTPS` and :ref:`LOGIN_URIS_REDIRECT_ALLOWED_HOSTS`. +However you will need to tweak the settings according to your use-case. You should take a look at :ref:`login_redirection_requires_https` and :ref:`login_uris_redirect_allowed_hosts`. TODO: RedirectDemo now exists, where do I connect it? @@ -259,10 +259,10 @@ In a multi-provider setup, the settings look like this : DJANGO_PYOIDC = { 'oidc_provider_name_1' : { - 'OIDC_CLIENT_ID' : '' # <- provider 1 settings here + 'client_id' : '' # <- provider 1 settings here } 'oidc_provider_name_2' : { - 'OIDC_CLIENT_ID' : '' # <- provider 2 settings here + 'client_id' : '' # <- provider 2 settings here } } @@ -274,8 +274,8 @@ If you are using our premade providers configuration, your ``settings.py`` will from .oidc_providers import oidc_provider_1, oidc_provider_2 DJANGO_PYOIDC = { - **oidc_provider_1.get_config(allowed_hosts=["app.local:8082"]), - **oidc_provider_2.get_config(allowed_hosts=["app.local:8082"]), + FIXME **oidc_provider_1.get_config(login_uris_redirect_allowed_hosts=["app.local:8082"]), + FIXME **oidc_provider_2.get_config(login_uris_redirect_allowed_hosts=["app.local:8082"]), } Then you have to include all your provider url configuration in your ``urlpatterns``. Since view names includes the identity provider name, @@ -296,5 +296,5 @@ Here is an example of such a configuration : You can then use those view names to redirect a user to one or the other provider. TODO: what are the 'different' view names here? -Since settings are local to a provider, you can also provide different :ref:`HOOK_GET_USER` for each to implement custom +Since settings are local to a provider, you can also provide different :ref:`hook_get_user` for each to implement custom behaviours based on which identity provider a user is coming from. diff --git a/docs/tutorial.rst b/docs/tutorial.rst index fa9927e..ad4346d 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -174,7 +174,7 @@ If you check this json you can extract paths from this file. For example the fir ``http://keycloak.local:8080/auth/realms/Demo``. Everything before the ``realms`` keyword is the ``keycloak_base_uri`` that this library needs, the word following ``realms/`` is the ``keycloak_realm`` parameter. -Then you can use the methods :py:meth:`get_config() ` and +FIXME Then you can use the methods :py:meth:`get_config() ` and :py:meth:`get_urlpatterns() ` to easily generate the settings and url configuration for your provider. @@ -186,10 +186,10 @@ Edit your django configuration to add your configuration to ``DJANGO_PYOIDC`` se from .oidc import my_oidc_provider DJANGO_PYOIDC = { - **my_oidc_provider.get_config(allowed_hosts=["app.local:8082"]), + FIXME **my_oidc_provider.get_config(login_uris_redirect_allowed_hosts=["app.local:8082"]), } -TODO: remove allowed_hosts from this step, should be in settings +TODO: remove login_uris_redirect_allowed_hosts from this step, should be in settings Generate the URLs ***************** diff --git a/docs/user.rst b/docs/user.rst index 0a071e9..c34e666 100644 --- a/docs/user.rst +++ b/docs/user.rst @@ -25,18 +25,18 @@ You should define them as a nested dictionary. The key to this dictionary is cal } } -POST_LOGIN_URI_FAILURE +post_login_uri_failure *********** This setting configures where a user is redirected on login failure, defaults to Django base url. -POST_LOGIN_URI_SUCCESS +post_login_uri_success ******************* This setting configures the default redirection URI on login success, defaults to Django base url. -POST_LOGOUT_REDIRECT_URI +post_logout_redirect_uri ********** This setting configures where a user is redirected after successful SSO logout, defaults to Django base url. @@ -51,35 +51,35 @@ URI_CONFIG This settings configures the path to your OIDC configuration. **TODO : example**. -OIDC_CALLBACK_PATH +oidc_callback_path ************* This setting is used to reference the callback view that should be provided as the ``redirect_uri`` parameter of the *Authorization Code Flow*. -LOGIN_REDIRECTION_REQUIRES_HTTPS +login_redirection_requires_https *********************** This setting configures if dynamic login redirection URI must have the ``https`` scheme. -LOGIN_URIS_REDIRECT_ALLOWED_HOSTS +login_uris_redirect_allowed_hosts ********************** This setting configures the list of allowed host in dynamic URI redirections. -OIDC_CLIENT_SECRET +client_secret ************* This setting configures the client secret used to authentify your application with an identity provider. -OIDC_CLIENT_ID +client_id ********* This setting configures the client id used to authentify your application with an identity provider. -CACHE_DJANGO_BACKEND +cache_django_backend ************* -This setting configures the cache backend that is used to store sessions details. +This setting configures the cache backend that is used to store OIDC sessions details. You can read more about *Cache Management* :ref:`here `. Hook settings @@ -123,7 +123,7 @@ This function takes two arguments : Since the user wasn't logged in, it is not yet attached to the request instance at this stage. As such trying to access ``request.user`` will return an unauthenticated user. -HOOK_GET_USER +hook_get_user ************* Calls the provided function on user login. It takes two arguments : @@ -176,7 +176,7 @@ Providers classes allows the final user to configure their project without havin Each provider implements the configuration logic and provides mostly two methods : -* One to generate a configuration dict to be inserted in the ``DJANGO_PYOIDC`` value of your django settings : :py:meth:`get_config() ` +* One to generate a configuration dict to be inserted in the ``DJANGO_PYOIDC`` value of your django settings FIXME : :py:meth:`get_config() ` * One to generate urls to be :func:`included ` in your url configuration : :py:meth:`get_urlpatterns() ` .. autoclass:: django_pyoidc.providers.KeycloakProvider diff --git a/mypy_settings.py b/mypy_settings.py new file mode 100644 index 0000000..905ab6c --- /dev/null +++ b/mypy_settings.py @@ -0,0 +1,10 @@ +from typing import Any, Dict + +DJANGO_PYOIDC: Dict[str, Dict[str, Any]] = {} + +INSTALLED_APPS = [ + "django.contrib.contenttypes", + "django.contrib.auth", + "django.contrib.sessions", + "django_pyoidc", +] diff --git a/pyproject.toml b/pyproject.toml index 21c747b..ef45555 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,5 @@ -license = {file = "LICENSE"} -readme = "README.md" # Optional - [project] +dynamic = ["version"] name="django-pyoidc" authors=[ {name="Régis Leroy (Makina Corpus)", email="django_pyoidc@makina-corpus.net"}, @@ -15,26 +13,72 @@ classifiers=["Topic :: Utilities", "Framework :: Django", "Development Status :: 3 - Alpha", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Security" ] description="Authenticate your users using OpenID Connect (OIDC)" -requires-python=">=3.7" -dynamic = ["version", "dependencies", "optional-dependencies"] -keywords=["openid","oidc","django","sso","single-sign-on", "openid-connect"] +requires-python=">=3.8" +keywords=["openid","oidc","django","sso","single-sign-on", "openid-connect", "authentication"] readme="README.md" +license="GPL-3.0-only" +dependencies = [ + "oic==1.7.0", + "django>=3.2", + "jsonpickle", + "jwt", + "pycryptodomex", +] +license-files = ['LICENSE'] [project.urls] -repository="https://gitlab.makina-corpus.net/pfl/django-pyoidc" +repository="https://github.com/makinacorpus/django_pyoidc" + +[project.optional-dependencies] +drf = ['djangorestframework', 'drf-spectacular'] [build-system] # These are the assumed default build requirements from pip: # https://pip.pypa.io/en/stable/reference/pip/#pep-517-and-518-support -requires = ["setuptools>=61.0.0", "wheel"] -build-backend = "setuptools.build_meta" +requires = ["hatchling", "versioningit"] +build-backend = "hatchling.build" + +[tool.mypy] +plugins = ["mypy_django_plugin.main", "mypy_drf_plugin.main"] + + +warn_unused_configs = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_unreachable = true + +strict_equality = true + +check_untyped_defs = true + +disallow_subclassing_any = true +disallow_untyped_decorators = true +disallow_any_generics = true +disallow_any_unimported = true +disallow_untyped_calls = true +disallow_incomplete_defs = true +disallow_untyped_defs = true + +no_implicit_reexport = true +no_implicit_optional = true + +show_error_codes = true +extra_checks = true + +[tool.django-stubs] +django_settings_module = "mypy_settings" +strict_settings = false + +[tool.hatch.version] +source = "versioningit" -[tool.setuptools.dynamic] -dependencies = { file = ["requirements.in"] } -optional-dependencies.test = { file = ["requirements-test.txt"] } +[tool.versioningit.vcs] +method = "git" diff --git a/requirements-test.txt b/requirements-test.txt deleted file mode 100644 index 0e1b47f..0000000 --- a/requirements-test.txt +++ /dev/null @@ -1,163 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.10 -# by the following command: -# -# pip-compile --output-file=requirements-test.txt requirements-test.in -# -alabaster==0.7.13 - # via sphinx -asgiref==3.8.1 - # via - # -c requirements.txt - # django - # django-cors-headers -attrs==23.2.0 - # via - # outcome - # trio -babel==2.15.0 - # via sphinx -certifi==2024.2.2 - # via - # -c requirements.txt - # requests - # selenium -cfgv==3.4.0 - # via pre-commit -charset-normalizer==3.3.2 - # via - # -c requirements.txt - # requests -colorama==0.4.6 - # via sphinx-autobuild -distlib==0.3.8 - # via virtualenv -django==4.2.13 - # via - # -c requirements.txt - # django-cors-headers - # djangorestframework -django-cors-headers==4.3.1 - # via -r requirements-test.in -djangorestframework==3.15.1 - # via -r requirements-test.in -docutils==0.19 - # via - # sphinx - # sphinx-rtd-theme -exceptiongroup==1.2.1 - # via - # trio - # trio-websocket -filelock==3.14.0 - # via virtualenv -h11==0.14.0 - # via wsproto -identify==2.5.36 - # via pre-commit -idna==3.7 - # via - # -c requirements.txt - # requests - # trio -imagesize==1.4.1 - # via sphinx -isort==5.13.2 - # via -r requirements-test.in -jinja2==3.1.4 - # via sphinx -livereload==2.6.3 - # via sphinx-autobuild -markupsafe==2.1.5 - # via - # -c requirements.txt - # jinja2 -nodeenv==1.8.0 - # via pre-commit -outcome==1.3.0.post0 - # via trio -packaging==24.0 - # via sphinx -platformdirs==4.2.2 - # via virtualenv -pre-commit==3.5.0 - # via -r requirements-test.in -psycopg2==2.9.9 - # via -r requirements-test.in -pygments==2.18.0 - # via sphinx -pysocks==1.7.1 - # via urllib3 -python-decouple==3.8 - # via -r requirements-test.in -pyyaml==6.0.1 - # via pre-commit -requests==2.32.1 - # via - # -c requirements.txt - # sphinx -selenium==4.21.0 - # via -r requirements-test.in -six==1.16.0 - # via - # -c requirements.txt - # livereload -sniffio==1.3.1 - # via trio -snowballstemmer==2.2.0 - # via sphinx -sortedcontainers==2.4.0 - # via trio -sphinx==6.2.1 - # via - # -r requirements-test.in - # sphinx-autobuild - # sphinx-rtd-theme - # sphinxcontrib-jquery -sphinx-autobuild==2021.3.14 - # via -r requirements-test.in -sphinx-rtd-theme==2.0.0 - # via -r requirements-test.in -sphinxcontrib-applehelp==1.0.4 - # via sphinx -sphinxcontrib-devhelp==1.0.2 - # via sphinx -sphinxcontrib-htmlhelp==2.0.1 - # via sphinx -sphinxcontrib-jquery==4.1 - # via sphinx-rtd-theme -sphinxcontrib-jsmath==1.0.1 - # via sphinx -sphinxcontrib-qthelp==1.0.3 - # via sphinx -sphinxcontrib-serializinghtml==1.1.5 - # via sphinx -sqlparse==0.5.0 - # via - # -c requirements.txt - # django -tornado==6.4 - # via livereload -trio==0.25.1 - # via - # selenium - # trio-websocket -trio-websocket==0.11.1 - # via selenium -typing-extensions==4.11.0 - # via - # -c requirements.txt - # asgiref - # selenium -urllib3[socks]==2.2.1 - # via - # -c requirements.txt - # requests - # selenium -virtualenv==20.26.2 - # via pre-commit -wsproto==1.2.0 - # via trio-websocket - -# The following packages are considered to be unsafe in a requirements file: -# setuptools diff --git a/requirements.in b/requirements.in deleted file mode 100644 index 7d19031..0000000 --- a/requirements.in +++ /dev/null @@ -1,4 +0,0 @@ -oic==1.7.0 -django>=3.2 -jsonpickle -jwt diff --git a/requirements.txt b/requirements.txt index 324c87d..b358e8a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,69 +1,115 @@ # -# This file is autogenerated by pip-compile with Python 3.10 +# This file is autogenerated by pip-compile with Python 3.8 # by the following command: # -# pip-compile --output-file=requirements.txt pyproject.toml +# pip-compile --extra=drf pyproject.toml # annotated-types==0.7.0 # via pydantic asgiref==3.8.1 # via django -certifi==2024.2.2 +attrs==24.3.0 + # via + # jsonschema + # referencing +backports-zoneinfo==0.2.1 + # via + # django + # djangorestframework +certifi==2024.12.14 # via requests -cffi==1.16.0 +cffi==1.17.1 # via cryptography -charset-normalizer==3.3.2 +charset-normalizer==3.4.1 # via requests -cryptography==42.0.7 +cryptography==44.0.0 # via # jwt # oic defusedxml==0.7.1 # via oic -django==4.2.13 +django==4.2.18 + # via + # django-pyoidc (pyproject.toml) + # djangorestframework + # drf-spectacular +djangorestframework==3.15.2 + # via + # django-pyoidc (pyproject.toml) + # drf-spectacular +drf-spectacular==0.28.0 # via django-pyoidc (pyproject.toml) future==1.0.0 # via pyjwkest -idna==3.7 +idna==3.10 # via requests -jsonpickle==3.0.4 +importlib-resources==6.4.5 + # via + # jsonschema + # jsonschema-specifications +inflection==0.5.1 + # via drf-spectacular +jsonpickle==4.0.1 # via django-pyoidc (pyproject.toml) +jsonschema==4.23.0 + # via drf-spectacular +jsonschema-specifications==2023.12.1 + # via jsonschema jwt==1.3.1 # via django-pyoidc (pyproject.toml) -mako==1.3.5 +mako==1.3.8 # via oic markupsafe==2.1.5 # via mako oic==1.7.0 # via django-pyoidc (pyproject.toml) +pkgutil-resolve-name==1.3.10 + # via jsonschema pycparser==2.22 # via cffi -pycryptodomex==3.20.0 +pycryptodomex==3.21.0 # via + # django-pyoidc (pyproject.toml) # oic # pyjwkest -pydantic==2.7.1 +pydantic==2.10.5 # via pydantic-settings -pydantic-core==2.18.2 +pydantic-core==2.27.2 # via pydantic -pydantic-settings==2.2.1 +pydantic-settings==2.7.1 # via oic pyjwkest==1.4.2 # via oic python-dotenv==1.0.1 # via pydantic-settings -requests==2.32.1 +pyyaml==6.0.2 + # via drf-spectacular +referencing==0.35.1 + # via + # jsonschema + # jsonschema-specifications +requests==2.32.3 # via # oic # pyjwkest -six==1.16.0 +rpds-py==0.20.1 + # via + # jsonschema + # referencing +six==1.17.0 # via pyjwkest -sqlparse==0.5.0 +sqlparse==0.5.3 # via django -typing-extensions==4.11.0 +typing-extensions==4.12.2 # via + # annotated-types # asgiref + # drf-spectacular # pydantic # pydantic-core -urllib3==2.2.1 +uritemplate==4.1.1 + # via drf-spectacular +urllib3==2.2.3 # via requests +zipp==3.20.2 + # via importlib-resources diff --git a/requirements/3.10/requirements-dev.txt b/requirements/3.10/requirements-dev.txt new file mode 100644 index 0000000..90e9d19 --- /dev/null +++ b/requirements/3.10/requirements-dev.txt @@ -0,0 +1,230 @@ +# +# This file is autogenerated by pip-compile with Python 3.10 +# by the following command: +# +# pip-compile --output-file=3.10/requirements-dev.txt requirements-dev.in +# +anyio==4.8.0 + # via httpx +asgiref==3.8.1 + # via + # -c requirements.txt + # django + # django-stubs +backports-tarfile==1.2.0 + # via jaraco-context +build==1.2.2.post1 + # via pip-tools +certifi==2024.12.14 + # via + # -c requirements.txt + # httpcore + # httpx + # requests +cffi==1.17.1 + # via + # -c requirements.txt + # cryptography +cfgv==3.4.0 + # via pre-commit +charset-normalizer==3.4.1 + # via + # -c requirements.txt + # requests +click==8.1.8 + # via + # hatch + # pip-tools + # userpath +cryptography==44.0.0 + # via + # -c requirements.txt + # secretstorage +distlib==0.3.9 + # via virtualenv +django==4.2.18 + # via + # -c requirements.txt + # django-stubs + # django-stubs-ext +django-stubs[compatible-mypy]==5.1.0 + # via + # -r requirements-dev.in + # djangorestframework-stubs +django-stubs-ext==5.1.2 + # via django-stubs +djangorestframework-stubs[compatible-mypy]==3.15.1 + # via -r requirements-dev.in +exceptiongroup==1.2.2 + # via anyio +filelock==3.16.1 + # via virtualenv +h11==0.14.0 + # via httpcore +hatch==1.14.0 + # via -r requirements-dev.in +hatchling==1.27.0 + # via hatch +httpcore==1.0.7 + # via httpx +httpx==0.28.1 + # via hatch +hyperlink==21.0.0 + # via hatch +identify==2.6.1 + # via pre-commit +idna==3.10 + # via + # -c requirements.txt + # anyio + # httpx + # hyperlink + # requests +importlib-metadata==8.5.0 + # via keyring +jaraco-classes==3.4.0 + # via keyring +jaraco-context==6.0.1 + # via keyring +jaraco-functools==4.1.0 + # via keyring +jeepney==0.8.0 + # via + # keyring + # secretstorage +keyring==25.6.0 + # via hatch +markdown-it-py==3.0.0 + # via rich +mdurl==0.1.2 + # via markdown-it-py +more-itertools==10.6.0 + # via + # jaraco-classes + # jaraco-functools +mypy==1.11.2 + # via + # -r requirements-dev.in + # django-stubs + # djangorestframework-stubs +mypy-extensions==1.0.0 + # via mypy +nodeenv==1.9.1 + # via pre-commit +packaging==24.2 + # via + # build + # hatch + # hatchling +pathspec==0.12.1 + # via hatchling +pexpect==4.9.0 + # via hatch +pip-tools==7.4.1 + # via -r requirements-dev.in +platformdirs==4.3.6 + # via + # hatch + # virtualenv +pluggy==1.5.0 + # via hatchling +pre-commit==3.5.0 + # via -r requirements-dev.in +ptyprocess==0.7.0 + # via pexpect +pycparser==2.22 + # via + # -c requirements.txt + # cffi +pygments==2.19.1 + # via rich +pyproject-hooks==1.2.0 + # via + # build + # pip-tools +pyyaml==6.0.2 + # via + # -c requirements.txt + # pre-commit +requests==2.32.3 + # via + # -c requirements.txt + # djangorestframework-stubs +rich==13.9.4 + # via hatch +ruff==0.9.1 + # via -r requirements-dev.in +secretstorage==3.3.3 + # via keyring +shellingham==1.5.4 + # via hatch +sniffio==1.3.1 + # via anyio +sqlparse==0.5.3 + # via + # -c requirements.txt + # django +tomli==2.2.1 + # via + # build + # django-stubs + # hatchling + # mypy + # pip-tools +tomli-w==1.2.0 + # via hatch +tomlkit==0.13.2 + # via hatch +trove-classifiers==2025.1.15.22 + # via hatchling +types-docutils==0.21.0.20241128 + # via types-pygments +types-markdown==3.7.0.20241204 + # via -r requirements-dev.in +types-psycopg2==2.9.21.20241019 + # via -r requirements-dev.in +types-pygments==2.19.0.20250107 + # via -r requirements-dev.in +types-pyyaml==6.0.12.20241230 + # via + # django-stubs + # djangorestframework-stubs +types-requests==2.32.0.20241016 + # via djangorestframework-stubs +types-setuptools==75.8.0.20250110 + # via types-pygments +typing-extensions==4.12.2 + # via + # -c requirements.txt + # anyio + # asgiref + # django-stubs + # django-stubs-ext + # djangorestframework-stubs + # mypy + # rich +urllib3==2.2.3 + # via + # -c requirements.txt + # requests + # types-requests +userpath==1.9.2 + # via hatch +uv==0.5.21 + # via hatch +virtualenv==20.29.0 + # via + # hatch + # pre-commit +wheel==0.45.1 + # via pip-tools +zipp==3.20.2 + # via + # -c requirements.txt + # importlib-metadata +zstandard==0.23.0 + # via hatch + +# The following packages are considered to be unsafe in a requirements file: +# pip +# setuptools diff --git a/requirements/3.10/requirements-test.txt b/requirements/3.10/requirements-test.txt new file mode 100644 index 0000000..8581220 --- /dev/null +++ b/requirements/3.10/requirements-test.txt @@ -0,0 +1,259 @@ +# +# This file is autogenerated by pip-compile with Python 3.10 +# by the following command: +# +# pip-compile --output-file=requirements/requirements-test.txt requirements/requirements-test.in +# +alabaster==0.7.13 + # via sphinx +annotated-types==0.7.0 + # via pydantic +asgiref==3.8.1 + # via + # django + # django-cors-headers + # django-stubs +attrs==24.3.0 + # via + # outcome + # trio +babel==2.16.0 + # via sphinx +build==1.2.2.post1 + # via pip-tools +certifi==2024.12.14 + # via + # requests + # selenium +cffi==1.17.1 + # via cryptography +cfgv==3.4.0 + # via pre-commit +charset-normalizer==3.4.1 + # via requests +click==8.1.8 + # via pip-tools +colorama==0.4.6 + # via sphinx-autobuild +cryptography==44.0.0 + # via + # jwt + # oic +defusedxml==0.7.1 + # via oic +distlib==0.3.9 + # via virtualenv +django==4.2.18 + # via + # -r requirements/requirements.in + # django-cors-headers + # django-stubs + # django-stubs-ext + # djangorestframework +django-cors-headers==4.4.0 + # via -r requirements/requirements-test.in +django-stubs[compatible-mypy]==5.1.0 + # via + # -r requirements/requirements-dev.in + # djangorestframework-stubs +django-stubs-ext==5.1.2 + # via django-stubs +djangorestframework==3.15.2 + # via -r requirements/requirements-test.in +djangorestframework-stubs[compatible-mypy]==3.15.1 + # via -r requirements/requirements-dev.in +docutils==0.19 + # via + # sphinx + # sphinx-rtd-theme +exceptiongroup==1.2.2 + # via + # trio + # trio-websocket +filelock==3.16.1 + # via virtualenv +future==1.0.0 + # via pyjwkest +h11==0.14.0 + # via wsproto +identify==2.6.1 + # via pre-commit +idna==3.10 + # via + # requests + # trio +imagesize==1.4.1 + # via sphinx +isort==5.13.2 + # via -r requirements/requirements-test.in +jinja2==3.1.5 + # via sphinx +jsonpickle==4.0.1 + # via -r requirements/requirements.in +jwt==1.3.1 + # via -r requirements/requirements.in +livereload==2.7.1 + # via sphinx-autobuild +mako==1.3.8 + # via oic +markupsafe==2.1.5 + # via + # jinja2 + # mako +mypy==1.11.2 + # via + # -r requirements/requirements-dev.in + # django-stubs + # djangorestframework-stubs +mypy-extensions==1.0.0 + # via mypy +nodeenv==1.9.1 + # via pre-commit +oic==1.7.0 + # via -r requirements/requirements.in +outcome==1.3.0.post0 + # via trio +packaging==24.2 + # via + # build + # sphinx +pip-tools==7.4.1 + # via -r requirements/requirements-dev.in +platformdirs==4.3.6 + # via virtualenv +pre-commit==3.5.0 + # via + # -r requirements/requirements-dev.in + # -r requirements/requirements-test.in +psycopg2==2.9.10 + # via -r requirements/requirements-test.in +pycparser==2.22 + # via cffi +pycryptodomex==3.21.0 + # via + # -r requirements/requirements.in + # oic + # pyjwkest +pydantic==2.10.5 + # via pydantic-settings +pydantic-core==2.27.2 + # via pydantic +pydantic-settings==2.7.1 + # via oic +pygments==2.19.1 + # via sphinx +pyjwkest==1.4.2 + # via oic +pyproject-hooks==1.2.0 + # via + # build + # pip-tools +pysocks==1.7.1 + # via urllib3 +python-decouple==3.8 + # via -r requirements/requirements-test.in +python-dotenv==1.0.1 + # via pydantic-settings +pyyaml==6.0.2 + # via pre-commit +requests==2.32.3 + # via + # djangorestframework-stubs + # oic + # pyjwkest + # sphinx +ruff==0.9.1 + # via -r requirements/requirements-dev.in +selenium==4.27.1 + # via -r requirements/requirements-test.in +six==1.17.0 + # via pyjwkest +sniffio==1.3.1 + # via trio +snowballstemmer==2.2.0 + # via sphinx +sortedcontainers==2.4.0 + # via trio +sphinx==6.2.1 + # via + # -r requirements/requirements-test.in + # sphinx-autobuild + # sphinx-rtd-theme + # sphinxcontrib-jquery +sphinx-autobuild==2021.3.14 + # via -r requirements/requirements-test.in +sphinx-rtd-theme==3.0.2 + # via -r requirements/requirements-test.in +sphinxcontrib-applehelp==1.0.4 + # via sphinx +sphinxcontrib-devhelp==1.0.2 + # via sphinx +sphinxcontrib-htmlhelp==2.0.1 + # via sphinx +sphinxcontrib-jquery==4.1 + # via sphinx-rtd-theme +sphinxcontrib-jsmath==1.0.1 + # via sphinx +sphinxcontrib-qthelp==1.0.3 + # via sphinx +sphinxcontrib-serializinghtml==1.1.5 + # via sphinx +sqlparse==0.5.3 + # via django +tomli==2.2.1 + # via + # build + # django-stubs + # mypy + # pip-tools +tornado==6.4.2 + # via livereload +trio==0.27.0 + # via + # selenium + # trio-websocket +trio-websocket==0.11.1 + # via selenium +types-docutils==0.21.0.20241128 + # via types-pygments +types-markdown==3.7.0.20241204 + # via -r requirements/requirements-dev.in +types-psycopg2==2.9.21.20241019 + # via -r requirements/requirements-dev.in +types-pygments==2.19.0.20250107 + # via -r requirements/requirements-dev.in +types-pyyaml==6.0.12.20241230 + # via + # django-stubs + # djangorestframework-stubs +types-requests==2.32.0.20241016 + # via djangorestframework-stubs +types-setuptools==75.8.0.20250110 + # via types-pygments +typing-extensions==4.12.2 + # via + # asgiref + # django-stubs + # django-stubs-ext + # djangorestframework-stubs + # mypy + # pydantic + # pydantic-core + # selenium +urllib3[socks]==2.2.3 + # via + # requests + # selenium + # types-requests +virtualenv==20.29.0 + # via pre-commit +websocket-client==1.8.0 + # via selenium +wheel==0.45.1 + # via pip-tools +wsproto==1.2.0 + # via trio-websocket + +# The following packages are considered to be unsafe in a requirements file: +# pip +# setuptools diff --git a/requirements/3.10/requirements.txt b/requirements/3.10/requirements.txt new file mode 100644 index 0000000..38bad37 --- /dev/null +++ b/requirements/3.10/requirements.txt @@ -0,0 +1,101 @@ +# +# This file is autogenerated by pip-compile with Python 3.10 +# by the following command: +# +# pip-compile --extra=drf --output-file=requirements/requirements.txt pyproject.toml +# +annotated-types==0.7.0 + # via pydantic +asgiref==3.8.1 + # via django +attrs==24.3.0 + # via + # jsonschema + # referencing +certifi==2024.12.14 + # via requests +cffi==1.17.1 + # via cryptography +charset-normalizer==3.4.1 + # via requests +cryptography==44.0.0 + # via + # jwt + # oic +defusedxml==0.7.1 + # via oic +django==4.2.18 + # via + # django-pyoidc (pyproject.toml) + # djangorestframework + # drf-spectacular +djangorestframework==3.15.2 + # via + # django-pyoidc (pyproject.toml) + # drf-spectacular +drf-spectacular==0.28.0 + # via django-pyoidc (pyproject.toml) +future==1.0.0 + # via pyjwkest +idna==3.10 + # via requests +inflection==0.5.1 + # via drf-spectacular +jsonpickle==4.0.1 + # via django-pyoidc (pyproject.toml) +jsonschema==4.23.0 + # via drf-spectacular +jsonschema-specifications==2023.12.1 + # via jsonschema +jwt==1.3.1 + # via django-pyoidc (pyproject.toml) +mako==1.3.8 + # via oic +markupsafe==2.1.5 + # via mako +oic==1.7.0 + # via django-pyoidc (pyproject.toml) +pycparser==2.22 + # via cffi +pycryptodomex==3.21.0 + # via + # django-pyoidc (pyproject.toml) + # oic + # pyjwkest +pydantic==2.10.5 + # via pydantic-settings +pydantic-core==2.27.2 + # via pydantic +pydantic-settings==2.7.1 + # via oic +pyjwkest==1.4.2 + # via oic +python-dotenv==1.0.1 + # via pydantic-settings +pyyaml==6.0.2 + # via drf-spectacular +referencing==0.35.1 + # via + # jsonschema + # jsonschema-specifications +requests==2.32.3 + # via + # oic + # pyjwkest +rpds-py==0.20.1 + # via + # jsonschema + # referencing +six==1.17.0 + # via pyjwkest +sqlparse==0.5.3 + # via django +typing-extensions==4.12.2 + # via + # asgiref + # pydantic + # pydantic-core +uritemplate==4.1.1 + # via drf-spectacular +urllib3==2.2.3 + # via requests diff --git a/requirements/3.8/requirements-dev.txt b/requirements/3.8/requirements-dev.txt new file mode 100644 index 0000000..ea767b7 --- /dev/null +++ b/requirements/3.8/requirements-dev.txt @@ -0,0 +1,157 @@ +# +# This file is autogenerated by pip-compile with Python 3.10 +# by the following command: +# +# pip-compile --output-file=requirements/requirements-dev.txt requirements/requirements-dev.in +# +annotated-types==0.7.0 + # via pydantic +asgiref==3.8.1 + # via + # django + # django-stubs +build==1.2.2.post1 + # via pip-tools +certifi==2024.12.14 + # via requests +cffi==1.17.1 + # via cryptography +cfgv==3.4.0 + # via pre-commit +charset-normalizer==3.4.1 + # via requests +click==8.1.8 + # via pip-tools +cryptography==44.0.0 + # via + # jwt + # oic +defusedxml==0.7.1 + # via oic +distlib==0.3.9 + # via virtualenv +django==4.2.18 + # via + # -r requirements/requirements.in + # django-stubs + # django-stubs-ext +django-stubs[compatible-mypy]==5.1.0 + # via + # -r requirements/requirements-dev.in + # djangorestframework-stubs +django-stubs-ext==5.1.2 + # via django-stubs +djangorestframework-stubs[compatible-mypy]==3.15.1 + # via -r requirements/requirements-dev.in +filelock==3.16.1 + # via virtualenv +future==1.0.0 + # via pyjwkest +identify==2.6.1 + # via pre-commit +idna==3.10 + # via requests +jsonpickle==4.0.1 + # via -r requirements/requirements.in +jwt==1.3.1 + # via -r requirements/requirements.in +mako==1.3.8 + # via oic +markupsafe==2.1.5 + # via mako +mypy==1.11.2 + # via + # -r requirements/requirements-dev.in + # django-stubs + # djangorestframework-stubs +mypy-extensions==1.0.0 + # via mypy +nodeenv==1.9.1 + # via pre-commit +oic==1.7.0 + # via -r requirements/requirements.in +packaging==24.2 + # via build +pip-tools==7.4.1 + # via -r requirements/requirements-dev.in +platformdirs==4.3.6 + # via virtualenv +pre-commit==3.5.0 + # via -r requirements/requirements-dev.in +pycparser==2.22 + # via cffi +pycryptodomex==3.21.0 + # via + # -r requirements/requirements.in + # oic + # pyjwkest +pydantic==2.10.5 + # via pydantic-settings +pydantic-core==2.27.2 + # via pydantic +pydantic-settings==2.7.1 + # via oic +pyjwkest==1.4.2 + # via oic +pyproject-hooks==1.2.0 + # via + # build + # pip-tools +python-dotenv==1.0.1 + # via pydantic-settings +pyyaml==6.0.2 + # via pre-commit +requests==2.32.3 + # via + # djangorestframework-stubs + # oic + # pyjwkest +ruff==0.9.1 + # via -r requirements/requirements-dev.in +six==1.17.0 + # via pyjwkest +sqlparse==0.5.3 + # via django +tomli==2.2.1 + # via + # build + # django-stubs + # mypy + # pip-tools +types-docutils==0.21.0.20241128 + # via types-pygments +types-markdown==3.7.0.20241204 + # via -r requirements/requirements-dev.in +types-psycopg2==2.9.21.20241019 + # via -r requirements/requirements-dev.in +types-pygments==2.19.0.20250107 + # via -r requirements/requirements-dev.in +types-pyyaml==6.0.12.20241230 + # via + # django-stubs + # djangorestframework-stubs +types-requests==2.32.0.20241016 + # via djangorestframework-stubs +types-setuptools==75.8.0.20250110 + # via types-pygments +typing-extensions==4.12.2 + # via + # asgiref + # django-stubs + # django-stubs-ext + # djangorestframework-stubs + # mypy + # pydantic + # pydantic-core +urllib3==2.2.3 + # via + # requests + # types-requests +virtualenv==20.29.0 + # via pre-commit +wheel==0.45.1 + # via pip-tools + +# The following packages are considered to be unsafe in a requirements file: +# pip +# setuptools diff --git a/requirements/3.8/requirements-test.txt b/requirements/3.8/requirements-test.txt new file mode 100644 index 0000000..8581220 --- /dev/null +++ b/requirements/3.8/requirements-test.txt @@ -0,0 +1,259 @@ +# +# This file is autogenerated by pip-compile with Python 3.10 +# by the following command: +# +# pip-compile --output-file=requirements/requirements-test.txt requirements/requirements-test.in +# +alabaster==0.7.13 + # via sphinx +annotated-types==0.7.0 + # via pydantic +asgiref==3.8.1 + # via + # django + # django-cors-headers + # django-stubs +attrs==24.3.0 + # via + # outcome + # trio +babel==2.16.0 + # via sphinx +build==1.2.2.post1 + # via pip-tools +certifi==2024.12.14 + # via + # requests + # selenium +cffi==1.17.1 + # via cryptography +cfgv==3.4.0 + # via pre-commit +charset-normalizer==3.4.1 + # via requests +click==8.1.8 + # via pip-tools +colorama==0.4.6 + # via sphinx-autobuild +cryptography==44.0.0 + # via + # jwt + # oic +defusedxml==0.7.1 + # via oic +distlib==0.3.9 + # via virtualenv +django==4.2.18 + # via + # -r requirements/requirements.in + # django-cors-headers + # django-stubs + # django-stubs-ext + # djangorestframework +django-cors-headers==4.4.0 + # via -r requirements/requirements-test.in +django-stubs[compatible-mypy]==5.1.0 + # via + # -r requirements/requirements-dev.in + # djangorestframework-stubs +django-stubs-ext==5.1.2 + # via django-stubs +djangorestframework==3.15.2 + # via -r requirements/requirements-test.in +djangorestframework-stubs[compatible-mypy]==3.15.1 + # via -r requirements/requirements-dev.in +docutils==0.19 + # via + # sphinx + # sphinx-rtd-theme +exceptiongroup==1.2.2 + # via + # trio + # trio-websocket +filelock==3.16.1 + # via virtualenv +future==1.0.0 + # via pyjwkest +h11==0.14.0 + # via wsproto +identify==2.6.1 + # via pre-commit +idna==3.10 + # via + # requests + # trio +imagesize==1.4.1 + # via sphinx +isort==5.13.2 + # via -r requirements/requirements-test.in +jinja2==3.1.5 + # via sphinx +jsonpickle==4.0.1 + # via -r requirements/requirements.in +jwt==1.3.1 + # via -r requirements/requirements.in +livereload==2.7.1 + # via sphinx-autobuild +mako==1.3.8 + # via oic +markupsafe==2.1.5 + # via + # jinja2 + # mako +mypy==1.11.2 + # via + # -r requirements/requirements-dev.in + # django-stubs + # djangorestframework-stubs +mypy-extensions==1.0.0 + # via mypy +nodeenv==1.9.1 + # via pre-commit +oic==1.7.0 + # via -r requirements/requirements.in +outcome==1.3.0.post0 + # via trio +packaging==24.2 + # via + # build + # sphinx +pip-tools==7.4.1 + # via -r requirements/requirements-dev.in +platformdirs==4.3.6 + # via virtualenv +pre-commit==3.5.0 + # via + # -r requirements/requirements-dev.in + # -r requirements/requirements-test.in +psycopg2==2.9.10 + # via -r requirements/requirements-test.in +pycparser==2.22 + # via cffi +pycryptodomex==3.21.0 + # via + # -r requirements/requirements.in + # oic + # pyjwkest +pydantic==2.10.5 + # via pydantic-settings +pydantic-core==2.27.2 + # via pydantic +pydantic-settings==2.7.1 + # via oic +pygments==2.19.1 + # via sphinx +pyjwkest==1.4.2 + # via oic +pyproject-hooks==1.2.0 + # via + # build + # pip-tools +pysocks==1.7.1 + # via urllib3 +python-decouple==3.8 + # via -r requirements/requirements-test.in +python-dotenv==1.0.1 + # via pydantic-settings +pyyaml==6.0.2 + # via pre-commit +requests==2.32.3 + # via + # djangorestframework-stubs + # oic + # pyjwkest + # sphinx +ruff==0.9.1 + # via -r requirements/requirements-dev.in +selenium==4.27.1 + # via -r requirements/requirements-test.in +six==1.17.0 + # via pyjwkest +sniffio==1.3.1 + # via trio +snowballstemmer==2.2.0 + # via sphinx +sortedcontainers==2.4.0 + # via trio +sphinx==6.2.1 + # via + # -r requirements/requirements-test.in + # sphinx-autobuild + # sphinx-rtd-theme + # sphinxcontrib-jquery +sphinx-autobuild==2021.3.14 + # via -r requirements/requirements-test.in +sphinx-rtd-theme==3.0.2 + # via -r requirements/requirements-test.in +sphinxcontrib-applehelp==1.0.4 + # via sphinx +sphinxcontrib-devhelp==1.0.2 + # via sphinx +sphinxcontrib-htmlhelp==2.0.1 + # via sphinx +sphinxcontrib-jquery==4.1 + # via sphinx-rtd-theme +sphinxcontrib-jsmath==1.0.1 + # via sphinx +sphinxcontrib-qthelp==1.0.3 + # via sphinx +sphinxcontrib-serializinghtml==1.1.5 + # via sphinx +sqlparse==0.5.3 + # via django +tomli==2.2.1 + # via + # build + # django-stubs + # mypy + # pip-tools +tornado==6.4.2 + # via livereload +trio==0.27.0 + # via + # selenium + # trio-websocket +trio-websocket==0.11.1 + # via selenium +types-docutils==0.21.0.20241128 + # via types-pygments +types-markdown==3.7.0.20241204 + # via -r requirements/requirements-dev.in +types-psycopg2==2.9.21.20241019 + # via -r requirements/requirements-dev.in +types-pygments==2.19.0.20250107 + # via -r requirements/requirements-dev.in +types-pyyaml==6.0.12.20241230 + # via + # django-stubs + # djangorestframework-stubs +types-requests==2.32.0.20241016 + # via djangorestframework-stubs +types-setuptools==75.8.0.20250110 + # via types-pygments +typing-extensions==4.12.2 + # via + # asgiref + # django-stubs + # django-stubs-ext + # djangorestframework-stubs + # mypy + # pydantic + # pydantic-core + # selenium +urllib3[socks]==2.2.3 + # via + # requests + # selenium + # types-requests +virtualenv==20.29.0 + # via pre-commit +websocket-client==1.8.0 + # via selenium +wheel==0.45.1 + # via pip-tools +wsproto==1.2.0 + # via trio-websocket + +# The following packages are considered to be unsafe in a requirements file: +# pip +# setuptools diff --git a/requirements/3.8/requirements.txt b/requirements/3.8/requirements.txt new file mode 100644 index 0000000..38bad37 --- /dev/null +++ b/requirements/3.8/requirements.txt @@ -0,0 +1,101 @@ +# +# This file is autogenerated by pip-compile with Python 3.10 +# by the following command: +# +# pip-compile --extra=drf --output-file=requirements/requirements.txt pyproject.toml +# +annotated-types==0.7.0 + # via pydantic +asgiref==3.8.1 + # via django +attrs==24.3.0 + # via + # jsonschema + # referencing +certifi==2024.12.14 + # via requests +cffi==1.17.1 + # via cryptography +charset-normalizer==3.4.1 + # via requests +cryptography==44.0.0 + # via + # jwt + # oic +defusedxml==0.7.1 + # via oic +django==4.2.18 + # via + # django-pyoidc (pyproject.toml) + # djangorestframework + # drf-spectacular +djangorestframework==3.15.2 + # via + # django-pyoidc (pyproject.toml) + # drf-spectacular +drf-spectacular==0.28.0 + # via django-pyoidc (pyproject.toml) +future==1.0.0 + # via pyjwkest +idna==3.10 + # via requests +inflection==0.5.1 + # via drf-spectacular +jsonpickle==4.0.1 + # via django-pyoidc (pyproject.toml) +jsonschema==4.23.0 + # via drf-spectacular +jsonschema-specifications==2023.12.1 + # via jsonschema +jwt==1.3.1 + # via django-pyoidc (pyproject.toml) +mako==1.3.8 + # via oic +markupsafe==2.1.5 + # via mako +oic==1.7.0 + # via django-pyoidc (pyproject.toml) +pycparser==2.22 + # via cffi +pycryptodomex==3.21.0 + # via + # django-pyoidc (pyproject.toml) + # oic + # pyjwkest +pydantic==2.10.5 + # via pydantic-settings +pydantic-core==2.27.2 + # via pydantic +pydantic-settings==2.7.1 + # via oic +pyjwkest==1.4.2 + # via oic +python-dotenv==1.0.1 + # via pydantic-settings +pyyaml==6.0.2 + # via drf-spectacular +referencing==0.35.1 + # via + # jsonschema + # jsonschema-specifications +requests==2.32.3 + # via + # oic + # pyjwkest +rpds-py==0.20.1 + # via + # jsonschema + # referencing +six==1.17.0 + # via pyjwkest +sqlparse==0.5.3 + # via django +typing-extensions==4.12.2 + # via + # asgiref + # pydantic + # pydantic-core +uritemplate==4.1.1 + # via drf-spectacular +urllib3==2.2.3 + # via requests diff --git a/requirements/requirements-dev.in b/requirements/requirements-dev.in new file mode 100644 index 0000000..6bcfa19 --- /dev/null +++ b/requirements/requirements-dev.in @@ -0,0 +1,14 @@ +-c requirements.txt + +pip-tools +pre-commit +isort +flake8 +black +mypy==1.11.2 +djangorestframework-stubs[compatible-mypy] +django-stubs[compatible-mypy] +types-Markdown +types-Pygments +types-psycopg2 +hatch diff --git a/requirements/requirements-dev.txt b/requirements/requirements-dev.txt new file mode 100644 index 0000000..35dffae --- /dev/null +++ b/requirements/requirements-dev.txt @@ -0,0 +1,136 @@ +# +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: +# +# pip-compile requirements/requirements-dev.in +# +asgiref==3.8.1 + # via + # -c requirements/requirements.txt + # django + # django-stubs +backports-zoneinfo==0.2.1 + # via + # -c requirements/requirements.txt + # django +build==1.2.2.post1 + # via pip-tools +certifi==2024.12.14 + # via + # -c requirements/requirements.txt + # requests +cfgv==3.4.0 + # via pre-commit +charset-normalizer==3.4.1 + # via + # -c requirements/requirements.txt + # requests +click==8.1.8 + # via pip-tools +distlib==0.3.9 + # via virtualenv +django==4.2.18 + # via + # -c requirements/requirements.txt + # django-stubs + # django-stubs-ext +django-stubs[compatible-mypy]==5.1.0 + # via + # -r requirements/requirements-dev.in + # djangorestframework-stubs +django-stubs-ext==5.1.2 + # via django-stubs +djangorestframework-stubs[compatible-mypy]==3.15.1 + # via -r requirements/requirements-dev.in +filelock==3.16.1 + # via virtualenv +identify==2.6.1 + # via pre-commit +idna==3.10 + # via + # -c requirements/requirements.txt + # requests +importlib-metadata==8.5.0 + # via build +mypy==1.11.2 + # via + # -r requirements/requirements-dev.in + # django-stubs + # djangorestframework-stubs +mypy-extensions==1.0.0 + # via mypy +nodeenv==1.9.1 + # via pre-commit +packaging==24.2 + # via build +pip-tools==7.4.1 + # via -r requirements/requirements-dev.in +platformdirs==4.3.6 + # via virtualenv +pre-commit==3.5.0 + # via -r requirements/requirements-dev.in +pyproject-hooks==1.2.0 + # via + # build + # pip-tools +pyyaml==6.0.2 + # via + # -c requirements/requirements.txt + # pre-commit +requests==2.32.3 + # via + # -c requirements/requirements.txt + # djangorestframework-stubs +ruff==0.9.1 + # via -r requirements/requirements-dev.in +sqlparse==0.5.3 + # via + # -c requirements/requirements.txt + # django +tomli==2.2.1 + # via + # build + # django-stubs + # mypy + # pip-tools +types-docutils==0.21.0.20241128 + # via types-pygments +types-markdown==3.7.0.20241204 + # via -r requirements/requirements-dev.in +types-psycopg2==2.9.21.20241019 + # via -r requirements/requirements-dev.in +types-pygments==2.19.0.20250107 + # via -r requirements/requirements-dev.in +types-pyyaml==6.0.12.20241230 + # via + # django-stubs + # djangorestframework-stubs +types-requests==2.32.0.20241016 + # via djangorestframework-stubs +types-setuptools==75.8.0.20250110 + # via types-pygments +typing-extensions==4.12.2 + # via + # -c requirements/requirements.txt + # asgiref + # django-stubs + # django-stubs-ext + # djangorestframework-stubs + # mypy +urllib3==2.2.3 + # via + # -c requirements/requirements.txt + # requests + # types-requests +virtualenv==20.29.0 + # via pre-commit +wheel==0.45.1 + # via pip-tools +zipp==3.20.2 + # via + # -c requirements/requirements.txt + # importlib-metadata + +# The following packages are considered to be unsafe in a requirements file: +# pip +# setuptools diff --git a/requirements-test.in b/requirements/requirements-test.in similarity index 76% rename from requirements-test.in rename to requirements/requirements-test.in index 0f67c9e..5fd6985 100644 --- a/requirements-test.in +++ b/requirements/requirements-test.in @@ -1,4 +1,6 @@ +-r requirements-dev.in -c requirements.txt + python-decouple psycopg2 sphinx<7 @@ -8,4 +10,4 @@ isort pre-commit selenium>4.10.0 djangorestframework>=3.15 -django-cors-headers \ No newline at end of file +django-cors-headers diff --git a/requirements/requirements-test.txt b/requirements/requirements-test.txt new file mode 100644 index 0000000..90b88c3 --- /dev/null +++ b/requirements/requirements-test.txt @@ -0,0 +1,246 @@ +# +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: +# +# pip-compile requirements/requirements-test.in +# +alabaster==0.7.13 + # via sphinx +asgiref==3.8.1 + # via + # -c requirements/requirements.txt + # django + # django-cors-headers + # django-stubs +attrs==24.3.0 + # via + # -c requirements/requirements.txt + # outcome + # trio +babel==2.16.0 + # via sphinx +backports-zoneinfo==0.2.1 + # via + # -c requirements/requirements.txt + # django + # djangorestframework +build==1.2.2.post1 + # via pip-tools +certifi==2024.12.14 + # via + # -c requirements/requirements.txt + # requests + # selenium +cfgv==3.4.0 + # via pre-commit +charset-normalizer==3.4.1 + # via + # -c requirements/requirements.txt + # requests +click==8.1.8 + # via pip-tools +colorama==0.4.6 + # via sphinx-autobuild +distlib==0.3.9 + # via virtualenv +django==4.2.18 + # via + # -c requirements/requirements.txt + # django-cors-headers + # django-stubs + # django-stubs-ext + # djangorestframework +django-cors-headers==4.4.0 + # via -r requirements/requirements-test.in +django-stubs[compatible-mypy]==5.1.0 + # via + # -r requirements/requirements-dev.in + # djangorestframework-stubs +django-stubs-ext==5.1.2 + # via django-stubs +djangorestframework==3.15.2 + # via + # -c requirements/requirements.txt + # -r requirements/requirements-test.in +djangorestframework-stubs[compatible-mypy]==3.15.1 + # via -r requirements/requirements-dev.in +docutils==0.19 + # via + # sphinx + # sphinx-rtd-theme +exceptiongroup==1.2.2 + # via + # trio + # trio-websocket +filelock==3.16.1 + # via virtualenv +h11==0.14.0 + # via wsproto +identify==2.6.1 + # via pre-commit +idna==3.10 + # via + # -c requirements/requirements.txt + # requests + # trio +imagesize==1.4.1 + # via sphinx +importlib-metadata==8.5.0 + # via + # build + # sphinx +isort==5.13.2 + # via -r requirements/requirements-test.in +jinja2==3.1.5 + # via sphinx +livereload==2.7.1 + # via sphinx-autobuild +markupsafe==2.1.5 + # via + # -c requirements/requirements.txt + # jinja2 +mypy==1.11.2 + # via + # -r requirements/requirements-dev.in + # django-stubs + # djangorestframework-stubs +mypy-extensions==1.0.0 + # via mypy +nodeenv==1.9.1 + # via pre-commit +outcome==1.3.0.post0 + # via trio +packaging==24.2 + # via + # build + # sphinx +pip-tools==7.4.1 + # via -r requirements/requirements-dev.in +platformdirs==4.3.6 + # via virtualenv +pre-commit==3.5.0 + # via + # -r requirements/requirements-dev.in + # -r requirements/requirements-test.in +psycopg2==2.9.10 + # via -r requirements/requirements-test.in +pygments==2.19.1 + # via sphinx +pyproject-hooks==1.2.0 + # via + # build + # pip-tools +pysocks==1.7.1 + # via urllib3 +python-decouple==3.8 + # via -r requirements/requirements-test.in +pytz==2024.2 + # via babel +pyyaml==6.0.2 + # via + # -c requirements/requirements.txt + # pre-commit +requests==2.32.3 + # via + # -c requirements/requirements.txt + # djangorestframework-stubs + # sphinx +ruff==0.9.1 + # via -r requirements/requirements-dev.in +selenium==4.27.1 + # via -r requirements/requirements-test.in +sniffio==1.3.1 + # via trio +snowballstemmer==2.2.0 + # via sphinx +sortedcontainers==2.4.0 + # via trio +sphinx==6.2.1 + # via + # -r requirements/requirements-test.in + # sphinx-autobuild + # sphinx-rtd-theme + # sphinxcontrib-jquery +sphinx-autobuild==2021.3.14 + # via -r requirements/requirements-test.in +sphinx-rtd-theme==3.0.2 + # via -r requirements/requirements-test.in +sphinxcontrib-applehelp==1.0.4 + # via sphinx +sphinxcontrib-devhelp==1.0.2 + # via sphinx +sphinxcontrib-htmlhelp==2.0.1 + # via sphinx +sphinxcontrib-jquery==4.1 + # via sphinx-rtd-theme +sphinxcontrib-jsmath==1.0.1 + # via sphinx +sphinxcontrib-qthelp==1.0.3 + # via sphinx +sphinxcontrib-serializinghtml==1.1.5 + # via sphinx +sqlparse==0.5.3 + # via + # -c requirements/requirements.txt + # django +tomli==2.2.1 + # via + # build + # django-stubs + # mypy + # pip-tools +tornado==6.4.2 + # via livereload +trio==0.27.0 + # via + # selenium + # trio-websocket +trio-websocket==0.11.1 + # via selenium +types-docutils==0.21.0.20241128 + # via types-pygments +types-markdown==3.7.0.20241204 + # via -r requirements/requirements-dev.in +types-psycopg2==2.9.21.20241019 + # via -r requirements/requirements-dev.in +types-pygments==2.19.0.20250107 + # via -r requirements/requirements-dev.in +types-pyyaml==6.0.12.20241230 + # via + # django-stubs + # djangorestframework-stubs +types-requests==2.32.0.20241016 + # via djangorestframework-stubs +types-setuptools==75.8.0.20250110 + # via types-pygments +typing-extensions==4.12.2 + # via + # -c requirements/requirements.txt + # asgiref + # django-stubs + # django-stubs-ext + # djangorestframework-stubs + # mypy + # selenium +urllib3[socks]==2.2.3 + # via + # -c requirements/requirements.txt + # requests + # selenium + # types-requests +virtualenv==20.29.0 + # via pre-commit +websocket-client==1.8.0 + # via selenium +wheel==0.45.1 + # via pip-tools +wsproto==1.2.0 + # via trio-websocket +zipp==3.20.2 + # via + # -c requirements/requirements.txt + # importlib-metadata + +# The following packages are considered to be unsafe in a requirements file: +# pip +# setuptools diff --git a/requirements/requirements.txt b/requirements/requirements.txt new file mode 100644 index 0000000..0abf69f --- /dev/null +++ b/requirements/requirements.txt @@ -0,0 +1,115 @@ +# +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: +# +# pip-compile --extra=drf --output-file=requirements/requirements.txt pyproject.toml +# +annotated-types==0.7.0 + # via pydantic +asgiref==3.8.1 + # via django +attrs==24.3.0 + # via + # jsonschema + # referencing +backports-zoneinfo==0.2.1 + # via + # django + # djangorestframework +certifi==2024.12.14 + # via requests +cffi==1.17.1 + # via cryptography +charset-normalizer==3.4.1 + # via requests +cryptography==44.0.0 + # via + # jwt + # oic +defusedxml==0.7.1 + # via oic +django==4.2.18 + # via + # django-pyoidc (pyproject.toml) + # djangorestframework + # drf-spectacular +djangorestframework==3.15.2 + # via + # django-pyoidc (pyproject.toml) + # drf-spectacular +drf-spectacular==0.28.0 + # via django-pyoidc (pyproject.toml) +future==1.0.0 + # via pyjwkest +idna==3.10 + # via requests +importlib-resources==6.4.5 + # via + # jsonschema + # jsonschema-specifications +inflection==0.5.1 + # via drf-spectacular +jsonpickle==4.0.1 + # via django-pyoidc (pyproject.toml) +jsonschema==4.23.0 + # via drf-spectacular +jsonschema-specifications==2023.12.1 + # via jsonschema +jwt==1.3.1 + # via django-pyoidc (pyproject.toml) +mako==1.3.8 + # via oic +markupsafe==2.1.5 + # via mako +oic==1.7.0 + # via django-pyoidc (pyproject.toml) +pkgutil-resolve-name==1.3.10 + # via jsonschema +pycparser==2.22 + # via cffi +pycryptodomex==3.21.0 + # via + # django-pyoidc (pyproject.toml) + # oic + # pyjwkest +pydantic==2.10.5 + # via pydantic-settings +pydantic-core==2.27.2 + # via pydantic +pydantic-settings==2.7.1 + # via oic +pyjwkest==1.4.2 + # via oic +python-dotenv==1.0.1 + # via pydantic-settings +pyyaml==6.0.2 + # via drf-spectacular +referencing==0.35.1 + # via + # jsonschema + # jsonschema-specifications +requests==2.32.3 + # via + # oic + # pyjwkest +rpds-py==0.20.1 + # via + # jsonschema + # referencing +six==1.17.0 + # via pyjwkest +sqlparse==0.5.3 + # via django +typing-extensions==4.12.2 + # via + # annotated-types + # asgiref + # drf-spectacular + # pydantic + # pydantic-core +uritemplate==4.1.1 + # via drf-spectacular +urllib3==2.2.3 + # via requests +zipp==3.20.2 + # via importlib-resources diff --git a/runtests.py b/run_e2e_tests.py similarity index 67% rename from runtests.py rename to run_e2e_tests.py index b64d376..64ad121 100644 --- a/runtests.py +++ b/run_e2e_tests.py @@ -9,7 +9,7 @@ if __name__ == "__main__": os.environ["DJANGO_SETTINGS_MODULE"] = "tests.e2e.settings" django.setup() - TestRunner = get_runner(settings) - test_runner = TestRunner() - failures = test_runner.run_tests(["tests", "tests/e2e"]) + E2eTestRunner = get_runner(settings) + test_runner = E2eTestRunner() + failures = test_runner.run_tests(["tests/e2e"]) sys.exit(bool(failures)) diff --git a/run_mypy.sh b/run_mypy.sh new file mode 100755 index 0000000..dc3e7c8 --- /dev/null +++ b/run_mypy.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +mypy django_pyoidc diff --git a/run_tests.py b/run_tests.py new file mode 100644 index 0000000..48ca0fd --- /dev/null +++ b/run_tests.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python +import os +import sys + +import django +from django.conf import settings +from django.test.utils import get_runner + +if __name__ == "__main__": + os.environ["DJANGO_SETTINGS_MODULE"] = "tests.test_settings" + django.setup() + TestRunner = get_runner(settings) + test_runner = TestRunner() + failures = test_runner.run_tests( + [ + "tests", + ] + ) + sys.exit(bool(failures)) diff --git a/setup.py b/setup.py deleted file mode 100644 index 077a592..0000000 --- a/setup.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env python - - -import os - -from setuptools import find_packages, setup - -HERE = os.path.abspath(os.path.dirname(__file__)) - -with open(os.path.join(HERE, "django_pyoidc", "VERSION")) as version_file: - VERSION = version_file.read().strip() - -setup( - version=VERSION, - packages=find_packages(), -) diff --git a/tests/e2e/settings.py b/tests/e2e/settings.py index 1e90688..423a149 100644 --- a/tests/e2e/settings.py +++ b/tests/e2e/settings.py @@ -16,7 +16,7 @@ "django_pyoidc", ] -ALLOWED_HOSTS = ["test.django-pyoidc.notatld"] +ALLOWED_HOSTS = ["test.django-pyoidc.notatld", "testserver", "localhost"] MIDDLEWARE = [ "django.contrib.sessions.middleware.SessionMiddleware", diff --git a/tests/e2e/test_app/callback.py b/tests/e2e/test_app/callback.py index 0512ce0..b1f40da 100644 --- a/tests/e2e/test_app/callback.py +++ b/tests/e2e/test_app/callback.py @@ -1,10 +1,13 @@ import logging -from django.conf import settings from django.contrib import messages from django.contrib.auth import get_user_model from django.core.exceptions import PermissionDenied +from django_pyoidc.client import OIDCClient +from django_pyoidc.exceptions import ClaimNotFoundError +from django_pyoidc.utils import extract_claim_from_tokens + logger = logging.getLogger(__name__) @@ -13,7 +16,11 @@ def login_callback(request, user): def logout_callback(request, logout_request_args): - messages.success(request, f"Logout Callback for {request.user.email}.") + if request.user.is_anonymous: + messages.success(request, "Logout Callback for anonymous user.") + else: + messages.success(request, f"Logout Callback for {request.user.email}.") + logout_request_args["locale"] = "fr" logout_request_args["test"] = "zorg" return logout_request_args @@ -39,11 +46,12 @@ def _debug_tokens(tokens={}): print(tokens["access_token_claims"]) if "id_token_claims" in tokens: + print("id_token_claims") print(type(tokens["id_token_claims"])) # dict print(tokens["id_token_claims"]) -def get_user_with_resource_access_check(tokens={}): +def get_user_with_resource_access_check(client: OIDCClient, tokens={}): _debug_tokens(tokens) access_token_claims = ( @@ -56,13 +64,13 @@ def get_user_with_resource_access_check(tokens={}): resource_access = ( access_token_claims["resource_access"] - if "resource_access" in access_token_claims + if access_token_claims and "resource_access" in access_token_claims else None ) # Perform resource access checks - client_id = settings.DJANGO_PYOIDC["sso1"]["OIDC_CLIENT_ID"] - # warning for user with o access Keycloak would not generate the resource_access claim + client_id = client.get_setting("client_id") + # warning for user with no access Keycloak would not generate the resource_access claim # so we need to check absence of the claim also if (resource_access and client_id not in resource_access) or ( resource_access is None @@ -98,35 +106,51 @@ def get_user_with_resource_access_check(tokens={}): return user -def get_user_with_minimal_audiences_check(tokens={}): +def extract_username_from_tokens(tokens: dict): + try: + username = extract_claim_from_tokens("preferred_username", tokens) + except ClaimNotFoundError: + try: + username = extract_claim_from_tokens("email", tokens) + except ClaimNotFoundError: + raise ClaimNotFoundError( + "We found nothing to extract a username in current OIDC tokens." + ) + return username + +def get_user_with_minimal_audiences_check(client: OIDCClient, tokens={}): + """Checking audiences on the access token. Works with access_token_claims extracted only.""" _debug_tokens(tokens) access_token_claims = ( - tokens["access_token_claims"] if "access_token_claims" in tokens else None + tokens["access_token_claims"] + if "access_token_claims" in tokens and tokens["access_token_claims"] + else None ) - id_token_claims = tokens["id_token_claims"] if "id_token_claims" in tokens else None + # id_token_claims = tokens["id_token_claims"] if "id_token_claims" in tokens else None info_token_claims = ( tokens["info_token_claims"] if "info_token_claims" in tokens else None ) # Perform a minimal audience check # Note: here not checking if client_id is in 'aud' because that's broken in Keycloak - client_id = settings.DJANGO_PYOIDC["sso1"]["OIDC_CLIENT_ID"] - if "azp" not in access_token_claims: - logger.error("Missing azp claim access_token") - raise PermissionDenied("You do not have access to this application.") - elif not access_token_claims["azp"] == client_id: - logger.error("Failed audience (azp claim) minimal check in access_token") - raise PermissionDenied("You do not have access to this application.") - - username = "" - - if "preferred_username" in id_token_claims: - username = id_token_claims["preferred_username"] - elif "preferred_username" in info_token_claims: - username = info_token_claims["preferred_username"] + client_id = client.get_setting("client_id") + if access_token_claims: + if "azp" not in access_token_claims: + logger.error("Missing azp claim access_token") + raise PermissionDenied("You do not have access to this application.") + elif not access_token_claims["azp"] == client_id: + logger.error("Failed audience (azp claim) minimal check in access_token") + raise PermissionDenied("You do not have access to this application.") else: - username = info_token_claims["email"] + logger.error( + "Missing access_token claims. This function need an extracted access token to work, please activate 'use_introspection_on_access_tokens' or 'hook_validate_access_token'." + ) + raise ClaimNotFoundError( + "get_user_with_minimal_audiences_check works only with claims extracted from the access token." + ) + + username = extract_username_from_tokens(tokens) User = get_user_model() user, created = User.objects.get_or_create( @@ -142,13 +166,13 @@ def get_user_with_minimal_audiences_check(tokens={}): return user -def get_user_with_audiences_check(tokens={}): +def get_user_with_audiences_check(client: OIDCClient, tokens={}): _debug_tokens(tokens) access_token_claims = ( tokens["access_token_claims"] if "access_token_claims" in tokens else None ) - id_token_claims = tokens["id_token_claims"] if "id_token_claims" in tokens else None + # id_token_claims = tokens["id_token_claims"] if "id_token_claims" in tokens else None info_token_claims = ( tokens["info_token_claims"] if "info_token_claims" in tokens else None ) @@ -173,18 +197,11 @@ def get_user_with_audiences_check(tokens={}): raise RuntimeError("Unknown type for audience claim") # Perform audience check - if audiences and settings.DJANGO_PYOIDC["sso1"]["OIDC_CLIENT_ID"] not in audiences: + if audiences and client.get_setting("client_id") not in audiences: logger.error("Failed audience check in access_token") raise PermissionDenied("You do not have access to this application.") - username = "" - - if "preferred_username" in id_token_claims: - username = id_token_claims["preferred_username"] - elif "preferred_username" in info_token_claims: - username = info_token_claims["preferred_username"] - else: - username = info_token_claims["email"] + username = extract_username_from_tokens(tokens) User = get_user_model() user, created = User.objects.get_or_create( @@ -198,3 +215,11 @@ def get_user_with_audiences_check(tokens={}): user.save() return user + + +def hook_validate_access_token(access_token_jwt: str, client: OIDCClient): + """Here we should handle the jwt string and convert it to a claim dictionnary. + + But we should also ensure validity of the jwt (like signatures, expiration, etc.). + """ + return {} diff --git a/tests/e2e/test_app/templates/tests.html b/tests/e2e/test_app/templates/tests.html index 9e582f8..1a4ba55 100644 --- a/tests/e2e/test_app/templates/tests.html +++ b/tests/e2e/test_app/templates/tests.html @@ -29,10 +29,10 @@

{% if user.is_authenticated %} - OIDC-LOGOUT-LINK + OIDC-LOGOUT-LINK {% else %} - OIDC-ANON-LOGOUT-LINK - OIDC-LOGIN-LINK + OIDC-ANON-LOGOUT-LINK + OIDC-LOGIN-LINK {% endif %}

diff --git a/tests/e2e/test_app/views.py b/tests/e2e/test_app/views.py index 248acb0..1fa46ae 100644 --- a/tests/e2e/test_app/views.py +++ b/tests/e2e/test_app/views.py @@ -18,11 +18,23 @@ class OIDCTestSuccessView(OIDCView): http_method_names = ["get"] def get(self, request, *args, **kwargs): - super().get(request, *args, **kwargs) messages.success(request, f"message: {request.user.email} is logged in.") + op_name = self.get_setting("op_name") + if op_name[:5] == "lemon": + number = op_name[-1] + context = { + "op_login_url": f"e2e_test_ll_login_{number}", + "op_logout_url": f"e2e_test_ll_logout_{number}", + } + else: + number = op_name[-1] + context = { + "op_login_url": f"e2e_test_login_{number}", + "op_logout_url": f"e2e_test_logout_{number}", + } template = loader.get_template("tests.html") - return HttpResponse(template.render(request=request)) + return HttpResponse(template.render(request=request, context=context)) class OIDCTestFailureView(OIDCView): @@ -34,11 +46,24 @@ class OIDCTestFailureView(OIDCView): http_method_names = ["get"] def get(self, request, *args, **kwargs): - super().get(request, *args, **kwargs) messages.error(request, "message: something went bad.") + op_name = self.get_setting("op_name") + if op_name[:5] == "lemon": + number = op_name[-1] + context = { + "op_login_url": f"e2e_test_ll_login_{number}", + "op_logout_url": f"e2e_test_ll_logout_{number}", + } + else: + number = op_name[-1] + context = { + "op_login_url": f"e2e_test_login_{number}", + "op_logout_url": f"e2e_test_logout_{number}", + } + template = loader.get_template("tests.html") - return HttpResponse(template.render(request=request)) + return HttpResponse(template.render(request=request, context=context)) class OIDCTestLogoutView(OIDCView): @@ -50,8 +75,20 @@ class OIDCTestLogoutView(OIDCView): http_method_names = ["get"] def get(self, request, *args, **kwargs): - super().get(request, *args, **kwargs) messages.success(request, "message: post logout view.") + op_name = self.get_setting("op_name") + if op_name[:5] == "lemon": + number = op_name[-1] + context = { + "op_login_url": f"e2e_test_ll_login_{number}", + "op_logout_url": f"e2e_test_ll_logout_{number}", + } + else: + number = op_name[-1] + context = { + "op_login_url": f"e2e_test_login_{number}", + "op_logout_url": f"e2e_test_logout_{number}", + } template = loader.get_template("tests.html") - return HttpResponse(template.render(request=request)) + return HttpResponse(template.render(request=request, context=context)) diff --git a/tests/e2e/tests_keycloak.py b/tests/e2e/tests_keycloak.py index 2ff4dc9..a18aedc 100644 --- a/tests/e2e/tests_keycloak.py +++ b/tests/e2e/tests_keycloak.py @@ -4,7 +4,6 @@ from urllib.parse import parse_qs, urlparse import requests -from django.test import override_settings from django.urls import reverse from selenium.webdriver import FirefoxProfile from selenium.webdriver.common.by import By @@ -130,6 +129,7 @@ def test_100_login_page_redirects_to_keycloak_sso(self, *args): """ Test that accessing login callback redirects to the SSO server. """ + self.selenium.delete_all_cookies() logging.basicConfig() logging.getLogger().setLevel(logging.DEBUG) requests_log = logging.getLogger("requests.packages.urllib3") @@ -140,7 +140,7 @@ def test_100_login_page_redirects_to_keycloak_sso(self, *args): print(f"Running Django on {self.live_server_url}") - login_url = reverse("test_login") + login_url = reverse("e2e_test_login_1") response = client.get( f"{self.live_server_url}{login_url}", allow_redirects=False ) @@ -161,7 +161,7 @@ def test_100_login_page_redirects_to_keycloak_sso(self, *args): parsed.path, "/auth/realms/realm1/protocol/openid-connect/auth" ) self.assertEqual(qs["client_id"][0], "app1") - self.assertEqual(qs["redirect_uri"][0], f"{self.live_server_url}/callback") + self.assertEqual(qs["redirect_uri"][0], f"{self.live_server_url}/callback-1/") self.assertEqual(qs["response_type"][0], "code") self.assertEqual(qs["scope"][0], "openid") self.assertTrue(qs["state"][0]) @@ -212,7 +212,15 @@ def _selenium_sso_login( # EC.title_is('') # EC.url_to_be('') // exact - username_input = self.selenium.find_element(By.NAME, "username") + # Uncomment if you want time to detect why it's not working + WebDriverWait(self.selenium, 30).until( + EC.presence_of_element_located((By.NAME, "username")) + ) + + username_input = self.selenium.find_element( + By.NAME, + "username", + ) username_input.send_keys(user) password_input = self.selenium.find_element(By.NAME, "password") password_input.send_keys(password) @@ -231,6 +239,11 @@ def _selenium_anon_logout(self): self.assertTrue("OIDC-ANON-LOGOUT-LINK" in bodyText) # click logout self.selenium.find_element(By.ID, "oidc-anon-logout-link").click() + # FIXME: ensure the anon logout worked for real + # WebDriverWait(self.selenium, 30).until( + # EC.presence_of_element_located((By.NAME, "username")) + # ) + # self.wait.until(EC.url_matches(logout_end_url)) def _selenium_logout(self, end_url): bodyText = self.selenium.find_element(By.TAG_NAME, "body").text @@ -247,10 +260,11 @@ def test_101_selenium_sso_login(self, *args): """ Test a complete working OIDC login/logout. """ + self.selenium.delete_all_cookies() timeout = 5 - login_url = reverse("test_login") - success_url = reverse("test_success") - post_logout_url = reverse("test_logout_done") + login_url = reverse("e2e_test_login_1") + success_url = reverse("e2e_test_success_1") + post_logout_url = reverse("e2e_test_logout_done_1") start_url = f"{self.live_server_url}{login_url}" middle_url = f"{self.live_server_url}{success_url}" end_url = f"{self.live_server_url}{post_logout_url}" @@ -288,14 +302,86 @@ def test_101_selenium_sso_login(self, *args): self.assertTrue("OIDC-LOGIN-LINK" in bodyText) self.assertFalse("OIDC-LOGOUT-LINK" in bodyText) - def test_102_selenium_sso_login__relogin_and_logout(self, *args): + def _test_102_selenium_sso_login_relogin_and_logout_with_mixed_clients_id( + self, *args + ): + """ + Test using sso1 and sso2 with different client_id on same sso server. + """ + self.selenium.delete_all_cookies() + timeout = 5 + login_url = reverse("e2e_test_login_2") + success_url = reverse("e2e_test_success_2") + start_url = f"{self.live_server_url}{login_url}" + middle_url = f"{self.live_server_url}{success_url}" + self.wait = WebDriverWait(self.selenium, timeout) + + # LOGIN 1 on sso2 + self._selenium_sso_login( + start_url, middle_url, "user1", "passwd1", active_sso_session=False + ) + + bodyText = self.selenium.find_element(By.TAG_NAME, "body").text + # check we are logged in + self.assertTrue("You are logged in as user1@example.com" in bodyText) + self.assertFalse("You are logged out" in bodyText) + + # Check the session message is shown + self.assertTrue("message: user1@example.com is logged in." in bodyText) + + # LOGIN 2: reusing existing session on sso2 + self._selenium_sso_login(start_url, middle_url, "", "", active_sso_session=True) + + # check we are logged in + self.assertTrue("You are logged in as user1@example.com" in bodyText) + self.assertFalse("You are logged out" in bodyText) + + # LOGIN 3: using sso1, different client_id on same sso + # we should get automatcally connected without a form submission + login_url1 = reverse("e2e_test_login_1") + success_url1 = reverse("e2e_test_success_1") + post_logout_url1 = reverse("e2e_test_logout_done_1") + start_url1 = f"{self.live_server_url}{login_url1}" + middle_url1 = f"{self.live_server_url}{success_url1}" + end_url1 = f"{self.live_server_url}{post_logout_url1}" + self._selenium_sso_login( + start_url1, middle_url1, "", "", active_sso_session=True + ) + + # check we are logged in + self.assertTrue("You are logged in as user1@example.com" in bodyText) + self.assertFalse("You are logged out" in bodyText) + + # check we have the logout link + self.assertFalse("OIDC-LOGIN-LINK" in bodyText) + self.assertTrue("OIDC-LOGOUT-LINK" in bodyText) + + # click logout + self.selenium.find_element(By.ID, "oidc-logout-link").click() + + self.wait.until(EC.url_matches(end_url1)) + + bodyText = self.selenium.find_element(By.TAG_NAME, "body").text + + # check we are NOT logged in + self.assertFalse("You are logged in as user1@example.com" in bodyText) + self.assertTrue("You are logged out" in bodyText) + + # Check the logout view message is shown + self.assertTrue("message: post logout view." in bodyText) + # check we have the login link + self.assertTrue("OIDC-LOGIN-LINK" in bodyText) + self.assertFalse("OIDC-LOGOUT-LINK" in bodyText) + + def _test_103_selenium_sso_login__relogin_and_logout(self, *args): """ Test a login/logout session, adding a re-login on existing session in the middle """ + self.selenium.delete_all_cookies() timeout = 5 - login_url = reverse("test_login") - success_url = reverse("test_success") - post_logout_url = reverse("test_logout_done") + login_url = reverse("e2e_test_login_1") + success_url = reverse("e2e_test_success_1") + post_logout_url = reverse("e2e_test_logout_done_1") start_url = f"{self.live_server_url}{login_url}" middle_url = f"{self.live_server_url}{success_url}" end_url = f"{self.live_server_url}{post_logout_url}" @@ -342,29 +428,12 @@ def test_102_selenium_sso_login__relogin_and_logout(self, *args): self.assertTrue("OIDC-LOGIN-LINK" in bodyText) self.assertFalse("OIDC-LOGOUT-LINK" in bodyText) - @override_settings( - DJANGO_PYOIDC={ - "sso1": { - "OIDC_CLIENT_ID": "app1", - "CACHE_DJANGO_BACKEND": "default", - "OIDC_PROVIDER_DISCOVERY_URI": "http://localhost:8080/auth/realms/realm1", - "OIDC_CLIENT_SECRET": "secret_app1", - "OIDC_CALLBACK_PATH": "/callback", - "LOGIN_URIS_REDIRECT_ALLOWED_HOSTS": ["testserver"], - "REDIRECT_REQUIRES_HTTPS": False, - "POST_LOGIN_URI_SUCCESS": "/test-success", - "POST_LOGIN_URI_FAILURE": "/test-failure", - "POST_LOGOUT_REDIRECT_URI": "/test-logout-done", - "HOOK_USER_LOGIN": "tests.e2e.test_app.callback:login_callback", - "HOOK_USER_LOGOUT": "tests.e2e.test_app.callback:logout_callback", - }, - }, - ) - def test_103_selenium_sso_session_with_callbacks(self, *args): + def _test_104_selenium_sso_session_with_callbacks(self, *args): + self.selenium.delete_all_cookies() timeout = 5 - login_url = reverse("test_login") - success_url = reverse("test_success") - post_logout_url = reverse("test_logout_done") + login_url = reverse("e2e_test_login_1") + success_url = reverse("e2e_test_success_1") + post_logout_url = reverse("e2e_test_logout_done_1") start_url = f"{self.live_server_url}{login_url}" middle_url = f"{self.live_server_url}{success_url}" end_url = f"{self.live_server_url}{post_logout_url}" @@ -398,28 +467,13 @@ def test_103_selenium_sso_session_with_callbacks(self, *args): # Check the logout callback message is shown self.assertTrue("Logout Callback for user1@example.com." in bodyText) - @override_settings( - DJANGO_PYOIDC={ - "sso1": { - "OIDC_CLIENT_ID": "bad_client_id", - "CACHE_DJANGO_BACKEND": "default", - "OIDC_PROVIDER_DISCOVERY_URI": "http://localhost:8080/auth/realms/realm1", - "OIDC_CLIENT_SECRET": "secret_app1", - "OIDC_CALLBACK_PATH": "/callback", - "POST_LOGOUT_REDIRECT_URI": "/test-logout-done", - "LOGIN_URIS_REDIRECT_ALLOWED_HOSTS": ["testserver"], - "REDIRECT_REQUIRES_HTTPS": False, - "POST_LOGIN_URI_SUCCESS": "/test-success", - "POST_LOGIN_URI_FAILURE": "/test-failure", - }, - }, - ) - def test_104_selenium_sso_failed_login(self, *args): + def _test_105_selenium_sso_failed_login(self, *args): """ - Test a failed SSO login (bad client) + Test a failed SSO login (bad client_id: bad_client_id) """ + self.selenium.delete_all_cookies() timeout = 30 - login_url = reverse("test_login") + login_url = reverse("e2e_test_login_3") start_url = f"{self.live_server_url}{login_url}" wait = WebDriverWait(self.selenium, timeout) # wait -> @@ -436,36 +490,22 @@ def test_104_selenium_sso_failed_login(self, *args): bodyText = self.selenium.find_element(By.TAG_NAME, "body").text # check the SSO rejected our client id + logger.error("*****************************") + logger.error(bodyText) self.assertTrue("We are sorry..." in bodyText) - @override_settings( - DJANGO_PYOIDC={ - "sso1": { - "OIDC_CLIENT_ID": "app1", - "CACHE_DJANGO_BACKEND": "default", - "OIDC_PROVIDER_DISCOVERY_URI": "http://localhost:8080/auth/realms/realm1", - "OIDC_CLIENT_SECRET": "secret_app1", - "OIDC_CALLBACK_PATH": "/callback", - "LOGIN_URIS_REDIRECT_ALLOWED_HOSTS": ["testserver"], - "REDIRECT_REQUIRES_HTTPS": False, - "POST_LOGOUT_REDIRECT_URI": "/test-logout-done", - "POST_LOGIN_URI_SUCCESS": "/test-success", - "POST_LOGIN_URI_FAILURE": "/test-failure", - "HOOK_GET_USER": "tests.e2e.test_app.callback:get_user_with_resource_access_check", - }, - }, - ) - def test_105_selenium_ressource_access_checks(self, *args): + def test_106_selenium_ressource_access_checks(self, *args): """ Check that a resource access check can be performed to prevent access for some users. @see tests.e2e.test_app.callback:get_user_with_resource_access_check """ + self.selenium.delete_all_cookies() timeout = 5 - login_location = reverse("test_login") - success_location = reverse("test_success") - failure_location = reverse("test_failure") - post_logout_location = reverse("test_logout_done") + login_location = reverse("e2e_test_login_4") + success_location = reverse("e2e_test_success_4") + failure_location = reverse("e2e_test_failure_4") + post_logout_location = reverse("e2e_test_logout_done_4") start_url = f"{self.live_server_url}{login_location}" ok_url = f"{self.live_server_url}{success_location}" failure_url = f"{self.live_server_url}{failure_location}" @@ -536,33 +576,16 @@ def test_105_selenium_ressource_access_checks(self, *args): self.assertTrue("message: user1@example.com is logged in." in bodyText) self._selenium_logout(end_url) - @override_settings( - DJANGO_PYOIDC={ - "sso1": { - "OIDC_CLIENT_ID": "app1", - "CACHE_DJANGO_BACKEND": "default", - "OIDC_PROVIDER_DISCOVERY_URI": "http://localhost:8080/auth/realms/realm1", - "OIDC_CLIENT_SECRET": "secret_app1", - "OIDC_CALLBACK_PATH": "/callback", - "LOGIN_URIS_REDIRECT_ALLOWED_HOSTS": ["testserver"], - "REDIRECT_REQUIRES_HTTPS": False, - "POST_LOGOUT_REDIRECT_URI": "/test-logout-done", - "POST_LOGIN_URI_SUCCESS": "/test-success", - "POST_LOGIN_URI_FAILURE": "/test-failure", - "HOOK_GET_USER": "tests.e2e.test_app.callback:get_user_with_minimal_audiences_check", - }, - }, - ) - def test_106_selenium_minimal_audience_checks(self, *args): + def test_107_selenium_minimal_audience_checks(self, *args): """ Check that a minimal audience check can be performed to prevent access for some users. - @see tests.e2e.test_app.callback:get_user_with_minimal_audiences_check """ + self.selenium.delete_all_cookies() timeout = 5 - login_location = reverse("test_login") - success_location = reverse("test_success") - post_logout_location = reverse("test_logout_done") + login_location = reverse("e2e_test_login_5") + success_location = reverse("e2e_test_success_5") + post_logout_location = reverse("e2e_test_logout_done_5") start_url = f"{self.live_server_url}{login_location}" ok_url = f"{self.live_server_url}{success_location}" end_url = f"{self.live_server_url}{post_logout_location}" @@ -664,15 +687,19 @@ def test_201_selenium_front_app_api_call(self, *args): bodyText = self.selenium.find_element(By.ID, "message").get_attribute( "innerHTML" ) - # logger.error(bodyText) + logger.error(bodyText) self.assertTrue("user1@example.com" in bodyText) # FIXME: there a selenium issue in the logout btn selection... - # self._selenium_front_logout() - # - # # After logout, launch unauthorized ajax call - # WebDriverWait(self.selenium, 30).until(EC.element_to_be_clickable((By.ID, "securedBtn"))).click() - # # let the ajax stuff behave - # time.sleep(3) - # bodyText = self.selenium.find_element(By.ID, "message").get_attribute('innerHTML') - # self.assertTrue("Request Forbidden" in bodyText) + self._selenium_front_logout() + + # After logout, launch unauthorized ajax call + WebDriverWait(self.selenium, 30).until( + EC.element_to_be_clickable((By.ID, "securedBtn")) + ).click() + # let the ajax stuff behave + time.sleep(3) + bodyText = self.selenium.find_element(By.ID, "message").get_attribute( + "innerHTML" + ) + self.assertTrue("Request Forbidden" in bodyText) diff --git a/tests/e2e/tests_lemonldapng.py b/tests/e2e/tests_lemonldapng.py index 70c7b69..6940e67 100644 --- a/tests/e2e/tests_lemonldapng.py +++ b/tests/e2e/tests_lemonldapng.py @@ -3,7 +3,6 @@ from urllib.parse import parse_qs, urlparse import requests -from django.test import override_settings from django.urls import reverse from selenium.webdriver.common.by import By from selenium.webdriver.firefox.options import Options @@ -47,7 +46,7 @@ def test_00_login_page_redirects_to_lemonldap_sso(self, *args): print(f"Running Django on {self.live_server_url}") - login_url = reverse("test_login") + login_url = reverse("e2e_test_ll_login_1") response = client.get( f"{self.live_server_url}{login_url}", allow_redirects=False ) @@ -66,7 +65,7 @@ def test_00_login_page_redirects_to_lemonldap_sso(self, *args): self.assertEqual(parsed.netloc, "localhost:8070") self.assertEqual(parsed.path, "/oauth2/authorize") self.assertEqual(qs["client_id"][0], "app1") - self.assertEqual(qs["redirect_uri"][0], f"{self.live_server_url}/callback") + self.assertEqual(qs["redirect_uri"][0], f"{self.live_server_url}/callback-ll-1") self.assertEqual(qs["response_type"][0], "code") self.assertEqual(qs["scope"][0], "openid") self.assertTrue(qs["state"][0]) @@ -115,8 +114,8 @@ def test_01_selenium_sso_login(self, *args): Test a complete working OIDC login. """ timeout = 5 - login_url = reverse("test_login") - success_url = reverse("test_success") + login_url = reverse("e2e_test_ll_login_1") + success_url = reverse("e2e_test_ll_success_1") start_url = f"{self.live_server_url}{login_url}" end_url = f"{self.live_server_url}{success_url}" self.wait = WebDriverWait(self.selenium, timeout) @@ -138,9 +137,9 @@ def test_02_selenium_sso_login_and_logout(self, *args): FIXME : Make this test independant of test #1 """ timeout = 5 - login_url = reverse("test_login") - success_url = reverse("test_success") - post_logout_url = reverse("test_logout_done") + login_url = reverse("e2e_test_ll_login_1") + success_url = reverse("e2e_test_ll_success_1") + post_logout_url = reverse("e2e_test_ll_logout_done_1") start_url = f"{self.live_server_url}{login_url}" middle_url = f"{self.live_server_url}{success_url}" end_url = f"{self.live_server_url}{post_logout_url}" @@ -171,30 +170,11 @@ def test_02_selenium_sso_login_and_logout(self, *args): self.assertTrue("OIDC-LOGIN-LINK" in bodyText) self.assertFalse("OIDC-LOGOUT-LINK" in bodyText) - @override_settings( - DJANGO_PYOIDC={ - "sso1": { - "OIDC_CLIENT_ID": "app1", - "CACHE_DJANGO_BACKEND": "default", - "OIDC_PROVIDER_DISCOVERY_URI": "http://localhost:8070/", - "OIDC_CLIENT_SECRET": "secret_app1", - "OIDC_CALLBACK_PATH": "/callback", - "LOGIN_URIS_REDIRECT_ALLOWED_HOSTS": ["testserver"], - "LOGIN_REDIRECTION_REQUIRES_HTTPS": False, - "POST_LOGIN_URI_SUCCESS": "/test-success", - "POST_LOGIN_URI_FAILURE": "/test-failure", - "POST_LOGOUT_REDIRECT_URI": "/test-logout-done", - "HOOK_USER_LOGIN": "tests.e2e.test_app.callback:login_callback", - "HOOK_USER_LOGOUT": "tests.e2e.test_app.callback:logout_callback", - "LOGOUT_QUERY_STRING_EXTRA_PARAMETERS_DICT": {"confirm": 1}, - }, - }, - ) def test_03_selenium_sso_session_with_callbacks(self, *args): timeout = 5 - login_url = reverse("test_login") - success_url = reverse("test_success") - post_logout_url = reverse("test_logout_done") + login_url = reverse("e2e_test_ll_login_1") + success_url = reverse("e2e_test_ll_success_1") + post_logout_url = reverse("e2e_test_ll_logout_done_1") start_url = f"{self.live_server_url}{login_url}" middle_url = f"{self.live_server_url}{success_url}" end_url = f"{self.live_server_url}{post_logout_url}" @@ -228,28 +208,12 @@ def test_03_selenium_sso_session_with_callbacks(self, *args): # Check the logout callback message is shown self.assertTrue("Logout Callback for rtyler@badwolf.org." in bodyText) - # @override_settings( - # DJANGO_PYOIDC={ - # "sso1": { - # "OIDC_CLIENT_ID": "bad_client_id", - # "CACHE_DJANGO_BACKEND": "default", - # "OIDC_PROVIDER_DISCOVERY_URI": "http://localhost:8070/", - # "OIDC_CLIENT_SECRET": "secret_app1", - # "OIDC_CALLBACK_PATH": "/callback", - # "POST_LOGOUT_REDIRECT_URI": "/test-logout-done", - # "LOGIN_URIS_REDIRECT_ALLOWED_HOSTS": ["testserver"], - # "LOGIN_REDIRECTION_REQUIRES_HTTPS": False, - # "POST_LOGIN_URI_SUCCESS": "/test-success", - # "POST_LOGIN_URI_FAILURE": "/test-failure", - # }, - # }, - # ) # def test_04_selenium_sso_failed_login(self, *args): # """ # Test a failed SSO login (bad client) # """ # timeout = 30 - # login_url = reverse("test_login") + # login_url = reverse("e2e_test_ll_login_2") # start_url = f"{self.live_server_url}{login_url}" # wait = WebDriverWait(self.selenium, timeout) # # wait -> @@ -259,16 +223,4 @@ def test_03_selenium_sso_session_with_callbacks(self, *args): # # EC.title_contains('') # # EC.title_is('') # # EC.url_to_be('') // exact - - -# -# self.selenium.get(start_url) -# # wait for the SSO redirection -# wait.until(EC.url_changes(start_url)) -# -# bodyText = self.selenium.find_element(By.TAG_NAME, "body").text -# # check the SSO rejected our client id -# self.assertTrue("We are sorry..." in bodyText) -# self.assertTrue("Invalid parameter: redirect_uri" in bodyText) -# -# + # (...) diff --git a/tests/e2e/urls.py b/tests/e2e/urls.py index 6f385de..3535bb8 100644 --- a/tests/e2e/urls.py +++ b/tests/e2e/urls.py @@ -50,36 +50,190 @@ class PublicViewSet(viewsets.ModelViewSet): apirouter.register(r"publics", PublicViewSet) urlpatterns = [ - path("login/", OIDCLoginView.as_view(op_name="sso1"), name="test_login"), + path("login-1/", OIDCLoginView.as_view(op_name="sso1"), name="e2e_test_login_1"), + path("login-2/", OIDCLoginView.as_view(op_name="sso2"), name="e2e_test_login_2"), + path("login-3/", OIDCLoginView.as_view(op_name="sso3"), name="e2e_test_login_3"), + path("login-4/", OIDCLoginView.as_view(op_name="sso4"), name="e2e_test_login_4"), + path("login-5/", OIDCLoginView.as_view(op_name="sso5"), name="e2e_test_login_5"), path( - "callback/", + "login-ll-1/", + OIDCLoginView.as_view(op_name="lemon1"), + name="e2e_test_ll_login_1", + ), + path( + "callback-1/", OIDCCallbackView.as_view(op_name="sso1"), - name="test_callback", + name="e2e_test_callback_1", + ), + path( + "callback-2/", + OIDCCallbackView.as_view(op_name="sso2"), + name="e2e_test_callback_2", + ), + path( + "callback-3/", + OIDCCallbackView.as_view(op_name="sso3"), + name="e2e_test_callback_3", + ), + path( + "callback-4/", + OIDCCallbackView.as_view(op_name="sso4"), + name="e2e_test_callback_4", ), path( - "logout/", + "callback-5/", + OIDCCallbackView.as_view(op_name="sso5"), + name="e2e_test_callback_5", + ), + path( + "callback-ll-1/", + OIDCCallbackView.as_view(op_name="lemon1"), + name="e2e_test_callback_ll_1", + ), + path( + "logout-1/", OIDCLogoutView.as_view(op_name="sso1"), - name="test_logout", + name="e2e_test_logout_1", + ), + path( + "logout-2/", + OIDCLogoutView.as_view(op_name="sso2"), + name="e2e_test_logout_2", + ), + path( + "logout-3/", + OIDCLogoutView.as_view(op_name="sso3"), + name="e2e_test_logout_3", + ), + path( + "logout-4/", + OIDCLogoutView.as_view(op_name="sso4"), + name="e2e_test_logout_4", + ), + path( + "logout-5/", + OIDCLogoutView.as_view(op_name="sso5"), + name="e2e_test_logout_5", + ), + path( + "logout-ll-1/", + OIDCLogoutView.as_view(op_name="lemon1"), + name="e2e_test_ll_logout_1", ), path( - "back_channel_logout/", + "back_channel_logout-1/", OIDCBackChannelLogoutView.as_view(op_name="sso1"), - name="test_blogout", + name="e2e_test_blogout_1", + ), + path( + "back_channel_logout-2/", + OIDCBackChannelLogoutView.as_view(op_name="sso2"), + name="e2e_test_blogout_2", + ), + path( + "back_channel_logout-3/", + OIDCBackChannelLogoutView.as_view(op_name="sso3"), + name="e2e_test_blogout_3", + ), + path( + "back_channel_logout-4/", + OIDCBackChannelLogoutView.as_view(op_name="sso4"), + name="e2e_test_blogout_4", + ), + path( + "back_channel_logout-5/", + OIDCBackChannelLogoutView.as_view(op_name="sso5"), + name="e2e_test_blogout_5", ), path( - "test-success/", + "test-success-1/", OIDCTestSuccessView.as_view(op_name="sso1"), - name="test_success", + name="e2e_test_success_1", ), path( - "test-logout-done/", + "test-success-2/", + OIDCTestSuccessView.as_view(op_name="sso2"), + name="e2e_test_success_2", + ), + path( + "test-success-3/", + OIDCTestSuccessView.as_view(op_name="sso3"), + name="e2e_test_success_3", + ), + path( + "test-success-4/", + OIDCTestSuccessView.as_view(op_name="sso4"), + name="e2e_test_success_4", + ), + path( + "test-success-5/", + OIDCTestSuccessView.as_view(op_name="sso5"), + name="e2e_test_success_5", + ), + path( + "test-ll-success-1/", + OIDCTestSuccessView.as_view(op_name="lemon1"), + name="e2e_test_ll_success_1", + ), + path( + "test-logout-done-1/", OIDCTestLogoutView.as_view(op_name="sso1"), - name="test_logout_done", + name="e2e_test_logout_done_1", + ), + path( + "test-logout-done-2/", + OIDCTestLogoutView.as_view(op_name="sso2"), + name="e2e_test_logout_done_2", + ), + path( + "test-logout-done-3/", + OIDCTestLogoutView.as_view(op_name="sso3"), + name="e2e_test_logout_done_3", + ), + path( + "test-logout-done-4/", + OIDCTestLogoutView.as_view(op_name="sso4"), + name="e2e_test_logout_done_4", + ), + path( + "test-logout-done-5/", + OIDCTestLogoutView.as_view(op_name="sso5"), + name="e2e_test_logout_done_5", + ), + path( + "test-ll-logout-done-1/", + OIDCTestLogoutView.as_view(op_name="lemon1"), + name="e2e_test_ll_logout_done_1", ), path( - "test-failure/", + "test-failure-1/", OIDCTestFailureView.as_view(op_name="sso1"), - name="test_failure", + name="e2e_test_failure_1", + ), + path( + "test-failure-2/", + OIDCTestFailureView.as_view(op_name="sso2"), + name="e2e_test_failure_2", + ), + path( + "test-failure-3/", + OIDCTestFailureView.as_view(op_name="sso3"), + name="e2e_test_failure_3", + ), + path( + "test-failure-4/", + OIDCTestFailureView.as_view(op_name="sso4"), + name="e2e_test_failure_4", + ), + path( + "test-failure-5/", + OIDCTestFailureView.as_view(op_name="sso5"), + name="e2e_test_failure_5", + ), + path( + "test-ll-failure-1/", + OIDCTestFailureView.as_view(op_name="lemon1"), + name="e2e_test_ll_failure_1", ), path("api/", include(apirouter.urls)), ] diff --git a/tests/e2e/utils.py b/tests/e2e/utils.py index 83e8af0..bbc2278 100644 --- a/tests/e2e/utils.py +++ b/tests/e2e/utils.py @@ -45,20 +45,21 @@ def _create_server(self, connections_override=None): "django.contrib.messages.middleware.MessageMiddleware", ], DJANGO_PYOIDC={ - "sso1": { - "OIDC_CLIENT_ID": "app1", - "CACHE_DJANGO_BACKEND": "default", - "OIDC_PROVIDER_DISCOVERY_URI": "http://localhost:8070/", - "OIDC_CLIENT_SECRET": "secret_app1", - "OIDC_CALLBACK_PATH": "/callback", - "POST_LOGOUT_REDIRECT_URI": "/test-logout-done", - "LOGIN_URIS_REDIRECT_ALLOWED_HOSTS": ["testserver"], - "LOGIN_REDIRECTION_REQUIRES_HTTPS": False, - "POST_LOGIN_URI_SUCCESS": "/test-success", - "POST_LOGIN_URI_FAILURE": "/test-failure", + "lemon1": { + "provider_class": "LemonLDAPng2Provider", + "client_id": "app1", + "cache_django_backend": "default", + "provider_discovery_uri": "http://localhost:8070/", + "client_secret": "secret_app1", + "oidc_callback_path": "/callback-ll-1", + "post_logout_redirect_uri": "/test-ll-logout-done-1", + "login_uris_redirect_allowed_hosts": ["testserver"], + "login_redirection_requires_https": False, + "post_login_uri_success": "/test-ll-success-1", + "post_login_uri_failure": "/test-ll-failure-1", "HOOK_USER_LOGIN": "tests.e2e.test_app.callback:login_callback", "HOOK_USER_LOGOUT": "tests.e2e.test_app.callback:logout_callback", - "LOGOUT_QUERY_STRING_EXTRA_PARAMETERS_DICT": {"confirm": 1}, + # "oidc_logout_query_string_extra_parameters_dict": {"confirm": 1}, }, }, ) @@ -208,14 +209,26 @@ def loadLemonLDAPFixtures(cls): ) print(" + Create client applications.") - cls.registerClient("app1", "secret_app1", cls.live_server_url) + cls.registerClient( + "app1", + "secret_app1", + cls.live_server_url, + callback_url=f"{cls.live_server_url}/callback-ll-1", + post_login_url=f"{cls.live_server_url}/test-ll-logout-done-1", + ) cls.registerClient( "app1-api", "secret_app1-api", cls.live_server_url, bearerOnly=True, ) - cls.registerClient("app2-full", "secret_app2-full", cls.live_server_url) + cls.registerClient( + "app2-full", + "secret_app2-full", + cls.live_server_url, + callback_url=f"{cls.live_server_url}/callback-ll-2", + post_login_url=f"{cls.live_server_url}/test-ll-logout-done-2", + ) cls.registerClient( "app2-api", "secret_app2-api", cls.live_server_url, bearerOnly=True ) @@ -282,9 +295,11 @@ def docker_lemonldap_command(cls, command: str): return output @classmethod - def registerClient(cls, name, secret, url, bearerOnly=False): - redirectUris = "''" if bearerOnly else f"'{url}/callback'" - logoutRedirectUris = "''" if bearerOnly else f"'{url}/test-logout-done'" + def registerClient( + cls, name, secret, url, callback_url="", post_login_url="", bearerOnly=False + ): + redirectUris = "''" if bearerOnly else f"'{callback_url}'" + logoutRedirectUris = "''" if bearerOnly else f"'{post_login_url}'" cls.docker_lemonldap_command( f"""usr/share/lemonldap-ng/bin/lemonldap-ng-cli -yes 1 \ @@ -326,37 +341,103 @@ def registerClient(cls, name, secret, url, bearerOnly=False): ], DJANGO_PYOIDC={ "sso1": { - "OIDC_CLIENT_ID": "app1", - "CACHE_DJANGO_BACKEND": "default", - "OIDC_PROVIDER_DISCOVERY_URI": "http://localhost:8080/auth/realms/realm1", - "OIDC_CLIENT_SECRET": "secret_app1", - "OIDC_CALLBACK_PATH": "/callback", - "LOGIN_URIS_REDIRECT_ALLOWED_HOSTS": ["testserver"], - "LOGIN_REDIRECTION_REQUIRES_HTTPS": False, - "POST_LOGIN_URI_SUCCESS": "/test-success", - "POST_LOGIN_URI_FAILURE": "/test-failure", - "POST_LOGOUT_REDIRECT_URI": "/test-logout-done", + "provider_class": "KeycloakProvider", + "keycloak_base_uri": "http://localhost:8080/auth/", + "keycloak_realm": "realm1", + "client_id": "app1", + "client_secret": "secret_app1", + "cache_django_backend": "default", + "callback_uri_name": "e2e_test_callback_1", + "post_login_uri_success": "/test-success-1", + "post_login_uri_failure": "/test-failure-1", + "post_logout_redirect_uri": "/test-logout-done-1", "HOOK_USER_LOGIN": "tests.e2e.test_app.callback:login_callback", "HOOK_USER_LOGOUT": "tests.e2e.test_app.callback:logout_callback", + "login_uris_redirect_allowed_hosts": ["testserver"], + "login_redirection_requires_https": False, }, + # sso2 use a different client_id "sso2": { - "OIDC_CLIENT_ID": "app1", - "CACHE_DJANGO_BACKEND": "default", - "OIDC_PROVIDER_DISCOVERY_URI": "http://localhost:8070/auth/realms/realm1", - "OIDC_CLIENT_SECRET": "secret_app1", - "OIDC_CALLBACK_PATH": "/callback", - "POST_LOGIN_URI_SUCCESS": "/test-success", - "POST_LOGIN_URI_FAILURE": "/test-failure", - "POST_LOGOUT_REDIRECT_URI": "/test-logout-done", - "LOGIN_URIS_REDIRECT_ALLOWED_HOSTS": ["testserver"], - "LOGIN_REDIRECTION_REQUIRES_HTTPS": False, + "provider_class": "KeycloakProvider", + "keycloak_base_uri": "http://localhost:8080/auth/", + "keycloak_realm": "realm1", + "client_id": "app2-full", + "client_secret": "secret_app2-full", + "cache_django_backend": "default", + "callback_uri_name": "e2e_test_callback_2", + "post_login_uri_success": "/test-success-2", + "post_login_uri_failure": "/test-failure-2", + "post_logout_redirect_uri": "/test-logout-done-2", + "HOOK_USER_LOGIN": "tests.e2e.test_app.callback:login_callback", + "HOOK_USER_LOGOUT": "tests.e2e.test_app.callback:logout_callback", + "login_uris_redirect_allowed_hosts": ["testserver"], + "login_redirection_requires_https": False, + "hook_get_user": "tests.e2e.test_app.callback:get_user_with_resource_access_check", + }, + # broken client_id + "sso3": { + "client_id": "bad_client_id", + "provider_class": "KeycloakProvider", + "keycloak_base_uri": "http://localhost:8080/auth/", + "keycloak_realm": "realm1", + "client_secret": "secret_app1", + "cache_django_backend": "default", + "callback_uri_name": "e2e_test_callback_3", + "post_login_uri_success": "/test-success-3", + "post_login_uri_failure": "/test-failure-3", + "post_logout_redirect_uri": "/test-logout-done-3", + "HOOK_USER_LOGIN": "tests.e2e.test_app.callback:login_callback", + "HOOK_USER_LOGOUT": "tests.e2e.test_app.callback:logout_callback", + "login_uris_redirect_allowed_hosts": ["testserver"], + "login_redirection_requires_https": False, + }, + # hook_get_user + "sso4": { + "provider_class": "KeycloakProvider", + "keycloak_base_uri": "http://localhost:8080/auth/", + "keycloak_realm": "realm1", + "client_id": "app1", + "client_secret": "secret_app1", + "cache_django_backend": "default", + "callback_uri_name": "e2e_test_callback_4", + "post_login_uri_success": "/test-success-4", + "post_login_uri_failure": "/test-failure-4", + "post_logout_redirect_uri": "/test-logout-done-4", + "login_uris_redirect_allowed_hosts": ["testserver"], + "login_redirection_requires_https": False, + "HOOK_USER_LOGIN": "tests.e2e.test_app.callback:login_callback", + "HOOK_USER_LOGOUT": "tests.e2e.test_app.callback:logout_callback", + "hook_get_user": "tests.e2e.test_app.callback:get_user_with_resource_access_check", + "use_introspection_on_access_tokens": True, + }, + # hook_get_user + "sso5": { + "provider_class": "KeycloakProvider", + "keycloak_base_uri": "http://localhost:8080/auth/", + "keycloak_realm": "realm1", + "client_id": "app1", + "client_secret": "secret_app1", + "cache_django_backend": "default", + "callback_uri_name": "e2e_test_callback_5", + "post_login_uri_success": "/test-success-5", + "post_login_uri_failure": "/test-failure-5", + "post_logout_redirect_uri": "/test-logout-done-5", + "login_uris_redirect_allowed_hosts": ["testserver"], + "login_redirection_requires_https": False, + "HOOK_USER_LOGIN": "tests.e2e.test_app.callback:login_callback", + "HOOK_USER_LOGOUT": "tests.e2e.test_app.callback:logout_callback", + "use_introspection_on_access_tokens": True, + "hook_get_user": "tests.e2e.test_app.callback:get_user_with_minimal_audiences_check", }, - "apisso": { - "OIDC_CLIENT_ID": "app1-api", - "CACHE_DJANGO_BACKEND": "default", - "OIDC_PROVIDER_DISCOVERY_URI": "http://localhost:8080/auth/realms/realm1", - "OIDC_CLIENT_SECRET": "secret_app1-api", - "USED_BY_REST_FRAMEWORK": True, + # API + "drf": { + "client_id": "app1-api", + "cache_django_backend": "default", + "provider_discovery_uri": "http://localhost:8080/auth/realms/realm1", + "client_secret": "secret_app1-api", + "provider_class": "KeycloakProvider", + "keycloak_base_uri": "http://localhost:8080/auth/", + "keycloak_realm": "realm1", }, }, ) @@ -492,7 +573,7 @@ def loadKeycloakFixtures(cls): "secret_app1", cls.live_server_url, serviceAccount=False, - channelLogoutUrl=f"{cls.live_server_url}/oidc/backchannel-logout", + channelLogoutUrl=f"{cls.live_server_url}/back_channel_logout-1/", ) app1_api_id = cls.registerClient( "app1-api", @@ -524,7 +605,11 @@ def loadKeycloakFixtures(cls): serviceAccount=True, ) app2_full_id = cls.registerClient( - "app2-full", "secret_app2-full", cls.live_server_url, bearerOnly=False + "app2-full", + "secret_app2-full", + cls.live_server_url, + bearerOnly=False, + channelLogoutUrl=f"{cls.live_server_url}/back_channel_logout-2/", ) app2_api_id = cls.registerClient( "app2-api", "secret_app2-api", cls.live_server_url, bearerOnly=True diff --git a/tests/test_settings.py b/tests/test_settings.py index 6aa5eb9..6963c85 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -14,7 +14,7 @@ "django_pyoidc", ] -ALLOWED_HOSTS = ["test.django-pyoidc.notatld"] +ALLOWED_HOSTS = ["test.django-pyoidc.notatld", "test2.django-pyoidc.notatld"] MIDDLEWARE = [ "django.contrib.sessions.middleware.SessionMiddleware", diff --git a/tests/tests_cache.py b/tests/tests_cache.py index 17bcddd..f9bd14f 100644 --- a/tests/tests_cache.py +++ b/tests/tests_cache.py @@ -1,7 +1,10 @@ from unittest import mock from unittest.mock import call +from oic.oauth2 import ASConfigurationResponse + from django_pyoidc.client import OIDCClient +from django_pyoidc.settings import OIDCSettingsFactory from django_pyoidc.utils import OIDCCacheBackendForDjango from tests.utils import OIDCTestCase @@ -14,34 +17,36 @@ def test_providers_info_not_cached(self, mocked_provider_config): """ # OIDCClient creation generates one call to Consumer.provider_config sso1 = OIDCClient(op_name="sso1") - mocked_provider_config.assert_has_calls([call("")]) + mocked_provider_config.assert_has_calls([call("http://sso1/realms/realm1")]) assert 1 == mocked_provider_config.call_count # restoring a user session does not create a new call sso1.consumer._backup(sid="1234") OIDCClient(op_name="sso1", session_id="1234") - - mocked_provider_config.assert_has_calls([call("")]) + mocked_provider_config.assert_has_calls([call("http://sso1/realms/realm1")]) assert 1 == mocked_provider_config.call_count # but a new empty Client would add a new metadata call OIDCClient(op_name="sso1") - mocked_provider_config.assert_has_calls([call(""), call("")]) + mocked_provider_config.assert_has_calls( + [call("http://sso1/realms/realm1"), call("http://sso1/realms/realm1")] + ) assert 2 == mocked_provider_config.call_count @mock.patch( "django_pyoidc.client.Consumer.provider_config", - return_value=('[{"foo": "bar"}]'), + return_value=ASConfigurationResponse(), ) def test_providers_info_cached(self, mocked_provider_config): """ - Test that multiple Clients creation with cache means only one provider_info call + Test that multiple Clients creation with cache means only one provider_info call. """ - # empty the caches - cache1 = OIDCCacheBackendForDjango("sso3") + settings1 = OIDCSettingsFactory.get("sso3") + settings2 = OIDCSettingsFactory.get("sso4") + cache1 = OIDCCacheBackendForDjango(settings1) cache1.clear() - cache2 = OIDCCacheBackendForDjango("sso4") + cache2 = OIDCCacheBackendForDjango(settings2) cache2.clear() # OIDCClient creation generates one call to Consumer.provider_config @@ -49,7 +54,7 @@ def test_providers_info_cached(self, mocked_provider_config): mocked_provider_config.assert_has_calls([call("http://sso3/uri")]) assert 1 == mocked_provider_config.call_count - # restoring a user session does not crteate a new call + # restoring a user session does not create a new call sso1.consumer._backup(sid="1234") OIDCClient(op_name="sso3", session_id="1234") mocked_provider_config.assert_has_calls([call("http://sso3/uri")]) @@ -64,6 +69,7 @@ def test_providers_info_cached(self, mocked_provider_config): # BUT adding a new Client with a different op_name will add a call, # as it is not the same cache key OIDCClient(op_name="sso4") + mocked_provider_config.assert_has_calls( [call("http://sso3/uri"), call("http://sso4/uri")] ) diff --git a/tests/tests_library_settings.py b/tests/tests_library_settings.py new file mode 100644 index 0000000..c5a64e1 --- /dev/null +++ b/tests/tests_library_settings.py @@ -0,0 +1,531 @@ +from importlib import import_module +from unittest import mock + +from django.conf import settings +from django.test import override_settings + +from django_pyoidc.client import OIDCClient +from django_pyoidc.exceptions import InvalidOIDCConfigurationException +from tests.utils import OIDCTestCase + +SessionStore = import_module(settings.SESSION_ENGINE).SessionStore + + +class SettingsTestCase(OIDCTestCase): + @override_settings( + DJANGO_PYOIDC={ + "lib_123": { + "oidc_cache_provider_metadata": False, + "oidc_cache_provider_metadata_ttl": 75, + "client_id": "foo", + "client_secret": "secret_app_foo", + "cache_django_backend": "default", + "provider_class": "django_pyoidc.providers.KeycloakProvider", + "keycloak_base_uri": "http://sso_tutut", + "keycloak_realm": "realm_foo", + "oidc_callback_path": "/callback-foo-abc", + "login_uris_redirect_allowed_hosts": ["foo", "bar"], + "login_redirection_requires_https": True, + "post_login_uri_success": "/abc-123", + "post_login_uri_failure": "/def-456", + "post_logout_redirect_uri": "/ghj-789", + "hook_user_login": "tests.e2e.test_app.callback:login_callback", + "hook_user_logout": "tests.e2e.test_app.callback:logout_callback", + }, + }, + ) + @mock.patch("django_pyoidc.client.Consumer.provider_config") + def test_client_settings_extracted_from_django_pyoidc_settings( + self, mocked_provider_config, *args + ): + """ + Test that definitions of DJANGO_PYOIDC in settings can be retrieved in client settings. + """ + sso_client = OIDCClient(op_name="lib_123") + settings = sso_client.get_settings() + self.assertEqual(settings.get("client_id"), "foo") + self.assertEqual(settings.get("client_secret"), "secret_app_foo") + self.assertEqual( + settings.get("provider_discovery_uri"), "http://sso_tutut/realms/realm_foo" + ) + self.assertEqual(settings.get("cache_django_backend"), "default") + self.assertEqual(settings.get("oidc_cache_provider_metadata"), False) + self.assertEqual(settings.get("oidc_cache_provider_metadata_ttl"), 75) + self.assertEqual(settings.get("oidc_callback_path"), "/callback-foo-abc") + self.assertEqual( + settings.get("login_uris_redirect_allowed_hosts"), ["foo", "bar"] + ) + self.assertEqual(settings.get("login_redirection_requires_https"), True) + self.assertEqual(settings.get("post_login_uri_success"), "/abc-123") + self.assertEqual(settings.get("post_login_uri_failure"), "/def-456") + self.assertEqual(settings.get("post_logout_redirect_uri"), "/ghj-789") + self.assertEqual( + settings.get("hook_user_login"), + "tests.e2e.test_app.callback:login_callback", + ) + self.assertEqual( + settings.get("hook_user_logout"), + "tests.e2e.test_app.callback:logout_callback", + ) + + @override_settings( + DJANGO_PYOIDC={ + "lib_238": { + "oidc_cache_provider_metadata": False, + "client_id": "foo2", + "provider_discovery_uri": "http://foo", + }, + }, + ) + @mock.patch("django_pyoidc.client.Consumer.provider_config") + def test_client_settings_defaults(self, mocked_provider_config, *args): + """ + Test that minimal definitions of DJANGO_PYOIDC still get some default settings. + """ + sso_client = OIDCClient(op_name="lib_238") + settings = sso_client.get_settings() + self.assertEqual(settings.get("client_id"), "foo2") + # useful if bad public client is used + self.assertEqual(settings.get("client_secret"), None) + self.assertEqual(settings.get("provider_discovery_uri"), "http://foo") + self.assertEqual(settings.get("cache_django_backend"), "default") + self.assertEqual(settings.get("oidc_cache_provider_metadata"), False) + self.assertEqual(settings.get("oidc_cache_provider_metadata_ttl"), 120) + self.assertEqual(settings.get("login_redirection_requires_https"), True) + self.assertEqual(settings.get("post_login_uri_success"), "/") + self.assertEqual(settings.get("post_login_uri_failure"), "/") + self.assertEqual(settings.get("post_logout_redirect_uri"), "/") + self.assertEqual(settings.get("oidc_callback_path"), "/oidc-callback/") + self.assertEqual(settings.get("login_uris_redirect_allowed_hosts"), None) + self.assertEqual(settings.get("hook_user_login"), None) + self.assertEqual(settings.get("hook_user_logout"), None) + self.assertEqual(settings.get("hook_validate_access_token"), None) + self.assertEqual(settings.get("use_introspection_on_access_tokens"), False) + + @override_settings( + DJANGO_PYOIDC={ + "lib_314": { + "oidc_cache_provider_metadata": False, + "client_id": "foo3", + "client_secret": "secret", + "provider_discovery_uri": "http://foo", + "callback_uri_name": "my_test_callback", + }, + }, + ) + @mock.patch("django_pyoidc.client.Consumer.provider_config") + def test_client_settings_callback_uri_name_to_oidc_callback_path( + self, mocked_provider_config, *args + ): + """ + Test that we can give a named route in callback_uri_name instead of giving oidc_callback_path. + """ + sso_client = OIDCClient(op_name="lib_314") + settings = sso_client.get_settings() + self.assertEqual(settings.get("callback_uri_name"), None) + self.assertEqual(settings.get("oidc_callback_path"), "/callback-xyz/") + + @override_settings( + DJANGO_PYOIDC={ + "lib_315": { + "oidc_cache_provider_metadata": False, + "client_id": "foo3", + "client_secret": "secret", + "provider_discovery_uri": "http://foo", + "CALLBACK_URI_NAME": "my_test_callback", + }, + }, + ) + @mock.patch("django_pyoidc.client.Consumer.provider_config") + def test_client_settings_callback_uri_name_to_oidc_callback_path_upper( + self, mocked_provider_config, *args + ): + """ + Test that we can give a named route in callback_uri_name instead of giving oidc_callback_path. + """ + sso_client = OIDCClient(op_name="lib_315") + settings = sso_client.get_settings() + self.assertEqual(settings.get("callback_uri_name"), None) + self.assertEqual(settings.get("CALLBACK_URI_NAME"), None) + self.assertEqual(settings.get("oidc_callback_path"), "/callback-xyz/") + + @override_settings( + DJANGO_PYOIDC={ + "lib_318": { + "oidc_cache_provider_metadata": False, + "client_id": "foo4", + "client_secret": "secret", + "provider_discovery_uri": "http://foo", + "logout_redirect": "/zorg-1", + "failure_redirect": "/zorg-2", + "success_redirect": "/zorg-3", + "redirect_requires_https": False, + }, + }, + ) + @mock.patch("django_pyoidc.client.Consumer.provider_config") + def test_client_settings_aliases(self, mocked_provider_config, *args): + """ + Test that minimal definitions of DJANGO_PYOIDC still get some default settings. + """ + sso_client = OIDCClient(op_name="lib_318") + settings = sso_client.get_settings() + # Aliases + # logout_redirect -> post_logout_redirect_uri + self.assertEqual(settings.get("logout_redirect"), None) + self.assertEqual(settings.get("post_logout_redirect_uri"), "/zorg-1") + # failure_redirect -> post_login_uri_failure + self.assertEqual(settings.get("failure_redirect"), None) + self.assertEqual(settings.get("post_login_uri_failure"), "/zorg-2") + # success_redirect -> post_login_uri_success + self.assertEqual(settings.get("success_redirect"), None) + self.assertEqual(settings.get("post_login_uri_success"), "/zorg-3") + # redirect_requires_https -> login_redirection_requires_https + self.assertEqual(settings.get("redirect_requires_https"), None) + self.assertEqual(settings.get("login_redirection_requires_https"), False) + + @override_settings( + DJANGO_PYOIDC={ + "lib_319": { + "oidc_cache_provider_metadata": False, + "client_id": "foo4", + "client_secret": "secret", + "provider_discovery_uri": "http://foo", + "LOGOUT_REDIRECT": "/zorg-1", + "FAILURE_REDIRECT": "/zorg-2", + "SUCCESS_REDIRECT": "/zorg-3", + "REDIRECT_REQUIRES_HTTPS": False, + }, + }, + ) + @mock.patch("django_pyoidc.client.Consumer.provider_config") + def test_client_settings_aliases_upper(self, mocked_provider_config, *args): + """ + Test that minimal definitions of DJANGO_PYOIDC still get some default settings. + """ + sso_client = OIDCClient(op_name="lib_319") + settings = sso_client.get_settings() + # Aliases + # logout_redirect -> post_logout_redirect_uri + self.assertEqual(settings.get("LOGOUT_REDIRECT"), None) + self.assertEqual(settings.get("logout_redirect"), None) + self.assertEqual(settings.get("post_logout_redirect_uri"), "/zorg-1") + # failure_redirect -> post_login_uri_failure + self.assertEqual(settings.get("failure_redirect"), None) + self.assertEqual(settings.get("FAILURE_REDIRECT"), None) + self.assertEqual(settings.get("post_login_uri_failure"), "/zorg-2") + # success_redirect -> post_login_uri_success + self.assertEqual(settings.get("success_redirect"), None) + self.assertEqual(settings.get("SUCCESS_REDIRECT"), None) + self.assertEqual(settings.get("post_login_uri_success"), "/zorg-3") + # redirect_requires_https -> login_redirection_requires_https + self.assertEqual(settings.get("redirect_requires_https"), None) + self.assertEqual(settings.get("REDIRECT_REQUIRES_HTTPS"), None) + self.assertEqual(settings.get("login_redirection_requires_https"), False) + + @override_settings( + DJANGO_PYOIDC={ + "lib_371": { + "oidc_cache_provider_metadata": False, + "client_id": "foo3", + "client_secret": "secret", + "provider_discovery_uri": "http://foo", + "callback_uri_name": "my_test_callback", + "use_introspection_on_access_tokens": True, + "hook_validate_access_token": "tests.e2e.test_app.callback:hook_validate_access_token", + }, + }, + ) + @mock.patch("django_pyoidc.client.Consumer.provider_config") + def test_client_settings_no_hook_validate_access_token_if_use_introspection_on_access_tokens( + self, mocked_provider_config, *args + ): + """ + Test that we prevent setting using both use_introspection_on_access_tokens and hook_validate_access_token. + """ + with self.assertRaises(InvalidOIDCConfigurationException) as context: + OIDCClient(op_name="lib_371") + self.assertTrue( + "You cannot define hook_validate_access_token if you use use_introspection_on_access_tokens." + in context.exception.__repr__() + ) + + @override_settings( + DJANGO_PYOIDC={ + "lib_547": { + "oidc_cache_provider_metadata": False, + "client_secret": "secret_app_foo2", + "provider_discovery_uri": "http://foo", + }, + }, + ) + @mock.patch("django_pyoidc.client.Consumer.provider_config") + def test_client_settings_missing_client_id(self, mocked_provider_config, *args): + """ + Test that missing client_id will fail. + """ + with self.assertRaises(InvalidOIDCConfigurationException) as context: + OIDCClient(op_name="lib_547") + self.assertTrue( + "Provider definition does not contain any 'client_id' entry." + in context.exception.__repr__() + ) + + @override_settings( + DJANGO_PYOIDC={ + "lib_548": { + "oidc_cache_provider_metadata": False, + "client_id": "lib_548", + "client_secret": "secret", + }, + "lib_549": { + "oidc_cache_provider_metadata": False, + "client_id": "lib_549", + "client_secret": "secret", + "provider_class": "django_pyoidc.providers.KeycloakProvider", + "keycloak_base_uri": "http://sso_tutut", + }, + "lib_550": { + "oidc_cache_provider_metadata": False, + "client_id": "lib_550", + "client_secret": "secret", + "provider_class": "django_pyoidc.providers.KeycloakProvider", + "keycloak_realm": "toto", + }, + "lib_551": { + "oidc_cache_provider_metadata": False, + "client_id": "lib_551", + "client_secret": "secret", + "provider_class": "django_pyoidc.providers.KeycloakProvider", + "provider_discovery_uri": "http://uvw/xyz/abc/", + }, + "lib_552": { + "oidc_cache_provider_metadata": False, + "client_id": "lib_552", + "client_secret": "secret", + "provider_class": "django_pyoidc.providers.KeycloakProvider", + "provider_discovery_uri": "http://uvw/xyz/realms/", + }, + "lib_553": { + "oidc_cache_provider_metadata": False, + "client_id": "lib_552", + "client_secret": "secret", + "provider_class": "django_pyoidc.providers.KeycloakProvider", + "provider_discovery_uri": "http://uvw/xyz/realms/foo/bar", + }, + }, + ) + @mock.patch("django_pyoidc.client.Consumer.provider_config") + def test_client_settings_missing_provider_discovery_uri( + self, mocked_provider_config, *args + ): + """ + Test that missing provider_discovery_uri (or alternatives) will fail. + """ + with self.assertRaises(InvalidOIDCConfigurationException) as context: + OIDCClient(op_name="lib_548") + self.assertTrue( + "No provider discovery uri provided." in context.exception.__repr__() + ) + with self.assertRaises(TypeError) as context: + OIDCClient(op_name="lib_549") + self.assertTrue( + "Keycloak10Provider requires keycloak_base_uri and keycloak_realm or provider_discovery_uri." + in context.exception.__repr__() + ) + with self.assertRaises(TypeError) as context: + OIDCClient(op_name="lib_550") + self.assertTrue( + "Keycloak10Provider requires keycloak_base_uri and keycloak_realm or provider_discovery_uri." + in context.exception.__repr__() + ) + with self.assertRaises(RuntimeError) as context: + OIDCClient(op_name="lib_551") + self.assertTrue( + "Provided 'provider_discovery_uri' url is not a valid Keycloak metadata url, it does not contains /realms/." + in context.exception.__repr__() + ) + with self.assertRaises(RuntimeError) as context: + OIDCClient(op_name="lib_552") + self.assertTrue( + "Provided 'provider_discovery_uri' url is not a valid Keycloak metadata url, it does not contains /realms/." + in context.exception.__repr__() + ) + with self.assertRaises(RuntimeError) as context: + OIDCClient(op_name="lib_553") + self.assertTrue( + "Cannot extract the keycloak realm from the provided url." + in context.exception.__repr__() + ) + + @override_settings( + DJANGO_PYOIDC={ + "lib_612": { + "oidc_cache_provider_metadata": False, + "client_id": "lib_612", + "client_secret": "secret", + "provider_class": "django_pyoidc.providers.KeycloakProvider", + "keycloak_base_uri": "http://abc/def", + "keycloak_realm": "ghj", + }, + "lib_613": { + "oidc_cache_provider_metadata": False, + "client_id": "lib_613", + "client_secret": "secret", + "provider_class": "django_pyoidc.providers.KeycloakProvider", + "provider_discovery_uri": "http://lmn/opq/realms/rst", + }, + "lib_614": { + "oidc_cache_provider_metadata": False, + "client_id": "lib_613", + "client_secret": "secret", + "provider_class": "django_pyoidc.providers.KeycloakProvider", + "provider_discovery_uri": "http://uvw/xyz/realms/abc/.well-known/openid-configuration", + }, + }, + ) + @mock.patch("django_pyoidc.client.Consumer.provider_config") + def test_client_settings_keycloak_provider_can_generate_provider_discovery_uri_or_not( + self, mocked_provider_config, *args + ): + """ + Test that missing provider_discovery_uri (or alternatives) will fail. + """ + sso_client = OIDCClient(op_name="lib_612") + settings = sso_client.get_settings() + self.assertEqual( + settings.get("provider_discovery_uri"), "http://abc/def/realms/ghj" + ) + sso_client = OIDCClient(op_name="lib_613") + settings = sso_client.get_settings() + self.assertEqual( + settings.get("provider_discovery_uri"), "http://lmn/opq/realms/rst" + ) + sso_client = OIDCClient(op_name="lib_614") + settings = sso_client.get_settings() + self.assertEqual( + settings.get("provider_discovery_uri"), "http://uvw/xyz/realms/abc" + ) + + @override_settings( + DJANGO_PYOIDC={ + "lib_885": { + "OIDC_CACHE_PROVIDER_METADATA": False, + "CLIENT_ID": "foo2", + "CLIENT_SECRET": "secret_app_foo2", + "CACHE_DJANGO_BACKEND": "default", + "PROVIDER_DISCOVERY_URI": "http://localhost:8080/auth/realms/stuff", + "OIDC_CALLBACK_PATH": "/callback-foo-def", + "LOGIN_URIS_REDIRECT_ALLOWED_HOSTS": ["foo2", "bar2"], + "LOGIN_REDIRECTION_REQUIRES_HTTPS": True, + "POST_LOGIN_URI_SUCCESS": "/abc-123-2", + "POST_LOGIN_URI_FAILURE": "/def-456-2", + "POST_LOGOUT_REDIRECT_URI": "/ghj-789-2", + "HOOK_USER_LOGIN": "tests.e2e.test_app.callback:login_callback", + "HOOK_USER_LOGOUT": "tests.e2e.test_app.callback:logout_callback", + }, + }, + ) + @mock.patch("django_pyoidc.client.Consumer.provider_config") + def test_client_settings_extracted_from_django_pyoidc_settings_upper( + self, mocked_provider_config, *args + ): + """ + Test that definitions of DJANGO_PYOIDC in settings can be retrieved in client settings. + + Here testing that cas is not taken into account. + """ + sso_client = OIDCClient(op_name="lib_885") + settings = sso_client.get_settings() + self.assertEqual(settings.get("client_id"), "foo2") + self.assertEqual(settings.get("client_secret"), "secret_app_foo2") + self.assertEqual( + settings.get("provider_discovery_uri"), + "http://localhost:8080/auth/realms/stuff", + ) + self.assertEqual(settings.get("cache_django_backend"), "default") + self.assertEqual(settings.get("oidc_cache_provider_metadata"), False) + self.assertEqual(settings.get("oidc_callback_path"), "/callback-foo-def") + self.assertEqual( + settings.get("login_uris_redirect_allowed_hosts"), ["foo2", "bar2"] + ) + self.assertEqual(settings.get("login_redirection_requires_https"), True) + self.assertEqual(settings.get("post_login_uri_success"), "/abc-123-2") + self.assertEqual(settings.get("post_login_uri_failure"), "/def-456-2") + self.assertEqual(settings.get("post_logout_redirect_uri"), "/ghj-789-2") + self.assertEqual( + settings.get("hook_user_login"), + "tests.e2e.test_app.callback:login_callback", + ) + self.assertEqual( + settings.get("hook_user_logout"), + "tests.e2e.test_app.callback:logout_callback", + ) + + @override_settings( + DJANGO_PYOIDC={ + "lib_901": { + "oidc_cache_provider_metadata": False, + "client_id": "zorg", + "client_secret": "--", + "provider_discovery_uri": "http://foobar/zorg/.well-known/openid-configuration/", + }, + "lib_902": { + "oidc_cache_provider_metadata": False, + "client_id": "zorg", + "client_secret": "--", + "provider_discovery_uri": "http://foobar/zorg2/.well-known/openid-configuration", + }, + }, + ) + @mock.patch("django_pyoidc.client.Consumer.provider_config") + def test_client_settings_well_known_part_removed_from_provider_path( + self, mocked_provider_config, *args + ): + """ + Test giving .well-known/openid-configuration/ paths for provider config paths is supported. + """ + sso_client = OIDCClient(op_name="lib_901") + settings = sso_client.get_settings() + self.assertEqual(settings.get("provider_discovery_uri"), "http://foobar/zorg") + sso_client = OIDCClient(op_name="lib_902") + settings = sso_client.get_settings() + self.assertEqual(settings.get("provider_discovery_uri"), "http://foobar/zorg2") + + @override_settings( + DJANGO_PYOIDC={ + "lib_865": { + "oidc_cache_provider_metadata": False, + "client_id": "eorg", + "client_secret": "--", + "provider_discovery_uri": "http://foo", + }, + }, + ) + @mock.patch("django_pyoidc.client.Consumer.provider_config") + def test_client_settings_globals(self, mocked_provider_config, *args): + """ + Test that some globale settings are defined. + """ + sso_client = OIDCClient(op_name="lib_865") + settings = sso_client.get_settings() + self.assertEqual(settings.get("CACHE_DJANGO_BACKEND"), "default") + self.assertEqual(settings.get("cache_django_backend"), "default") + self.assertEqual(settings.get("OIDC_CACHE_PROVIDER_METADATA"), False) + self.assertEqual(settings.get("oidc_cache_provider_metadata"), False) + self.assertEqual(settings.get("OIDC_CACHE_PROVIDER_METADATA_TTL"), 120) + self.assertEqual(settings.get("oidc_cache_provider_metadata_ttl"), 120) + self.assertEqual(settings.get("USE_INTROSPECTION_ON_ACCESS_TOKENS"), False) + self.assertEqual(settings.get("use_introspection_on_access_tokens"), False) + + @mock.patch("django_pyoidc.client.Consumer.provider_config") + def test_client_use_introspection_on_access_tokens_defaults_to_true_if_name_drf( + self, *args + ): + """ + Test that some globale settings are defined. + """ + sso_client = OIDCClient(op_name="drf") + settings = sso_client.get_settings() + self.assertEqual(settings.get("USE_INTROSPECTION_ON_ACCESS_TOKENS"), True) + self.assertEqual(settings.get("use_introspection_on_access_tokens"), True) diff --git a/tests/tests_session.py b/tests/tests_session.py index d23d913..b887bc9 100644 --- a/tests/tests_session.py +++ b/tests/tests_session.py @@ -1,7 +1,10 @@ from unittest import mock from unittest.mock import call +from oic.oauth2 import ASConfigurationResponse + from django_pyoidc.client import OIDCClient +from django_pyoidc.settings import OIDCSettingsFactory from django_pyoidc.utils import OIDCCacheBackendForDjango from tests.utils import OIDCTestCase @@ -10,19 +13,23 @@ class SessionTestCase(OIDCTestCase): @mock.patch("django_pyoidc.client.Consumer.provider_config") def test_session_isolation_between_providers(self, mocked_provider_config): """ - Test that different SSO providers using same SID do not conflict + Test that different SSO providers using same SID do not conflict. """ sso1 = OIDCClient(op_name="sso1") sso2 = OIDCClient(op_name="sso2") - mocked_provider_config.assert_has_calls([call(""), call("")]) + mocked_provider_config.assert_has_calls( + [call("http://sso1/realms/realm1"), call("http://sso2/uri")] + ) assert 2 == mocked_provider_config.call_count sso1.consumer._backup(sid="1234") sso2.consumer._backup(sid="1234") # no more calls - mocked_provider_config.assert_has_calls([call(""), call("")]) + mocked_provider_config.assert_has_calls( + [call("http://sso1/realms/realm1"), call("http://sso2/uri")] + ) assert 2 == mocked_provider_config.call_count client1 = OIDCClient(op_name="sso1", session_id="1234") @@ -31,14 +38,16 @@ def test_session_isolation_between_providers(self, mocked_provider_config): client2 = OIDCClient(op_name="sso2", session_id="1234") # no more calls - mocked_provider_config.assert_has_calls([call(""), call("")]) + mocked_provider_config.assert_has_calls( + [call("http://sso1/realms/realm1"), call("http://sso2/uri")] + ) assert 2 == mocked_provider_config.call_count self.assertEqual(client2.consumer.client_id, "2") @mock.patch( "django_pyoidc.client.Consumer.provider_config", - return_value=('[{"foo": "bar"}]'), + return_value=ASConfigurationResponse(), ) def test_session_isolation_between_providers_cached(self, mocked_provider_config): """ @@ -46,8 +55,10 @@ def test_session_isolation_between_providers_cached(self, mocked_provider_config """ # empty the caches - cache1 = OIDCCacheBackendForDjango("sso3") - cache2 = OIDCCacheBackendForDjango("sso4") + settings1 = OIDCSettingsFactory.get("sso3") + settings2 = OIDCSettingsFactory.get("sso4") + cache1 = OIDCCacheBackendForDjango(settings1) + cache2 = OIDCCacheBackendForDjango(settings2) cache1.clear() cache2.clear() diff --git a/tests/tests_urls.py b/tests/tests_urls.py new file mode 100644 index 0000000..176368e --- /dev/null +++ b/tests/tests_urls.py @@ -0,0 +1,73 @@ +from unittest import mock + +from django.conf import settings +from django.urls import reverse + +from tests.utils import OIDCTestCase + + +class UrlsTestCase(OIDCTestCase): + def test_reverse_urls_are_defined(self, *args): + """ + Test that urls defined in our test settings are working. + """ + self.assertEqual( + reverse("test_blogout"), + "/back_channel_logout-xyz/", + ) + self.assertEqual( + reverse("test_login"), + "/login-xyz/", + ) + self.assertEqual( + reverse("test_logout"), + "/logout-xyz/", + ) + self.assertEqual( + reverse("test_logout"), + "/logout-xyz/", + ) + + @mock.patch("django_pyoidc.client.Consumer.provider_config") + def test_callback_path_with_uri_name(self, *args): + """ + Test that using callback_uri_name we have the right callback path. + + "sso1" definition is not using oidc_callback_path but a named route + with callback_uri_name instead. + """ + host = settings.DJANGO_PYOIDC["sso1"]["login_uris_redirect_allowed_hosts"][0] + response = self.client.get( + reverse("test_login"), + data={"next": f"https://{host}/foo/bar"}, + SERVER_NAME=host, + ) + self.assertEqual(response.status_code, 302) + location = response.headers["Location"] + elements = location.split("?") + query_string = elements[1] + arguments = query_string.split("&") + self.assertIn("client_id=1", arguments) + self.assertIn(f"redirect_uri=http%3A%2F%2F{host}%2Fcallback-xyz%2F", arguments) + + @mock.patch("django_pyoidc.client.Consumer.provider_config") + def test_callback_path_with_oidc_callback_path(self, *args): + """ + Test that using only oidc_callback_path we have the right callback path. + + "sso2" definition does not contain callback_uri_name but oidc_callback_path + instead. + """ + host = settings.DJANGO_PYOIDC["sso2"]["login_uris_redirect_allowed_hosts"][0] + response = self.client.get( + reverse("test_login_2"), + data={"next": f"https://{host}/foo/bar"}, + SERVER_NAME=host, + ) + self.assertEqual(response.status_code, 302) + location = response.headers["Location"] + elements = location.split("?") + query_string = elements[1] + arguments = query_string.split("&") + self.assertIn("client_id=2", arguments) + self.assertIn(f"redirect_uri=http%3A%2F%2F{host}%2Fcallback-wtf%2F", arguments) diff --git a/tests/tests_views.py b/tests/tests_views.py index a93c9c6..7b3745d 100644 --- a/tests/tests_views.py +++ b/tests/tests_views.py @@ -24,7 +24,7 @@ class LoginViewTestCase(OIDCTestCase): ) def test_redirect_uri_management_no_next_params(self, *args): """ - Test that without a next parameter we are redirected to 'POST_LOGOUT_REDIRECT_URI' + Test that without a next parameter we are redirected to 'post_logout_redirect_uri' """ response = self.client.get( reverse("test_login"), @@ -35,7 +35,7 @@ def test_redirect_uri_management_no_next_params(self, *args): ) self.assertEqual( self.client.session["oidc_login_next"], - settings.DJANGO_PYOIDC["sso1"]["POST_LOGIN_URI_SUCCESS"], + settings.DJANGO_PYOIDC["sso1"]["post_login_uri_success"], ) @mock.patch("django_pyoidc.client.Consumer.provider_config") @@ -45,13 +45,13 @@ def test_redirect_uri_management_no_next_params(self, *args): ) def test_redirect_uri_management_next_to_samesite(self, *args): """ - Test that redirecting to a site allowed in 'LOGIN_URIS_REDIRECT_ALLOWED_HOSTS' works + Test that redirecting to a site allowed in 'login_uris_redirect_allowed_hosts' works """ response = self.client.get( reverse("test_login"), data={ "next": "https://" - + settings.DJANGO_PYOIDC["sso1"]["LOGIN_URIS_REDIRECT_ALLOWED_HOSTS"][0] + + settings.DJANGO_PYOIDC["sso1"]["login_uris_redirect_allowed_hosts"][0] + "/myview/details" }, SERVER_NAME="test.django-pyoidc.notatld", @@ -71,13 +71,13 @@ def test_redirect_uri_management_next_to_samesite(self, *args): ) def test_redirect_uri_management_next_follows_https_requires(self, *args): """ - Test that trying to redirect to a non https site when LOGIN_REDIRECTION_REQUIRES_HTTPS is set to True does not work + Test that trying to redirect to a non https site when login_redirection_requires_https is set to True does not work """ response = self.client.get( reverse("test_login"), data={ "next": "http://" - + settings.DJANGO_PYOIDC["sso1"]["LOGIN_URIS_REDIRECT_ALLOWED_HOSTS"][0] + + settings.DJANGO_PYOIDC["sso1"]["login_uris_redirect_allowed_hosts"][0] + "/myview/details" }, SERVER_NAME="test.django-pyoidc.notatld", @@ -87,17 +87,13 @@ def test_redirect_uri_management_next_follows_https_requires(self, *args): ) self.assertEqual( self.client.session["oidc_login_next"], - settings.DJANGO_PYOIDC["sso1"]["POST_LOGIN_URI_SUCCESS"], + settings.DJANGO_PYOIDC["sso1"]["post_login_uri_success"], ) @mock.patch("django_pyoidc.client.Consumer.provider_config") - @mock.patch( - "django_pyoidc.client.Consumer.begin", - return_value=(1234, "https://sso.notatld"), - ) - def test_redirect_uri_management_next_to_disallowed_site(self, *args): + def test_redirect_uri_bad_server_name(self, *args): """ - Test that trying to redirect to a site not allowed in 'LOGIN_URIS_REDIRECT_ALLOWED_HOSTS' results in HTTP 400 + Test that requesting django oidc with bad host name is rejected (HTTP 400). """ response = self.client.get( reverse("test_login"), @@ -106,16 +102,52 @@ def test_redirect_uri_management_next_to_disallowed_site(self, *args): ) self.assertEqual(response.status_code, 400) + @mock.patch("django_pyoidc.client.Consumer.provider_config") + def test_redirect_uri_management_next_to_disallowed_site(self, *args): + """ + Test that trying to redirect to a site not allowed in 'login_uris_redirect_allowed_hosts' results in ignored instruction. + + The library will reject this host and use the 'post_login_uri_success' setting instead. + """ + response = self.client.get( + reverse("test_login"), + data={"next": "http://test.hacker.notatld/myview/details"}, + SERVER_NAME="test.django-pyoidc.notatld", + ) + self.assertEqual(response.status_code, 302) + self.assertEqual( + self.client.session["oidc_login_next"], + settings.DJANGO_PYOIDC["sso1"]["post_login_uri_success"], + ) + + @mock.patch("django_pyoidc.client.Consumer.provider_config") + def test_redirect_uri_management_next_to_disallowed_site2(self, *args): + """ + Test that trying to redirect to a site not allowed in 'login_uris_redirect_allowed_hosts' results in ignored instruction. + + The library will reject this host and use "/" instead (as 'post_login_uri_success' is undefined for sso2). + """ + response = self.client.get( + reverse("test_login_2"), + data={"next": "http://test.hacker.notatld/myview/details"}, + SERVER_NAME="test2.django-pyoidc.notatld", + ) + self.assertEqual(response.status_code, 302) + self.assertEqual( + self.client.session["oidc_login_next"], + "/", + ) + @mock.patch("django_pyoidc.client.Consumer.provider_config") def test_oidc_session_is_saved(self, *args): """ - Test that the OIDC client is saved on login request, and that the returned session ID allows us to restore the client + Test that the OIDC client is saved on login request, and that the returned session ID allows us to restore the client. """ response = self.client.get( reverse("test_login"), data={ "next": "https://" - + settings.DJANGO_PYOIDC["sso1"]["LOGIN_URIS_REDIRECT_ALLOWED_HOSTS"][0] + + settings.DJANGO_PYOIDC["sso1"]["login_uris_redirect_allowed_hosts"][0] + "/myview/details" }, SERVER_NAME="test.django-pyoidc.notatld", @@ -196,9 +228,9 @@ def setUpTestData(cls): def test_callback_but_no_sid_on_our_side(self): """ - Test that receiving a random request without any session states is well handled + Test that receiving a random request without any session states is well handled. """ - response = self.client.get(reverse("test_callback")) + response = self.client.get(reverse("my_test_callback")) self.assertRedirects(response, "/logout_failure", fetch_redirect_response=False) @@ -209,7 +241,7 @@ def test_callback_but_no_sid_on_our_side(self): @mock.patch("django_pyoidc.client.Consumer.restore") def test_callback_but_state_mismatch(self, mocked_restore, mocked_parse_authz): """ - Test that receiving a callback with a wrong state parameter results in an HTTP 4XX error + Test that receiving a callback with a wrong state parameter results in an HTTP 4XX error. """ self.client.force_login(self.user) @@ -219,7 +251,7 @@ def test_callback_but_state_mismatch(self, mocked_restore, mocked_parse_authz): session["oidc_sid"] = state session.save() - response = self.client.get(reverse("test_callback")) + response = self.client.get(reverse("my_test_callback")) self.assertEqual(response.status_code, 400) mocked_restore.assert_called_once_with(state) mocked_parse_authz.assert_called_once() @@ -234,7 +266,7 @@ def test_callback_but_state_mismatch(self, mocked_restore, mocked_parse_authz): ) @mock.patch( "django_pyoidc.client.Consumer.complete", - return_value={"id_token": IdToken(iss="fake")}, + return_value={"id_token": IdToken(iss="fake"), "access_token": "--"}, ) @mock.patch("django_pyoidc.client.Consumer.restore") def test_callback_no_session_state_provided_invalid_user( @@ -257,7 +289,7 @@ def test_callback_no_session_state_provided_invalid_user( session["oidc_sid"] = state session.save() - response = self.client.get(reverse("test_callback")) + response = self.client.get(reverse("my_test_callback")) mocked_restore.assert_called_once_with(state) mocked_complete.assert_called_once_with(state=state, session_state=None) mocked_parse_authz.assert_called_once() @@ -265,8 +297,7 @@ def test_callback_no_session_state_provided_invalid_user( mocked_get_user.assert_called_once_with( { "info_token_claims": {}, - "access_token_jwt": None, - "access_token_claims": None, + "access_token_jwt": "--", "id_token_claims": {"iss": "fake"}, } ) @@ -274,7 +305,7 @@ def test_callback_no_session_state_provided_invalid_user( self.assertRedirects(response, "/logout_failure", fetch_redirect_response=False) self.assertEqual(OIDCSession.objects.all().count(), 0) - @mock.patch("django_pyoidc.views.OIDCView.call_callback_function") + @mock.patch("django_pyoidc.views.OIDCView.call_user_login_callback_function") @mock.patch( "django_pyoidc.client.Consumer.parse_authz", return_value=({"state": "test_id_12345"}, None, None), @@ -283,7 +314,7 @@ def test_callback_no_session_state_provided_invalid_user( @mock.patch("django_pyoidc.client.Consumer.get_user_info") @mock.patch( "django_pyoidc.client.Consumer.complete", - return_value={"id_token": IdToken(iss="fake")}, + return_value={"id_token": IdToken(iss="fake"), "access_token": "--"}, ) @mock.patch("django_pyoidc.client.Consumer.restore") def test_callback_no_session_state_provided_valid_user( @@ -293,7 +324,7 @@ def test_callback_no_session_state_provided_valid_user( mocked_get_user_info, mocked_get_user, mocked_parse_authz, - mocked_call_callback_function, + mocked_call_user_login_callback_function, ): """ Test that receiving a callback for a user that gets validated by the developer-provided function 'get_user' @@ -314,7 +345,7 @@ def test_callback_no_session_state_provided_valid_user( dummy_user = self.user mocked_get_user.return_value = dummy_user - response = self.client.get(reverse("test_callback")) + response = self.client.get(reverse("my_test_callback")) with self.subTest("pyoidc calls are performed"): mocked_restore.assert_called_once_with(state) @@ -325,8 +356,7 @@ def test_callback_no_session_state_provided_valid_user( mocked_get_user.assert_called_once_with( { "info_token_claims": user_info_dict, - "access_token_jwt": None, - "access_token_claims": None, + "access_token_jwt": "--", "id_token_claims": {"iss": "fake"}, } ) @@ -343,15 +373,15 @@ def test_callback_no_session_state_provided_valid_user( self.assertEqual(session.sub, user_info_dict["sub"]) self.assertEqual(session.state, state) self.assertEqual(session.cache_session_key, self.client.session.session_key) - mocked_call_callback_function.assert_called_once() + mocked_call_user_login_callback_function.assert_called_once() - @mock.patch("django_pyoidc.views.OIDCView.call_callback_function") + @mock.patch("django_pyoidc.views.OIDCView.call_user_login_callback_function") @mock.patch("django_pyoidc.client.Consumer.parse_authz") @mock.patch("django_pyoidc.engine.get_user_by_email") @mock.patch("django_pyoidc.client.Consumer.get_user_info") @mock.patch( "django_pyoidc.client.Consumer.complete", - return_value={"id_token": IdToken(iss="fake")}, + return_value={"id_token": IdToken(iss="fake"), "access_token": "--"}, ) @mock.patch("django_pyoidc.client.Consumer.restore") def test_callback_with_session_state_provided_valid_user( @@ -361,7 +391,7 @@ def test_callback_with_session_state_provided_valid_user( mocked_get_user_info, mocked_get_user, mocked_parse_authz, - mocked_call_callback_function, + mocked_call_user_login_callback_function, ): """ Test that receiving a callback with a session state (SID) for a user that gets validated by the developer-provided @@ -386,7 +416,7 @@ def test_callback_with_session_state_provided_valid_user( dummy_user = self.user mocked_get_user.return_value = dummy_user - response = self.client.get(reverse("test_callback")) + response = self.client.get(reverse("my_test_callback")) with self.subTest("pyoidc calls are performed"): mocked_restore.assert_called_once_with(state) @@ -399,8 +429,7 @@ def test_callback_with_session_state_provided_valid_user( mocked_get_user.assert_called_once_with( { "info_token_claims": user_info_dict, - "access_token_jwt": None, - "access_token_claims": None, + "access_token_jwt": "--", "id_token_claims": {"iss": "fake"}, } ) @@ -419,13 +448,59 @@ def test_callback_with_session_state_provided_valid_user( self.assertEqual(session.state, state) self.assertEqual(session.cache_session_key, self.client.session.session_key) - mocked_call_callback_function.assert_called_once() + mocked_call_user_login_callback_function.assert_called_once() + + @mock.patch( + "django_pyoidc.client.Consumer.parse_authz", + return_value=({"state": "test_id_12345"}, None, None), + ) + @mock.patch("django_pyoidc.engine.get_user_by_email", return_value=None) + @mock.patch( + "django_pyoidc.client.Consumer.get_user_info", return_value=OpenIDSchema() + ) + @mock.patch( + "django_pyoidc.client.Consumer.complete", + return_value={"id_token": IdToken(iss="fake"), "access_token": "--"}, + ) + @mock.patch("django_pyoidc.client.Consumer.restore") + @mock.patch("tests.e2e.test_app.callback.hook_validate_access_token") + def test_callback_calling_hook_validate_access_token( + self, + mocked_user_access_token_hook, + mocked_restore, + mocked_complete, + mocked_get_user_info, + mocked_get_user, + mocked_parse_authz, + ): + """ + Test that receiving a callback for a user that does not get validated by the developer-provided function 'get_user' + does not get logged in + """ + self.client.force_login(self.user) + + state = "test_id_12345" + + session = self.client.session + session["oidc_sid"] = state + session.save() + + # sso2 contains a definition with a hook for access token validation + response = self.client.get(reverse("my_test_callback_sso2")) + mocked_restore.assert_called_once_with(state) + mocked_complete.assert_called_once_with(state=state, session_state=None) + mocked_parse_authz.assert_called_once() + mocked_get_user_info.assert_called_once_with(state=state) + mocked_get_user.assert_called_once() + + self.assertRedirects(response, "/", fetch_redirect_response=False) + self.assertEqual(OIDCSession.objects.all().count(), 0) + mocked_user_access_token_hook.assert_called_once() class BackchannelLogoutTestCase(OIDCTestCase): @classmethod def setUpTestData(cls): - """ To generate an other jwk key : 'jose jwk gen -i '{"alg":"HS256"}' -o oct.jwk' """ diff --git a/tests/urls.py b/tests/urls.py new file mode 100644 index 0000000..12e8d65 --- /dev/null +++ b/tests/urls.py @@ -0,0 +1,33 @@ +from django.urls import path + +from django_pyoidc.views import ( + OIDCBackChannelLogoutView, + OIDCCallbackView, + OIDCLoginView, + OIDCLogoutView, +) + +urlpatterns = [ + path("login-xyz/", OIDCLoginView.as_view(op_name="sso1"), name="test_login"), + path("login-wtf/", OIDCLoginView.as_view(op_name="sso2"), name="test_login_2"), + path( + "callback-xyz/", + OIDCCallbackView.as_view(op_name="sso1"), + name="my_test_callback", + ), + path( + "callback-wtf/", + OIDCCallbackView.as_view(op_name="sso2"), + name="my_test_callback_sso2", + ), + path( + "logout-xyz/", + OIDCLogoutView.as_view(op_name="sso1"), + name="test_logout", + ), + path( + "back_channel_logout-xyz/", + OIDCBackChannelLogoutView.as_view(op_name="sso1"), + name="test_blogout", + ), +] diff --git a/tests/utils.py b/tests/utils.py index 923d7fd..a7ce243 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -9,37 +9,49 @@ DJANGO_PYOIDC={ "sso1": { "OIDC_CACHE_PROVIDER_METADATA": False, - "OIDC_CLIENT_ID": "1", - "CACHE_DJANGO_BACKEND": "default", - "OIDC_PROVIDER_DISCOVERY_URI": "", - "OIDC_CLIENT_SECRET": "", - "OIDC_CALLBACK_PATH": "/callback", - "POST_LOGIN_URI_SUCCESS": "/default/success", - "LOGIN_URIS_REDIRECT_ALLOWED_HOSTS": ["test.django-pyoidc.notatld"], - "LOGIN_REDIRECTION_REQUIRES_HTTPS": True, - "POST_LOGOUT_REDIRECT_URI": "/logoutdone", - "POST_LOGIN_URI_FAILURE": "/logout_failure", + "client_id": "1", + "client_secret": "--", + "cache_django_backend": "default", + "provider_class": "KeycloakProvider", + "keycloak_base_uri": "http://sso1", + "keycloak_realm": "realm1", + "callback_uri_name": "my_test_callback", + "post_login_uri_success": "/default/success", + "login_uris_redirect_allowed_hosts": ["test.django-pyoidc.notatld"], + "login_redirection_requires_https": True, + "post_logout_redirect_uri": "/logoutdone", + "post_login_uri_failure": "/logout_failure", + "use_introspection_on_access_tokens": False, }, "sso2": { "OIDC_CACHE_PROVIDER_METADATA": False, - "OIDC_CLIENT_ID": "2", - "CACHE_DJANGO_BACKEND": "default", - "OIDC_PROVIDER_DISCOVERY_URI": "", - "OIDC_CLIENT_SECRET": "", + "client_id": "2", + "client_secret": "--", + "cache_django_backend": "default", + "provider_discovery_uri": "http://sso2/uri", + "login_uris_redirect_allowed_hosts": ["test2.django-pyoidc.notatld"], + "oidc_callback_path": "/callback-wtf/", + "hook_validate_access_token": "tests.e2e.test_app.callback:hook_validate_access_token", }, "sso3": { - "OIDC_CACHE_PROVIDER_METADATA": True, - "OIDC_CLIENT_ID": "3", - "CACHE_DJANGO_BACKEND": "default", - "OIDC_PROVIDER_DISCOVERY_URI": "http://sso3/uri", - "OIDC_CLIENT_SECRET": "", + "oidc_cache_provider_metadata": True, + "client_id": "3", + "client_secret": "--", + "cache_django_backend": "default", + "provider_discovery_uri": "http://sso3/uri", }, "sso4": { "OIDC_CACHE_PROVIDER_METADATA": True, - "OIDC_CLIENT_ID": "4", - "CACHE_DJANGO_BACKEND": "default", - "OIDC_PROVIDER_DISCOVERY_URI": "http://sso4/uri", - "OIDC_CLIENT_SECRET": "", + "client_id": "4", + "client_secret": "--", + "cache_django_backend": "default", + "provider_discovery_uri": "http://sso4/uri/.well-known/openid-configuration", + }, + "drf": { + "OIDC_CACHE_PROVIDER_METADATA": False, + "client_id": "drf-api", + "client_secret": "--", + "provider_discovery_uri": "http://sso5/uri/.well-known/openid-configuration", }, } )