Skip to content

Callback routes #17

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Mar 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ update_all_deps : requirements/requirements.txt requirements/requirements-dev.tx
requirements/requirements.txt : pyproject.toml
pip-compile -o $@ $< --extra drf

requirements/requirements-dev.txt : requirements/requirements-dev.in requirements/requirements/requirements.txt
requirements/requirements-dev.txt : requirements/requirements-dev.in requirements/requirements.txt
pip-compile -o $@ $<

requirements/requirements-test.txt : requirements/requirements-test.in requirements/requirements-dev.in requirements/requirements.txt
Expand Down
86 changes: 86 additions & 0 deletions django_pyoidc/helper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
from typing import Any, List

from django.urls import URLPattern, path

from django_pyoidc.settings import OIDCSettings, OIDCSettingsFactory


class OIDCHelper:
"""
This is a utility class providing a wrapper around the provider, the settings and the views.
"""

def __init__(self, *args: Any, op_name: str, **kwargs: Any):
"""
Parameters:
op_name (str): the name of the sso provider that you are using.
This should exists as a key in the DJANGO_PYOIDC settings section.
"""
self.op_name = op_name
self.opsettings: OIDCSettings = OIDCSettingsFactory.get(self.op_name)
self.provider = self.opsettings.provider

@property
def login_uri_name(self) -> str:
"""
The login viewname (use in :func:`django:django.urls.reverse` django directive for example) of this configuration
"""
return f"{self.op_name}-login"

@property
def logout_uri_name(self) -> str:
"""
The logout viewname (use in :func:`django:django.urls.reverse` django directive for example) of this configuration
"""
return f"{self.op_name}-logout"

@property
def callback_uri_name(self) -> str:
"""
The callback viewname (use in :func:`django:django.urls.reverse` django directive for example) of this configuration
"""
return f"{self.op_name}-callback"

@property
def backchannel_logout_uri_name(self) -> str:
"""
The backchannel logout viewname (use in :func:`django:django.urls.reverse` django directive for example) of this configuration
"""
return f"{self.op_name}-backchannel-logout"

def get_urlpatterns(self) -> List[URLPattern]:
"""
Returns:
A list of urllpatterns to be included using :func:`django:django.urls.include` in your url configuration
"""
from django_pyoidc.views import (
OIDCBackChannelLogoutView,
OIDCCallbackView,
OIDCLoginView,
OIDCLogoutView,
)

oidc_paths_prefix = self.opsettings.get("oidc_paths_prefix")
result = [
path(
f"{oidc_paths_prefix}-callback",
OIDCCallbackView.as_view(op_name=self.op_name),
name=self.callback_uri_name,
),
path(
f"{oidc_paths_prefix}-login",
OIDCLoginView.as_view(op_name=self.op_name),
name=self.login_uri_name,
),
path(
f"{oidc_paths_prefix}-logout",
OIDCLogoutView.as_view(op_name=self.op_name),
name=self.logout_uri_name,
),
path(
f"{oidc_paths_prefix}-backchannel-logout",
OIDCBackChannelLogoutView.as_view(op_name=self.op_name),
name=self.backchannel_logout_uri_name,
),
]
return result
59 changes: 8 additions & 51 deletions django_pyoidc/providers/provider.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
from typing import Any, Dict, List, Optional, TypedDict

from django.urls import path
from typing import Any, Dict, Optional, TypedDict

ProviderConfig = TypedDict(
"ProviderConfig",
Expand All @@ -9,6 +7,7 @@
"client_id": Optional[str],
"client_secret": Optional[str],
"oidc_cache_provider_metadata": Optional[str],
"oidc_paths_prefix": str,
"oidc_callback_path": str,
# less important ---
"provider_discovery_uri": str,
Expand Down Expand Up @@ -51,10 +50,14 @@ def __init__(self, *args: Any, op_name: str, **kwargs: Any):
else:
self.oidc_logout_redirect_parameter_name = "post_logout_redirect"

if "oidc_paths_prefix" in kwargs:
self.oidc_paths_prefix = kwargs["oidc_paths_prefix"]
else:
self.oidc_paths_prefix = self.op_name
if "oidc_callback_path" in kwargs:
self.oidc_callback_path = kwargs["oidc_callback_path"]
else:
self.oidc_callback_path = "/oidc-callback/"
self.oidc_callback_path = f"{self.oidc_paths_prefix}-callback"

def get_default_config(self) -> ProviderConfig:
"""Get the default configuration settings for this provider.
Expand All @@ -67,6 +70,7 @@ def get_default_config(self) -> ProviderConfig:
client_id=None,
client_secret=None,
oidc_cache_provider_metadata=None,
oidc_paths_prefix=self.oidc_paths_prefix,
oidc_callback_path=self.oidc_callback_path,
# less important ---
provider_discovery_uri=self.provider_discovery_uri,
Expand All @@ -78,50 +82,3 @@ def get_default_config(self) -> ProviderConfig:
oidc_logout_query_string_redirect_parameter=None,
oidc_logout_query_string_extra_parameters_dict=None,
)

@property
def login_uri_name(self) -> str:
"""
The login viewname (use in :func:`django:django.urls.reverse` django directive for example) of this configuration
"""
return f"{self.op_name}-login"

@property
def logout_uri_name(self) -> str:
"""
The logout viewname (use in :func:`django:django.urls.reverse` django directive for example) of this configuration
"""
return f"{self.op_name}-logout"

@property
def callback_uri_name(self) -> str:
"""
The callback viewname (use in :func:`django:django.urls.reverse` django directive for example) of this configuration
"""
return f"{self.op_name}-callback"

def get_urlpatterns(self) -> List[Any]:
"""
Returns:
A list of urllpatterns to be included using :func:`django:django.urls.include` in your url configuration
"""
from django_pyoidc.views import OIDCCallbackView, OIDCLoginView, OIDCLogoutView

result = [
path(
f"{self.oidc_callback_path}",
OIDCCallbackView.as_view(op_name=self.op_name),
name=self.callback_uri_name,
),
path(
f"{self.oidc_callback_path}-login",
OIDCLoginView.as_view(op_name=self.op_name),
name=self.login_uri_name,
),
path(
f"{self.oidc_callback_path}-logout",
OIDCLogoutView.as_view(op_name=self.op_name),
name=self.logout_uri_name,
),
]
return result
43 changes: 36 additions & 7 deletions django_pyoidc/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from django.urls import reverse_lazy

from django_pyoidc.exceptions import InvalidOIDCConfigurationException
from django_pyoidc.providers.provider import Provider

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -54,8 +55,10 @@ def __init__(self, op_name: str):
provider_discovery_uri (str): URL of the SSO server (the .well-known/openid-configuration part is added to this path).
Some providers like the keycloak provider can instead generate this settings by combining 'keycloak_base_uri' (str) and
'keycloak_realm' (str) settings.
oidc_callback_path (str): the path used to call this library during the login round-trips, the default is "/oidc-callback/".
callback_uri_name (str): the route giving the path for oidc_callback_path that you can use instead of oidc_callback_path
oidc_paths_prefix (str): the string prefix for all paths created by the OIDCHelper when creating routes in get_urlpatterns, the default is "{op_name}".
oidc_callback_path (str): the path used to call this library during the login round-trips, the default is "{oidc_paths_prefix}-callback".
if you use namespaced and prefixed routes for the routes created via get_urlpatterns prefer using callback_uri_name.
callback_uri_name (str): the route giving the path for oidc_callback_path that you can use instead of oidc_callback_path.
post_logout_redirect_uri (str): the URI where a user should be redirected to on logout success
post_login_uri_failure (str): the URI where a user should be redirected to on login failure
post_login_uri_success (str): the URI a user should be redirected to on login success if no redirection url where provided
Expand All @@ -82,6 +85,10 @@ def __init__(self, op_name: str):
"""

self.op_name = op_name
if not hasattr(django_settings, "DJANGO_PYOIDC"):
raise InvalidOIDCConfigurationException(
"DJANGO_PYOIDC settings are undefined."
)
if self.op_name not in django_settings.DJANGO_PYOIDC:
raise InvalidOIDCConfigurationException(
f"{self.op_name} provider name must be configured in DJANGO_PYOIDC settings."
Expand All @@ -107,14 +114,14 @@ def __init__(self, op_name: str):
)

# This call can fail if required attributes are not set
provider = provider_real_class(op_name=self.op_name, **op_definition)
self._provider = provider_real_class(op_name=self.op_name, **op_definition)

# Init a local final operator settings with user given values
self.OP_SETTINGS = op_definition
# get some defaults and variation set by the provider
# For example it could be a newly computed value (provider_discovery_uri from uri and realm for keycloak)
# or some defaults altered
provider_default_settings = provider.get_default_config()
provider_default_settings = self._provider.get_default_config()
# Then merge the two, so for all settings we have, with priority
# * user defined specific values (if not empty)
# * provider computed or default value (if not empty)
Expand Down Expand Up @@ -150,15 +157,33 @@ def _fix_settings(self, op_definition: Dict[str, Any]) -> Dict[str, Any]:
op_definition["provider_discovery_uri"] = discovery

# Special path manipulations
if "oidc_paths_prefix" in op_definition:
# we cannot be sure that this part will be the full path at the end
# because the routes based on this path can be used in prefix
# but we can document that when prefix are used the callback_uri_name
# is a better way to define callback path.
# here this will only work when no route prefix is used.

if "oidc_callback_path" not in op_definition:
op_definition["oidc_callback_path"] = reverse_lazy(
f"{op_definition['oidc_paths_prefix']}-callback"
)

if "oidc_callback_path" in op_definition:
op_definition["oidc_callback_path"] = op_definition["oidc_callback_path"]
# remove '/' prefix if any.
op_definition["oidc_callback_path"] = op_definition[
"oidc_callback_path"
].lstrip("/")

# else: do not set defaults.
# The Provider object should have defined a default callback path part and default
# callback path.

if "callback_uri_name" in op_definition:
op_definition["oidc_callback_path"] = reverse_lazy(
op_definition["callback_uri_name"]
)
del op_definition["callback_uri_name"]
# else: do not set defaults.
# The Provider objet will define a defaut callback path if not set.

# allow simpler names
# * "logout_redirect" for "post_logout_redirect_uri"
Expand Down Expand Up @@ -239,6 +264,10 @@ def get(
return default
return res

@property
def provider(self) -> Provider:
return self._provider

def _get_attr(self, key: str) -> Optional[OidcSettingValue]:
"""Retrieve attr, if op value is None a check on globals is made.

Expand Down
4 changes: 4 additions & 0 deletions django_pyoidc/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ def check_audience(client_id: str, access_token_claims: Dict[str, Any]) -> bool:
if "aud" not in access_token_claims:
return False
if client_id not in access_token_claims["aud"]:
# in case we are current requester of the access token the audience may not be presence in
# 'aud' (that's the case in recent keycloak) but it should then be in azp (requester of the token).
if "azp" in access_token_claims and client_id == access_token_claims["azp"]:
return True
logger.error(
f"{client_id} not found in access_token_claims['aud']: {access_token_claims['aud']}"
)
Expand Down
16 changes: 12 additions & 4 deletions django_pyoidc/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,13 +131,21 @@ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:

sid = request.session.get("oidc_sid")
if sid:
client = OIDCClient(self.op_name, session_id=sid)
try:
client = OIDCClient(self.op_name, session_id=sid)
except InvalidSIDException:
# maybe a failed attempt trace in the session.
# we ignore the session sid and get back on the first steps.
client = OIDCClient(self.op_name)
else:
client = OIDCClient(self.op_name)

client.consumer.consumer_config["authz_page"] = self.get_setting(
"oidc_callback_path"
)
callback_path = str(self.get_setting("oidc_callback_path"))
if not callback_path.startswith("/"):
# issue in pyoidc when base_url ends with "/" and path does not start with "/" there's an extra "/" added (doubling)
# to prevent that we finally enforce the "/" prefix on the path
callback_path = f"/{callback_path}"
client.consumer.consumer_config["authz_page"] = callback_path
next_redirect_uri = self.get_next_url(request, "next")

if not next_redirect_uri:
Expand Down
26 changes: 24 additions & 2 deletions docs/settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,33 @@ When disabled the access token claims are not extracted, you only have the ``acc
oidc_paths_prefix
*****************

**Default** : dynamically computed using the name of your identity provide
**Default** : dynamically computed using the name of your identity provider.

This is the prefix of the various url names created by the OIDCHelper when using get_urlpatterns. If not set it defaults to the op_name.

.. note::
One of the created paths is the one referenced by the setting ``oidc_callback_path``.

You can use this setting to change how the OIDC views are named. By default they are named ``<op_name>_[login|callback]``.

Configuring this setting allows you to swap ``<op_name`` with an other value.
Configuring this setting allows you to swap ``<op_name>`` with an other value.

oidc_callback_path
*************

This setting is used to reference the callback view that should be provided as the ``redirect_uri`` parameter of the *Authorization Code Flow*.
A default path ``<op_name>-callback`` will be used if nothing is provided. This path is used internally to manage the authentication.
You can alternatively use **callback_uri_name** to provide a named route for this path, this alternative will be better because using
only oidc_callback_path you need to know the route prefix used on your oidc routes if any.


callback_uri_name
*************

Name of a Django route that can be used to generate the ``oidc_callback_path`` value.
If you used the OIDCHelper get_urlpatterns the default callback was created with a name ``<op_name>-callback`` (which is also the default path value).
But the routes namespaces used with get_urlpatterns may be needed. So your final value for this route name should
be something like "oidc_auth:mysso-callback" if "oidc_auth" was your route namespace and my_sso is your op_name.

Advanced identity provider configuration
========================================
Expand Down
Empty file added docs/user.rst
Empty file.
25 changes: 21 additions & 4 deletions tests/e2e/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,34 @@
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"HOST": config("POSTGRES_HOST", "db"),
"HOST": config("POSTGRES_HOST", "127.0.0.1"),
"USER": config("POSTGRES_USER", "postgres"),
"NAME": config("POSTGRES_DB", "postgres"),
"PASSWORD": config("POSTGRES_PASSWORD", "postgres"),
"PORT": config("POSTGRES_PORT", default=5432),
}
}

# DJANGO_PYOIDC settings are defined in tests overrides
# we keep this one very short.
DJANGO_PYOIDC = {}
# DJANGO_PYOIDC settings are defined here and not in tests overrides
# if we need to use the OIDCHelper.get_urlpatterns() function
DJANGO_PYOIDC = {
"lemon1": {
"provider_class": "LemonLDAPng2Provider",
"client_id": "app1",
"cache_django_backend": "default",
"provider_discovery_uri": "http://localhost:8070/",
"client_secret": "secret_app1",
"callback_uri_name": "lemon1_namespace:lemon1-callback",
"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",
# "oidc_logout_query_string_extra_parameters_dict": {"confirm": 1},
},
}

REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
Expand Down
Loading