Skip to content

Commit f558ead

Browse files
committed
ModuleRouter: support paths in BASE
If Satosa is installed under a path which is not the root of the webserver (ie. "https://example.com/satosa"), then endpoint routing must take the base path into consideration. Some modules registered some of their endpoints with the base path included, but other times the base path was omitted, thus it made the routing fail. Now all endpoint registrations include the base path in their endpoint map. Provide a simple implementation for joining path components, since we don't want to add the separator for empty strings and when any of the path components already have it. Additionally, DEBUG logging was configured for the tests so that the debug logs are accessible during testing.
1 parent 4e8d27c commit f558ead

File tree

18 files changed

+281
-65
lines changed

18 files changed

+281
-65
lines changed

src/satosa/backends/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def __init__(self, auth_callback_func, internal_attributes, base_url, name):
2929
self.auth_callback_func = auth_callback_func
3030
self.internal_attributes = internal_attributes
3131
self.converter = AttributeMapper(internal_attributes)
32-
self.base_url = base_url
32+
self.base_url = base_url.rstrip("/") if base_url else ""
3333
self.name = name
3434

3535
def start_auth(self, context, internal_request):

src/satosa/base.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import uuid
77

88
from saml2.s_utils import UnknownSystemEntity
9+
from urllib.parse import urlparse
910

1011
from satosa import util
1112
from .context import Context
@@ -38,6 +39,8 @@ def __init__(self, config):
3839
"""
3940
self.config = config
4041

42+
base_path = urlparse(self.config["BASE"]).path.lstrip("/")
43+
4144
logger.info("Loading backend modules...")
4245
backends = load_backends(self.config, self._auth_resp_callback_func,
4346
self.config["INTERNAL_ATTRIBUTES"])
@@ -63,8 +66,10 @@ def __init__(self, config):
6366
self.config["BASE"]))
6467
self._link_micro_services(self.response_micro_services, self._auth_resp_finish)
6568

66-
self.module_router = ModuleRouter(frontends, backends,
67-
self.request_micro_services + self.response_micro_services)
69+
self.module_router = ModuleRouter(frontends,
70+
backends,
71+
self.request_micro_services + self.response_micro_services,
72+
base_path)
6873

6974
def _link_micro_services(self, micro_services, finisher):
7075
if not micro_services:

src/satosa/context.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,6 @@ def path(self, p):
7676
raise ValueError("path can't start with '/'")
7777
self._path = p
7878

79-
def target_entity_id_from_path(self):
80-
target_entity_id = self.path.split("/")[1]
81-
return target_entity_id
82-
8379
def decorate(self, key, value):
8480
"""
8581
Add information to the context

src/satosa/frontends/base.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
Holds a base class for frontend modules used in the SATOSA proxy.
33
"""
44
from ..attribute_mapping import AttributeMapper
5+
from ..util import join_paths
6+
7+
from urllib.parse import urlparse
58

69

710
class FrontendModule(object):
@@ -14,17 +17,22 @@ def __init__(self, auth_req_callback_func, internal_attributes, base_url, name):
1417
:type auth_req_callback_func:
1518
(satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response
1619
:type internal_attributes: dict[str, dict[str, str | list[str]]]
20+
:type base_url: str
1721
:type name: str
1822
1923
:param auth_req_callback_func: Callback should be called by the module after the
2024
authorization response has been processed.
25+
:param internal_attributes: attribute mapping
26+
:param base_url: base url of the proxy
2127
:param name: name of the plugin
2228
"""
2329
self.auth_req_callback_func = auth_req_callback_func
2430
self.internal_attributes = internal_attributes
2531
self.converter = AttributeMapper(internal_attributes)
26-
self.base_url = base_url
32+
self.base_url = base_url or ""
2733
self.name = name
34+
self.endpoint_baseurl = join_paths(self.base_url, self.name)
35+
self.endpoint_basepath = urlparse(self.endpoint_baseurl).path.lstrip("/")
2836

2937
def handle_authn_response(self, context, internal_resp):
3038
"""

src/satosa/frontends/openid_connect.py

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
from ..response import BadRequest, Created
3838
from ..response import SeeOther, Response
3939
from ..response import Unauthorized
40-
from ..util import rndstr
40+
from ..util import join_paths, rndstr
4141

4242
import satosa.logging_util as lu
4343
from satosa.internal import InternalData
@@ -97,7 +97,6 @@ def __init__(self, auth_req_callback_func, internal_attributes, conf, base_url,
9797
else:
9898
cdb = {}
9999

100-
self.endpoint_baseurl = "{}/{}".format(self.base_url, self.name)
101100
self.provider = _create_provider(
102101
provider_config,
103102
self.endpoint_baseurl,
@@ -173,6 +172,19 @@ def register_endpoints(self, backend_names):
173172
:rtype: list[(str, ((satosa.context.Context, Any) -> satosa.response.Response, Any))]
174173
:raise ValueError: if more than one backend is configured
175174
"""
175+
# See https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig
176+
#
177+
# We skip the scheme + host + port of the issuer URL, because we can only map the
178+
# path for the provider config endpoint. We are safe to use urlparse().path here,
179+
# because for issuer OIDC allows only https URLs without query and fragment parts.
180+
issuer = self.provider.configuration_information["issuer"]
181+
autoconf_path = ".well-known/openid-configuration"
182+
provider_config = (
183+
"^{}$".format(join_paths(urlparse(issuer).path.lstrip("/"), autoconf_path)),
184+
self.provider_config,
185+
)
186+
jwks_uri = ("^{}/jwks$".format(self.endpoint_basepath), self.jwks)
187+
176188
backend_name = None
177189
if len(backend_names) != 1:
178190
# only supports one backend since there currently is no way to publish multiple authorization endpoints
@@ -189,40 +201,49 @@ def register_endpoints(self, backend_names):
189201
else:
190202
backend_name = backend_names[0]
191203

192-
provider_config = ("^.well-known/openid-configuration$", self.provider_config)
193-
jwks_uri = ("^{}/jwks$".format(self.name), self.jwks)
194-
195204
if backend_name:
196205
# if there is only one backend, include its name in the path so the default routing can work
197-
auth_endpoint = "{}/{}/{}/{}".format(self.base_url, backend_name, self.name, AuthorizationEndpoint.url)
206+
auth_endpoint = join_paths(
207+
self.base_url,
208+
backend_name,
209+
self.name,
210+
AuthorizationEndpoint.url,
211+
)
198212
self.provider.configuration_information["authorization_endpoint"] = auth_endpoint
199213
auth_path = urlparse(auth_endpoint).path.lstrip("/")
200214
else:
201-
auth_path = "{}/{}".format(self.name, AuthorizationEndpoint.url)
215+
auth_path = join_paths(self.endpoint_basepath, AuthorizationRequest.url)
202216

203217
authentication = ("^{}$".format(auth_path), self.handle_authn_request)
204218
url_map = [provider_config, jwks_uri, authentication]
205219

206220
if any("code" in v for v in self.provider.configuration_information["response_types_supported"]):
207-
self.provider.configuration_information["token_endpoint"] = "{}/{}".format(
208-
self.endpoint_baseurl, TokenEndpoint.url
221+
self.provider.configuration_information["token_endpoint"] = join_paths(
222+
self.endpoint_baseurl,
223+
TokenEndpoint.url,
209224
)
210225
token_endpoint = (
211-
"^{}/{}".format(self.name, TokenEndpoint.url), self.token_endpoint
226+
"^{}".format(join_paths(self.endpoint_basepath, TokenEndpoint.url)),
227+
self.token_endpoint,
212228
)
213229
url_map.append(token_endpoint)
214230

215231
self.provider.configuration_information["userinfo_endpoint"] = (
216-
"{}/{}".format(self.endpoint_baseurl, UserinfoEndpoint.url)
232+
join_paths(self.endpoint_baseurl, UserinfoEndpoint.url)
217233
)
218234
userinfo_endpoint = (
219-
"^{}/{}".format(self.name, UserinfoEndpoint.url), self.userinfo_endpoint
235+
"^{}".format(
236+
join_paths(self.endpoint_basepath, UserinfoEndpoint.url)
237+
),
238+
self.userinfo_endpoint,
220239
)
221240
url_map.append(userinfo_endpoint)
222241

223242
if "registration_endpoint" in self.provider.configuration_information:
224243
client_registration = (
225-
"^{}/{}".format(self.name, RegistrationEndpoint.url),
244+
"^{}".format(
245+
join_paths(self.endpoint_basepath, RegistrationEndpoint.url)
246+
),
226247
self.client_registration,
227248
)
228249
url_map.append(client_registration)

src/satosa/frontends/ping.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import satosa.logging_util as lu
44
from satosa.frontends.base import FrontendModule
55
from satosa.response import Response
6+
from satosa.util import join_paths
67

78

89
logger = logging.getLogger(__name__)
@@ -43,7 +44,7 @@ def register_endpoints(self, backend_names):
4344
:rtype: list[(str, ((satosa.context.Context, Any) -> satosa.response.Response, Any))]
4445
:raise ValueError: if more than one backend is configured
4546
"""
46-
url_map = [("^{}".format(self.name), self.ping_endpoint)]
47+
url_map = [("^{}".format(join_paths(self.endpoint_basepath, self.name)), self.ping_endpoint)]
4748

4849
return url_map
4950

src/satosa/frontends/saml2.py

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ def register_endpoints(self, backend_names):
117117

118118
if self.enable_metadata_reload():
119119
url_map.append(
120-
("^%s/%s$" % (self.name, "reload-metadata"), self._reload_metadata))
120+
("^%s/%s$" % (self.endpoint_basepath, "reload-metadata"), self._reload_metadata))
121121

122122
self.idp_config = self._build_idp_config_endpoints(
123123
self.config[self.KEY_IDP_CONFIG], backend_names)
@@ -511,15 +511,19 @@ def _register_endpoints(self, providers):
511511
"""
512512
url_map = []
513513

514+
backend_providers = "|".join(providers)
515+
base_path = urlparse(self.base_url).path.lstrip("/")
516+
if base_path:
517+
base_path = base_path + "/"
514518
for endp_category in self.endpoints:
515519
for binding, endp in self.endpoints[endp_category].items():
516-
valid_providers = ""
517-
for provider in providers:
518-
valid_providers = "{}|^{}".format(valid_providers, provider)
519-
valid_providers = valid_providers.lstrip("|")
520-
parsed_endp = urlparse(endp)
521-
url_map.append(("(%s)/%s$" % (valid_providers, parsed_endp.path),
522-
functools.partial(self.handle_authn_request, binding_in=binding)))
520+
endp_path = urlparse(endp).path
521+
url_map.append(
522+
(
523+
"^{}({})/{}$".format(base_path, backend_providers, endp_path),
524+
functools.partial(self.handle_authn_request, binding_in=binding)
525+
)
526+
)
523527

524528
if self.expose_entityid_endpoint():
525529
logger.debug("Exposing frontend entity endpoint = {}".format(self.idp.config.entityid))
@@ -675,11 +679,18 @@ def _load_idp_dynamic_endpoints(self, context):
675679
:param context:
676680
:return: An idp server
677681
"""
678-
target_entity_id = context.target_entity_id_from_path()
682+
target_entity_id = self._target_entity_id_from_path(context.path)
679683
idp_conf_file = self._load_endpoints_to_config(context.target_backend, target_entity_id)
680684
idp_config = IdPConfig().load(idp_conf_file)
681685
return Server(config=idp_config)
682686

687+
def _target_entity_id_from_path(self, request_path):
688+
path = request_path.lstrip("/")
689+
base_path = urlparse(self.base_url).path.lstrip("/")
690+
if base_path and path.startswith(base_path):
691+
path = path[len(base_path):].lstrip("/")
692+
return path.split("/")[1]
693+
683694
def _load_idp_dynamic_entity_id(self, state):
684695
"""
685696
Loads an idp server with the entity id saved in state
@@ -705,7 +716,7 @@ def handle_authn_request(self, context, binding_in):
705716
:type binding_in: str
706717
:rtype: satosa.response.Response
707718
"""
708-
target_entity_id = context.target_entity_id_from_path()
719+
target_entity_id = self._target_entity_id_from_path(context.path)
709720
target_entity_id = urlsafe_b64decode(target_entity_id).decode()
710721
context.decorate(Context.KEY_TARGET_ENTITYID, target_entity_id)
711722

@@ -723,7 +734,7 @@ def _create_state_data(self, context, resp_args, relay_state):
723734
:rtype: dict[str, dict[str, str] | str]
724735
"""
725736
state = super()._create_state_data(context, resp_args, relay_state)
726-
state["target_entity_id"] = context.target_entity_id_from_path()
737+
state["target_entity_id"] = self._target_entity_id_from_path(context.path)
727738
return state
728739

729740
def handle_backend_error(self, exception):
@@ -758,13 +769,16 @@ def _register_endpoints(self, providers):
758769
"""
759770
url_map = []
760771

772+
backend_providers = "|".join(providers)
773+
base_path = urlparse(self.base_url).path.lstrip("/")
774+
if base_path:
775+
base_path = base_path + "/"
761776
for endp_category in self.endpoints:
762777
for binding, endp in self.endpoints[endp_category].items():
763-
valid_providers = "|^".join(providers)
764-
parsed_endp = urlparse(endp)
778+
endp_path = urlparse(endp).path
765779
url_map.append(
766780
(
767-
r"(^{})/\S+/{}".format(valid_providers, parsed_endp.path),
781+
"^{}({})/\S+/{}$".format(base_path, backend_providers, endp_path),
768782
functools.partial(self.handle_authn_request, binding_in=binding)
769783
)
770784
)

src/satosa/micro_services/account_linking.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from ..exception import SATOSAAuthenticationError
1313
from ..micro_services.base import ResponseMicroService
1414
from ..response import Redirect
15+
from ..util import join_paths
1516

1617
import satosa.logging_util as lu
1718
logger = logging.getLogger(__name__)
@@ -161,4 +162,13 @@ def register_endpoints(self):
161162
162163
:return: A list of endpoints bound to a function
163164
"""
164-
return [("^account_linking%s$" % self.endpoint, self._handle_al_response)]
165+
return [
166+
(
167+
"^{}$".format(
168+
join_paths(
169+
self.base_path, "account_linking", self.endpoint
170+
)
171+
),
172+
self._handle_al_response,
173+
)
174+
]

src/satosa/micro_services/base.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Micro service for SATOSA
33
"""
44
import logging
5+
from urllib.parse import urlparse
56

67
logger = logging.getLogger(__name__)
78

@@ -14,6 +15,7 @@ class MicroService(object):
1415
def __init__(self, name, base_url, **kwargs):
1516
self.name = name
1617
self.base_url = base_url
18+
self.base_path = urlparse(base_url).path.lstrip("/")
1719
self.next = None
1820

1921
def process(self, context, data):

src/satosa/micro_services/consent.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from satosa.internal import InternalData
1717
from satosa.micro_services.base import ResponseMicroService
1818
from satosa.response import Redirect
19+
from satosa.util import join_paths
1920

2021

2122
logger = logging.getLogger(__name__)
@@ -238,4 +239,13 @@ def register_endpoints(self):
238239
239240
:return: A list of endpoints bound to a function
240241
"""
241-
return [("^consent%s$" % self.endpoint, self._handle_consent_response)]
242+
return [
243+
(
244+
"^{}$".format(
245+
join_paths(
246+
self.base_path, "consent", self.endpoint
247+
)
248+
),
249+
self._handle_consent_response,
250+
)
251+
]

0 commit comments

Comments
 (0)