Skip to content

Commit 9811f9f

Browse files
authored
Merge pull request #17 from makinacorpus/callback-routes
Callback routes
2 parents 1f9bf3b + 9243b43 commit 9811f9f

18 files changed

+434
-139
lines changed

Makefile

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ update_all_deps : requirements/requirements.txt requirements/requirements-dev.tx
66
requirements/requirements.txt : pyproject.toml
77
pip-compile -o $@ $< --extra drf
88

9-
requirements/requirements-dev.txt : requirements/requirements-dev.in requirements/requirements/requirements.txt
9+
requirements/requirements-dev.txt : requirements/requirements-dev.in requirements/requirements.txt
1010
pip-compile -o $@ $<
1111

1212
requirements/requirements-test.txt : requirements/requirements-test.in requirements/requirements-dev.in requirements/requirements.txt

django_pyoidc/helper.py

+86
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
from typing import Any, List
2+
3+
from django.urls import URLPattern, path
4+
5+
from django_pyoidc.settings import OIDCSettings, OIDCSettingsFactory
6+
7+
8+
class OIDCHelper:
9+
"""
10+
This is a utility class providing a wrapper around the provider, the settings and the views.
11+
"""
12+
13+
def __init__(self, *args: Any, op_name: str, **kwargs: Any):
14+
"""
15+
Parameters:
16+
op_name (str): the name of the sso provider that you are using.
17+
This should exists as a key in the DJANGO_PYOIDC settings section.
18+
"""
19+
self.op_name = op_name
20+
self.opsettings: OIDCSettings = OIDCSettingsFactory.get(self.op_name)
21+
self.provider = self.opsettings.provider
22+
23+
@property
24+
def login_uri_name(self) -> str:
25+
"""
26+
The login viewname (use in :func:`django:django.urls.reverse` django directive for example) of this configuration
27+
"""
28+
return f"{self.op_name}-login"
29+
30+
@property
31+
def logout_uri_name(self) -> str:
32+
"""
33+
The logout viewname (use in :func:`django:django.urls.reverse` django directive for example) of this configuration
34+
"""
35+
return f"{self.op_name}-logout"
36+
37+
@property
38+
def callback_uri_name(self) -> str:
39+
"""
40+
The callback viewname (use in :func:`django:django.urls.reverse` django directive for example) of this configuration
41+
"""
42+
return f"{self.op_name}-callback"
43+
44+
@property
45+
def backchannel_logout_uri_name(self) -> str:
46+
"""
47+
The backchannel logout viewname (use in :func:`django:django.urls.reverse` django directive for example) of this configuration
48+
"""
49+
return f"{self.op_name}-backchannel-logout"
50+
51+
def get_urlpatterns(self) -> List[URLPattern]:
52+
"""
53+
Returns:
54+
A list of urllpatterns to be included using :func:`django:django.urls.include` in your url configuration
55+
"""
56+
from django_pyoidc.views import (
57+
OIDCBackChannelLogoutView,
58+
OIDCCallbackView,
59+
OIDCLoginView,
60+
OIDCLogoutView,
61+
)
62+
63+
oidc_paths_prefix = self.opsettings.get("oidc_paths_prefix")
64+
result = [
65+
path(
66+
f"{oidc_paths_prefix}-callback",
67+
OIDCCallbackView.as_view(op_name=self.op_name),
68+
name=self.callback_uri_name,
69+
),
70+
path(
71+
f"{oidc_paths_prefix}-login",
72+
OIDCLoginView.as_view(op_name=self.op_name),
73+
name=self.login_uri_name,
74+
),
75+
path(
76+
f"{oidc_paths_prefix}-logout",
77+
OIDCLogoutView.as_view(op_name=self.op_name),
78+
name=self.logout_uri_name,
79+
),
80+
path(
81+
f"{oidc_paths_prefix}-backchannel-logout",
82+
OIDCBackChannelLogoutView.as_view(op_name=self.op_name),
83+
name=self.backchannel_logout_uri_name,
84+
),
85+
]
86+
return result

django_pyoidc/providers/provider.py

+8-51
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
from typing import Any, Dict, List, Optional, TypedDict
2-
3-
from django.urls import path
1+
from typing import Any, Dict, Optional, TypedDict
42

53
ProviderConfig = TypedDict(
64
"ProviderConfig",
@@ -9,6 +7,7 @@
97
"client_id": Optional[str],
108
"client_secret": Optional[str],
119
"oidc_cache_provider_metadata": Optional[str],
10+
"oidc_paths_prefix": str,
1211
"oidc_callback_path": str,
1312
# less important ---
1413
"provider_discovery_uri": str,
@@ -51,10 +50,14 @@ def __init__(self, *args: Any, op_name: str, **kwargs: Any):
5150
else:
5251
self.oidc_logout_redirect_parameter_name = "post_logout_redirect"
5352

53+
if "oidc_paths_prefix" in kwargs:
54+
self.oidc_paths_prefix = kwargs["oidc_paths_prefix"]
55+
else:
56+
self.oidc_paths_prefix = self.op_name
5457
if "oidc_callback_path" in kwargs:
5558
self.oidc_callback_path = kwargs["oidc_callback_path"]
5659
else:
57-
self.oidc_callback_path = "/oidc-callback/"
60+
self.oidc_callback_path = f"{self.oidc_paths_prefix}-callback"
5861

5962
def get_default_config(self) -> ProviderConfig:
6063
"""Get the default configuration settings for this provider.
@@ -67,6 +70,7 @@ def get_default_config(self) -> ProviderConfig:
6770
client_id=None,
6871
client_secret=None,
6972
oidc_cache_provider_metadata=None,
73+
oidc_paths_prefix=self.oidc_paths_prefix,
7074
oidc_callback_path=self.oidc_callback_path,
7175
# less important ---
7276
provider_discovery_uri=self.provider_discovery_uri,
@@ -78,50 +82,3 @@ def get_default_config(self) -> ProviderConfig:
7882
oidc_logout_query_string_redirect_parameter=None,
7983
oidc_logout_query_string_extra_parameters_dict=None,
8084
)
81-
82-
@property
83-
def login_uri_name(self) -> str:
84-
"""
85-
The login viewname (use in :func:`django:django.urls.reverse` django directive for example) of this configuration
86-
"""
87-
return f"{self.op_name}-login"
88-
89-
@property
90-
def logout_uri_name(self) -> str:
91-
"""
92-
The logout viewname (use in :func:`django:django.urls.reverse` django directive for example) of this configuration
93-
"""
94-
return f"{self.op_name}-logout"
95-
96-
@property
97-
def callback_uri_name(self) -> str:
98-
"""
99-
The callback viewname (use in :func:`django:django.urls.reverse` django directive for example) of this configuration
100-
"""
101-
return f"{self.op_name}-callback"
102-
103-
def get_urlpatterns(self) -> List[Any]:
104-
"""
105-
Returns:
106-
A list of urllpatterns to be included using :func:`django:django.urls.include` in your url configuration
107-
"""
108-
from django_pyoidc.views import OIDCCallbackView, OIDCLoginView, OIDCLogoutView
109-
110-
result = [
111-
path(
112-
f"{self.oidc_callback_path}",
113-
OIDCCallbackView.as_view(op_name=self.op_name),
114-
name=self.callback_uri_name,
115-
),
116-
path(
117-
f"{self.oidc_callback_path}-login",
118-
OIDCLoginView.as_view(op_name=self.op_name),
119-
name=self.login_uri_name,
120-
),
121-
path(
122-
f"{self.oidc_callback_path}-logout",
123-
OIDCLogoutView.as_view(op_name=self.op_name),
124-
name=self.logout_uri_name,
125-
),
126-
]
127-
return result

django_pyoidc/settings.py

+36-7
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from django.urls import reverse_lazy
88

99
from django_pyoidc.exceptions import InvalidOIDCConfigurationException
10+
from django_pyoidc.providers.provider import Provider
1011

1112
logger = logging.getLogger(__name__)
1213

@@ -54,8 +55,10 @@ def __init__(self, op_name: str):
5455
provider_discovery_uri (str): URL of the SSO server (the .well-known/openid-configuration part is added to this path).
5556
Some providers like the keycloak provider can instead generate this settings by combining 'keycloak_base_uri' (str) and
5657
'keycloak_realm' (str) settings.
57-
oidc_callback_path (str): the path used to call this library during the login round-trips, the default is "/oidc-callback/".
58-
callback_uri_name (str): the route giving the path for oidc_callback_path that you can use instead of oidc_callback_path
58+
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}".
59+
oidc_callback_path (str): the path used to call this library during the login round-trips, the default is "{oidc_paths_prefix}-callback".
60+
if you use namespaced and prefixed routes for the routes created via get_urlpatterns prefer using callback_uri_name.
61+
callback_uri_name (str): the route giving the path for oidc_callback_path that you can use instead of oidc_callback_path.
5962
post_logout_redirect_uri (str): the URI where a user should be redirected to on logout success
6063
post_login_uri_failure (str): the URI where a user should be redirected to on login failure
6164
post_login_uri_success (str): the URI a user should be redirected to on login success if no redirection url where provided
@@ -82,6 +85,10 @@ def __init__(self, op_name: str):
8285
"""
8386

8487
self.op_name = op_name
88+
if not hasattr(django_settings, "DJANGO_PYOIDC"):
89+
raise InvalidOIDCConfigurationException(
90+
"DJANGO_PYOIDC settings are undefined."
91+
)
8592
if self.op_name not in django_settings.DJANGO_PYOIDC:
8693
raise InvalidOIDCConfigurationException(
8794
f"{self.op_name} provider name must be configured in DJANGO_PYOIDC settings."
@@ -107,14 +114,14 @@ def __init__(self, op_name: str):
107114
)
108115

109116
# This call can fail if required attributes are not set
110-
provider = provider_real_class(op_name=self.op_name, **op_definition)
117+
self._provider = provider_real_class(op_name=self.op_name, **op_definition)
111118

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

152159
# Special path manipulations
160+
if "oidc_paths_prefix" in op_definition:
161+
# we cannot be sure that this part will be the full path at the end
162+
# because the routes based on this path can be used in prefix
163+
# but we can document that when prefix are used the callback_uri_name
164+
# is a better way to define callback path.
165+
# here this will only work when no route prefix is used.
166+
167+
if "oidc_callback_path" not in op_definition:
168+
op_definition["oidc_callback_path"] = reverse_lazy(
169+
f"{op_definition['oidc_paths_prefix']}-callback"
170+
)
171+
153172
if "oidc_callback_path" in op_definition:
154-
op_definition["oidc_callback_path"] = op_definition["oidc_callback_path"]
173+
# remove '/' prefix if any.
174+
op_definition["oidc_callback_path"] = op_definition[
175+
"oidc_callback_path"
176+
].lstrip("/")
177+
178+
# else: do not set defaults.
179+
# The Provider object should have defined a default callback path part and default
180+
# callback path.
181+
155182
if "callback_uri_name" in op_definition:
156183
op_definition["oidc_callback_path"] = reverse_lazy(
157184
op_definition["callback_uri_name"]
158185
)
159186
del op_definition["callback_uri_name"]
160-
# else: do not set defaults.
161-
# The Provider objet will define a defaut callback path if not set.
162187

163188
# allow simpler names
164189
# * "logout_redirect" for "post_logout_redirect_uri"
@@ -239,6 +264,10 @@ def get(
239264
return default
240265
return res
241266

267+
@property
268+
def provider(self) -> Provider:
269+
return self._provider
270+
242271
def _get_attr(self, key: str) -> Optional[OidcSettingValue]:
243272
"""Retrieve attr, if op value is None a check on globals is made.
244273

django_pyoidc/utils.py

+4
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ def check_audience(client_id: str, access_token_claims: Dict[str, Any]) -> bool:
6262
if "aud" not in access_token_claims:
6363
return False
6464
if client_id not in access_token_claims["aud"]:
65+
# in case we are current requester of the access token the audience may not be presence in
66+
# 'aud' (that's the case in recent keycloak) but it should then be in azp (requester of the token).
67+
if "azp" in access_token_claims and client_id == access_token_claims["azp"]:
68+
return True
6569
logger.error(
6670
f"{client_id} not found in access_token_claims['aud']: {access_token_claims['aud']}"
6771
)

django_pyoidc/views.py

+12-4
Original file line numberDiff line numberDiff line change
@@ -131,13 +131,21 @@ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
131131

132132
sid = request.session.get("oidc_sid")
133133
if sid:
134-
client = OIDCClient(self.op_name, session_id=sid)
134+
try:
135+
client = OIDCClient(self.op_name, session_id=sid)
136+
except InvalidSIDException:
137+
# maybe a failed attempt trace in the session.
138+
# we ignore the session sid and get back on the first steps.
139+
client = OIDCClient(self.op_name)
135140
else:
136141
client = OIDCClient(self.op_name)
137142

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

143151
if not next_redirect_uri:

docs/settings.rst

+24-2
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,33 @@ When disabled the access token claims are not extracted, you only have the ``acc
7171
oidc_paths_prefix
7272
*****************
7373

74-
**Default** : dynamically computed using the name of your identity provide
74+
**Default** : dynamically computed using the name of your identity provider.
75+
76+
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.
77+
78+
.. note::
79+
One of the created paths is the one referenced by the setting ``oidc_callback_path``.
7580

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

78-
Configuring this setting allows you to swap ``<op_name`` with an other value.
83+
Configuring this setting allows you to swap ``<op_name>`` with an other value.
84+
85+
oidc_callback_path
86+
*************
87+
88+
This setting is used to reference the callback view that should be provided as the ``redirect_uri`` parameter of the *Authorization Code Flow*.
89+
A default path ``<op_name>-callback`` will be used if nothing is provided. This path is used internally to manage the authentication.
90+
You can alternatively use **callback_uri_name** to provide a named route for this path, this alternative will be better because using
91+
only oidc_callback_path you need to know the route prefix used on your oidc routes if any.
92+
93+
94+
callback_uri_name
95+
*************
96+
97+
Name of a Django route that can be used to generate the ``oidc_callback_path`` value.
98+
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).
99+
But the routes namespaces used with get_urlpatterns may be needed. So your final value for this route name should
100+
be something like "oidc_auth:mysso-callback" if "oidc_auth" was your route namespace and my_sso is your op_name.
79101

80102
Advanced identity provider configuration
81103
========================================

docs/user.rst

Whitespace-only changes.

tests/e2e/settings.py

+21-4
Original file line numberDiff line numberDiff line change
@@ -47,17 +47,34 @@
4747
DATABASES = {
4848
"default": {
4949
"ENGINE": "django.db.backends.postgresql",
50-
"HOST": config("POSTGRES_HOST", "db"),
50+
"HOST": config("POSTGRES_HOST", "127.0.0.1"),
5151
"USER": config("POSTGRES_USER", "postgres"),
5252
"NAME": config("POSTGRES_DB", "postgres"),
5353
"PASSWORD": config("POSTGRES_PASSWORD", "postgres"),
5454
"PORT": config("POSTGRES_PORT", default=5432),
5555
}
5656
}
5757

58-
# DJANGO_PYOIDC settings are defined in tests overrides
59-
# we keep this one very short.
60-
DJANGO_PYOIDC = {}
58+
# DJANGO_PYOIDC settings are defined here and not in tests overrides
59+
# if we need to use the OIDCHelper.get_urlpatterns() function
60+
DJANGO_PYOIDC = {
61+
"lemon1": {
62+
"provider_class": "LemonLDAPng2Provider",
63+
"client_id": "app1",
64+
"cache_django_backend": "default",
65+
"provider_discovery_uri": "http://localhost:8070/",
66+
"client_secret": "secret_app1",
67+
"callback_uri_name": "lemon1_namespace:lemon1-callback",
68+
"post_logout_redirect_uri": "/test-ll-logout-done-1",
69+
"login_uris_redirect_allowed_hosts": ["testserver"],
70+
"login_redirection_requires_https": False,
71+
"post_login_uri_success": "/test-ll-success-1",
72+
"post_login_uri_failure": "/test-ll-failure-1",
73+
"HOOK_USER_LOGIN": "tests.e2e.test_app.callback:login_callback",
74+
"HOOK_USER_LOGOUT": "tests.e2e.test_app.callback:logout_callback",
75+
# "oidc_logout_query_string_extra_parameters_dict": {"confirm": 1},
76+
},
77+
}
6178

6279
REST_FRAMEWORK = {
6380
"DEFAULT_AUTHENTICATION_CLASSES": [

0 commit comments

Comments
 (0)