Skip to content

Commit 3ce3a64

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. Additionally, DEBUG logging was configured for the tests so that the debug logs are accessible during testing.
1 parent 4e8d27c commit 3ce3a64

File tree

14 files changed

+127
-45
lines changed

14 files changed

+127
-45
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: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
"""
44
from ..attribute_mapping import AttributeMapper
55

6+
import os.path
7+
from urllib.parse import urlparse
8+
69

710
class FrontendModule(object):
811
"""
@@ -23,8 +26,10 @@ def __init__(self, auth_req_callback_func, internal_attributes, base_url, name):
2326
self.auth_req_callback_func = auth_req_callback_func
2427
self.internal_attributes = internal_attributes
2528
self.converter = AttributeMapper(internal_attributes)
26-
self.base_url = base_url
29+
self.base_url = base_url.rstrip("/") if base_url else ""
2730
self.name = name
31+
self.endpoint_baseurl = os.path.join(self.base_url, self.name)
32+
self.endpoint_basepath = urlparse(self.endpoint_baseurl).path.lstrip("/")
2833

2934
def handle_authn_response(self, context, internal_resp):
3035
"""

src/satosa/frontends/openid_connect.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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,9 @@ 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+
provider_config = ("^.well-known/openid-configuration$", self.provider_config)
176+
jwks_uri = ("^{}/jwks$".format(self.endpoint_basepath), self.jwks)
177+
176178
backend_name = None
177179
if len(backend_names) != 1:
178180
# only supports one backend since there currently is no way to publish multiple authorization endpoints
@@ -189,16 +191,13 @@ def register_endpoints(self, backend_names):
189191
else:
190192
backend_name = backend_names[0]
191193

192-
provider_config = ("^.well-known/openid-configuration$", self.provider_config)
193-
jwks_uri = ("^{}/jwks$".format(self.name), self.jwks)
194-
195194
if backend_name:
196195
# if there is only one backend, include its name in the path so the default routing can work
197196
auth_endpoint = "{}/{}/{}/{}".format(self.base_url, backend_name, self.name, AuthorizationEndpoint.url)
198197
self.provider.configuration_information["authorization_endpoint"] = auth_endpoint
199198
auth_path = urlparse(auth_endpoint).path.lstrip("/")
200199
else:
201-
auth_path = "{}/{}".format(self.name, AuthorizationEndpoint.url)
200+
auth_path = "{}/{}".format(self.endpoint_basepath, AuthorizationEndpoint.url)
202201

203202
authentication = ("^{}$".format(auth_path), self.handle_authn_request)
204203
url_map = [provider_config, jwks_uri, authentication]
@@ -208,21 +207,21 @@ def register_endpoints(self, backend_names):
208207
self.endpoint_baseurl, TokenEndpoint.url
209208
)
210209
token_endpoint = (
211-
"^{}/{}".format(self.name, TokenEndpoint.url), self.token_endpoint
210+
"^{}/{}".format(self.endpoint_basepath, TokenEndpoint.url), self.token_endpoint
212211
)
213212
url_map.append(token_endpoint)
214213

215214
self.provider.configuration_information["userinfo_endpoint"] = (
216215
"{}/{}".format(self.endpoint_baseurl, UserinfoEndpoint.url)
217216
)
218217
userinfo_endpoint = (
219-
"^{}/{}".format(self.name, UserinfoEndpoint.url), self.userinfo_endpoint
218+
"^{}/{}".format(self.endpoint_basepath, UserinfoEndpoint.url), self.userinfo_endpoint
220219
)
221220
url_map.append(userinfo_endpoint)
222221

223222
if "registration_endpoint" in self.provider.configuration_information:
224223
client_registration = (
225-
"^{}/{}".format(self.name, RegistrationEndpoint.url),
224+
"^{}/{}".format(self.endpoint_basepath, RegistrationEndpoint.url),
226225
self.client_registration,
227226
)
228227
url_map.append(client_registration)

src/satosa/frontends/ping.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
import os.path
23

34
import satosa.logging_util as lu
45
from satosa.frontends.base import FrontendModule
@@ -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(os.path.join(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
@@ -3,6 +3,7 @@
33
"""
44
import json
55
import logging
6+
import os.path
67

78
import requests
89
from jwkest.jwk import rsa_load, RSAKey
@@ -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+
os.path.join(
169+
self.base_path, "account_linking", self.endpoint.lstrip("/")
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
@@ -4,6 +4,7 @@
44
import hashlib
55
import json
66
import logging
7+
import os.path
78
from base64 import urlsafe_b64encode
89

910
import requests
@@ -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+
os.path.join(
246+
self.base_path, "consent", self.endpoint.lstrip("/")
247+
)
248+
),
249+
self._handle_consent_response,
250+
)
251+
]

src/satosa/routing.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,18 +38,20 @@ class UnknownEndpoint(ValueError):
3838
and handles the internal routing between frontends and backends.
3939
"""
4040

41-
def __init__(self, frontends, backends, micro_services):
41+
def __init__(self, frontends, backends, micro_services, base_path=""):
4242
"""
4343
:type frontends: dict[str, satosa.frontends.base.FrontendModule]
4444
:type backends: dict[str, satosa.backends.base.BackendModule]
4545
:type micro_services: Sequence[satosa.micro_services.base.MicroService]
46+
:type base_path: str
4647
4748
:param frontends: All available frontends used by the proxy. Key as frontend name, value as
4849
module
4950
:param backends: All available backends used by the proxy. Key as backend name, value as
5051
module
5152
:param micro_services: All available micro services used by the proxy. Key as micro service name, value as
5253
module
54+
:param base_path: Base path for endpoint mapping
5355
"""
5456

5557
if not frontends or not backends:
@@ -68,6 +70,8 @@ def __init__(self, frontends, backends, micro_services):
6870
else:
6971
self.micro_services = {}
7072

73+
self.base_path = base_path
74+
7175
logger.debug("Loaded backends with endpoints: {}".format(backends))
7276
logger.debug("Loaded frontends with endpoints: {}".format(frontends))
7377
logger.debug("Loaded micro services with endpoints: {}".format(micro_services))
@@ -134,6 +138,19 @@ def _find_registered_endpoint(self, context, modules):
134138

135139
raise ModuleRouter.UnknownEndpoint(context.path)
136140

141+
def _find_backend(self, request_path):
142+
"""
143+
Tries to guess the backend in use from the request.
144+
Returns the backend name or None if the backend was not specified.
145+
"""
146+
request_path = request_path.lstrip("/")
147+
if self.base_path and request_path.startswith(self.base_path):
148+
request_path = request_path[len(self.base_path):].lstrip("/")
149+
backend_guess = request_path.split("/")[0]
150+
if backend_guess in self.backends:
151+
return backend_guess
152+
return None
153+
137154
def endpoint_routing(self, context):
138155
"""
139156
Finds and returns the endpoint function bound to the path
@@ -155,13 +172,12 @@ def endpoint_routing(self, context):
155172
msg = "Routing path: {path}".format(path=context.path)
156173
logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg)
157174
logger.debug(logline)
158-
path_split = context.path.split("/")
159-
backend = path_split[0]
160175

161-
if backend in self.backends:
176+
backend = self._find_backend(context.path)
177+
if backend is not None:
162178
context.target_backend = backend
163179
else:
164-
msg = "Unknown backend {}".format(backend)
180+
msg = "No backend was specified in request or no such backend {}".format(backend)
165181
logline = lu.LOG_FMT.format(
166182
id=lu.get_session_id(context.state), message=msg
167183
)
@@ -170,6 +186,8 @@ def endpoint_routing(self, context):
170186
try:
171187
name, frontend_endpoint = self._find_registered_endpoint(context, self.frontends)
172188
except ModuleRouter.UnknownEndpoint:
189+
for frontend in self.frontends.values():
190+
logger.debug(f"Unable to find {context.path} in {frontend['endpoints']}")
173191
pass
174192
else:
175193
context.target_frontend = name
@@ -183,7 +201,7 @@ def endpoint_routing(self, context):
183201
context.target_micro_service = name
184202
return micro_service_endpoint
185203

186-
if backend in self.backends:
204+
if backend is not None:
187205
backend_endpoint = self._find_registered_backend_endpoint(context)
188206
if backend_endpoint:
189207
return backend_endpoint

0 commit comments

Comments
 (0)