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
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",
},
}
)