Skip to content

Commit b253b30

Browse files
committed
fixup! ModuleRouter: support paths in BASE
Rebased to current master. When composing the paths, use os.path.join primarily, since it handles empty strings and duplicate separators logically. As long as we use the BASE_URL in the OpenID Connect frontend as an issuer, it's not possible to create multiple provider discovery URLs. Add documentation and a comment to explain this limitation.
1 parent 3ce3a64 commit b253b30

File tree

5 files changed

+90
-26
lines changed

5 files changed

+90
-26
lines changed

doc/README.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,8 @@ bind_password: !ENVFILE LDAP_BIND_PASSWORD_FILE
7878

7979
| Parameter name | Data type | Example value | Description |
8080
| -------------- | --------- | ------------- | ----------- |
81-
| `BASE` | string | `https://proxy.example.com` | base url of the proxy |
81+
| `BASE` | string | `https://proxy.example.com` | The base url of the proxy. For the OIDC Frontend, this is used to set the issuer as well, and due to implementation constraints, avoid
82+
using trailing slashes in this case. |
8283
| `COOKIE_STATE_NAME` | string | `satosa_state` | name of the cookie SATOSA uses for preserving state between requests |
8384
| `CONTEXT_STATE_DELETE` | bool | `True` | controls whether SATOSA will delete the state cookie after receiving the authentication response from the upstream IdP|
8485
| `STATE_ENCRYPTION_KEY` | string | `52fddd3528a44157` | key used for encrypting the state cookie, will be overridden by the environment variable `SATOSA_STATE_ENCRYPTION_KEY` if it is set |

src/satosa/frontends/base.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,19 @@ def __init__(self, auth_req_callback_func, internal_attributes, base_url, name):
1717
:type auth_req_callback_func:
1818
(satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response
1919
:type internal_attributes: dict[str, dict[str, str | list[str]]]
20+
:type base_url: str
2021
:type name: str
2122
2223
:param auth_req_callback_func: Callback should be called by the module after the
2324
authorization response has been processed.
25+
:param internal_attributes: attribute mapping
26+
:param base_url: base url of the proxy
2427
:param name: name of the plugin
2528
"""
2629
self.auth_req_callback_func = auth_req_callback_func
2730
self.internal_attributes = internal_attributes
2831
self.converter = AttributeMapper(internal_attributes)
29-
self.base_url = base_url.rstrip("/") if base_url else ""
32+
self.base_url = base_url or ""
3033
self.name = name
3134
self.endpoint_baseurl = os.path.join(self.base_url, self.name)
3235
self.endpoint_basepath = urlparse(self.endpoint_baseurl).path.lstrip("/")

src/satosa/frontends/openid_connect.py

+31-9
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import json
66
import logging
7+
import os.path
78
from collections import defaultdict
89
from urllib.parse import urlencode, urlparse
910

@@ -172,7 +173,16 @@ def register_endpoints(self, backend_names):
172173
:rtype: list[(str, ((satosa.context.Context, Any) -> satosa.response.Response, Any))]
173174
:raise ValueError: if more than one backend is configured
174175
"""
175-
provider_config = ("^.well-known/openid-configuration$", self.provider_config)
176+
# See https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig
177+
# Unfortunately since the issuer is always `base_url` for all OIDC frontend instances,
178+
# the discovery endpoint will be the same for every instance.
179+
# This means that only one frontend will be usable for autodiscovery.
180+
autoconf_path = ".well-known/openid-configuration"
181+
base_path = urlparse(self.base_url).path.lstrip("/")
182+
provider_config = (
183+
"^{}$".format(os.path.join(base_path, autoconf_path)),
184+
self.provider_config,
185+
)
176186
jwks_uri = ("^{}/jwks$".format(self.endpoint_basepath), self.jwks)
177187

178188
backend_name = None
@@ -193,35 +203,47 @@ def register_endpoints(self, backend_names):
193203

194204
if backend_name:
195205
# if there is only one backend, include its name in the path so the default routing can work
196-
auth_endpoint = "{}/{}/{}/{}".format(self.base_url, backend_name, self.name, AuthorizationEndpoint.url)
206+
auth_endpoint = os.path.join(
207+
self.base_url,
208+
backend_name,
209+
self.name,
210+
AuthorizationEndpoint.url,
211+
)
197212
self.provider.configuration_information["authorization_endpoint"] = auth_endpoint
198213
auth_path = urlparse(auth_endpoint).path.lstrip("/")
199214
else:
200-
auth_path = "{}/{}".format(self.endpoint_basepath, AuthorizationEndpoint.url)
215+
auth_path = os.path.join(self.endpoint_basepath, AuthorizationRequest.url)
201216

202217
authentication = ("^{}$".format(auth_path), self.handle_authn_request)
203218
url_map = [provider_config, jwks_uri, authentication]
204219

205220
if any("code" in v for v in self.provider.configuration_information["response_types_supported"]):
206-
self.provider.configuration_information["token_endpoint"] = "{}/{}".format(
207-
self.endpoint_baseurl, TokenEndpoint.url
221+
self.provider.configuration_information["token_endpoint"] = os.path.join(
222+
self.endpoint_baseurl,
223+
TokenEndpoint.url,
208224
)
209225
token_endpoint = (
210-
"^{}/{}".format(self.endpoint_basepath, TokenEndpoint.url), self.token_endpoint
226+
"^{}".format(os.path.join(self.endpoint_basepath, TokenEndpoint.url)),
227+
self.token_endpoint,
211228
)
212229
url_map.append(token_endpoint)
213230

214231
self.provider.configuration_information["userinfo_endpoint"] = (
215-
"{}/{}".format(self.endpoint_baseurl, UserinfoEndpoint.url)
232+
os.path.join(self.endpoint_baseurl, UserinfoEndpoint.url)
216233
)
217234
userinfo_endpoint = (
218-
"^{}/{}".format(self.endpoint_basepath, UserinfoEndpoint.url), self.userinfo_endpoint
235+
"^{}".format(
236+
os.path.join(self.endpoint_basepath, UserinfoEndpoint.url)
237+
),
238+
self.userinfo_endpoint,
219239
)
220240
url_map.append(userinfo_endpoint)
221241

222242
if "registration_endpoint" in self.provider.configuration_information:
223243
client_registration = (
224-
"^{}/{}".format(self.endpoint_basepath, RegistrationEndpoint.url),
244+
"^{}".format(
245+
os.path.join(self.endpoint_basepath, RegistrationEndpoint.url)
246+
),
225247
self.client_registration,
226248
)
227249
url_map.append(client_registration)

tests/flows/test_oidc-saml.py

+12-5
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ def oidc_frontend_config(signing_key_path):
5252
"module": "satosa.frontends.openid_connect.OpenIDConnectFrontend",
5353
"name": "OIDCFrontend",
5454
"config": {
55-
"issuer": "https://proxy-op.example.com",
5655
"signing_key_path": signing_key_path,
5756
"provider": {"response_types_supported": ["id_token"]},
5857
"client_db_uri": DB_URI, # use mongodb for integration testing
@@ -69,7 +68,6 @@ def oidc_stateless_frontend_config(signing_key_path, client_db_path):
6968
"module": "satosa.frontends.openid_connect.OpenIDConnectFrontend",
7069
"name": "OIDCFrontend",
7170
"config": {
72-
"issuer": "https://proxy-op.example.com",
7371
"signing_key_path": signing_key_path,
7472
"client_db_path": client_db_path,
7573
"db_uri": "stateless://user:abc123@localhost",
@@ -94,6 +92,12 @@ def _client_setup(self):
9492
"response_types": ["id_token"]
9593
}
9694

95+
def _discover_provider(self, client, provider):
96+
discovery_path = (
97+
os.path.join(urlparse(provider).path, ".well-known/openid-configuration")
98+
)
99+
return json.loads(client.get(discovery_path).data.decode("utf-8"))
100+
97101
def test_full_flow(self, satosa_config_dict, oidc_frontend_config, saml_backend_config, idp_conf):
98102
self._client_setup()
99103
subject_id = "testuser1"
@@ -110,7 +114,8 @@ def test_full_flow(self, satosa_config_dict, oidc_frontend_config, saml_backend_
110114
test_client = Client(make_app(SATOSAConfig(satosa_config_dict)), Response)
111115

112116
# get frontend OP config info
113-
provider_config = json.loads(test_client.get("/.well-known/openid-configuration").data.decode("utf-8"))
117+
issuer = satosa_config_dict["BASE"]
118+
provider_config = self._discover_provider(test_client, issuer)
114119

115120
# create auth req
116121
claims_request = ClaimsRequest(id_token=Claims(**{k: None for k in USERS[subject_id]}))
@@ -168,7 +173,8 @@ def test_full_stateless_id_token_flow(self, satosa_config_dict, oidc_stateless_f
168173
test_client = Client(make_app(SATOSAConfig(satosa_config_dict)), Response)
169174

170175
# get frontend OP config info
171-
provider_config = json.loads(test_client.get("/.well-known/openid-configuration").data.decode("utf-8"))
176+
issuer = satosa_config_dict["BASE"]
177+
provider_config = self._discover_provider(test_client, issuer)
172178

173179
# create auth req
174180
claims_request = ClaimsRequest(id_token=Claims(**{k: None for k in USERS[subject_id]}))
@@ -226,7 +232,8 @@ def test_full_stateless_code_flow(self, satosa_config_dict, oidc_stateless_front
226232
test_client = Client(make_app(SATOSAConfig(satosa_config_dict)), Response)
227233

228234
# get frontend OP config info
229-
provider_config = json.loads(test_client.get("/.well-known/openid-configuration").data.decode("utf-8"))
235+
issuer = satosa_config_dict["BASE"]
236+
provider_config = self._discover_provider(test_client, issuer)
230237

231238
# create auth req
232239
claims_request = ClaimsRequest(id_token=Claims(**{k: None for k in USERS[subject_id]}))

tests/satosa/frontends/test_openid_connect.py

+41-10
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
INTERNAL_ATTRIBUTES = {
2929
"attributes": {"mail": {"saml": ["email"], "openid": ["email"]}}
3030
}
31-
BASE_URL = "https://op.example.com"
31+
BASE_URL = "https://op.example.com/satosa"
3232
CLIENT_ID = "client1"
3333
CLIENT_SECRET = "client_secret"
3434
EXTRA_CLAIMS = {
@@ -86,10 +86,10 @@ def frontend_config_with_extra_id_token_claims(self, signing_key_path):
8686

8787
return config
8888

89-
def create_frontend(self, frontend_config):
89+
def create_frontend(self, frontend_config, issuer=BASE_URL):
9090
# will use in-memory storage
9191
instance = OpenIDConnectFrontend(lambda ctx, req: None, INTERNAL_ATTRIBUTES,
92-
frontend_config, BASE_URL, "oidc_frontend")
92+
frontend_config, issuer, "oidc_frontend")
9393
instance.register_endpoints(["foo_backend"])
9494
return instance
9595

@@ -369,19 +369,45 @@ def test_jwks(self, context, frontend):
369369
jwks = json.loads(http_response.message)
370370
assert jwks == {"keys": [frontend.signing_key.serialize()]}
371371

372-
def test_register_endpoints_token_and_userinfo_endpoint_is_published_if_necessary(self, frontend):
372+
@pytest.mark.parametrize("issuer", [
373+
"https://example.com",
374+
"https://example.com/some/op",
375+
"https://example.com/"
376+
])
377+
def test_register_endpoints_handles_path_in_issuer(self, frontend_config, issuer):
378+
frontend = self.create_frontend(frontend_config, issuer)
379+
issuer_path = urlparse(issuer).path[1:]
380+
if issuer_path:
381+
issuer_path += "/"
373382
urls = frontend.register_endpoints(["test"])
374-
assert ("^{}/{}".format(frontend.name, TokenEndpoint.url), frontend.token_endpoint) in urls
375-
assert ("^{}/{}".format(frontend.name, UserinfoEndpoint.url), frontend.userinfo_endpoint) in urls
383+
assert (
384+
"^{}{}$".format(issuer_path, ".well-known/openid-configuration"),
385+
frontend.provider_config,
386+
) in urls
387+
388+
assert (
389+
"^{}/{}".format(frontend.endpoint_basepath, TokenEndpoint.url),
390+
frontend.token_endpoint,
391+
) in urls
392+
assert (
393+
"^{}/{}".format(frontend.endpoint_basepath, UserinfoEndpoint.url),
394+
frontend.userinfo_endpoint,
395+
) in urls
376396

377397
def test_register_endpoints_token_and_userinfo_endpoint_is_not_published_if_only_implicit_flow(
378398
self, frontend_config, context):
379399
frontend_config["provider"]["response_types_supported"] = ["id_token", "id_token token"]
380400
frontend = self.create_frontend(frontend_config)
381401

382402
urls = frontend.register_endpoints(["test"])
383-
assert ("^{}/{}".format("test", TokenEndpoint.url), frontend.token_endpoint) not in urls
384-
assert ("^{}/{}".format("test", UserinfoEndpoint.url), frontend.userinfo_endpoint) not in urls
403+
assert (
404+
"^{}/{}".format(frontend.endpoint_basepath, TokenEndpoint.url),
405+
frontend.token_endpoint,
406+
) not in urls
407+
assert (
408+
"^{}/{}".format(frontend.endpoint_basepath, UserinfoEndpoint.url),
409+
frontend.userinfo_endpoint,
410+
) not in urls
385411

386412
http_response = frontend.provider_config(context)
387413
provider_config = ProviderConfigurationResponse().deserialize(http_response.message, "json")
@@ -397,8 +423,13 @@ def test_register_endpoints_dynamic_client_registration_is_configurable(
397423
frontend = self.create_frontend(frontend_config)
398424

399425
urls = frontend.register_endpoints(["test"])
400-
assert (("^{}/{}".format(frontend.name, RegistrationEndpoint.url),
401-
frontend.client_registration) in urls) == client_registration_enabled
426+
assert (
427+
(
428+
"^{}/{}".format(frontend.endpoint_basepath, RegistrationEndpoint.url),
429+
frontend.client_registration,
430+
)
431+
in urls
432+
) == client_registration_enabled
402433
provider_info = ProviderConfigurationResponse().deserialize(frontend.provider_config(None).message, "json")
403434
assert ("registration_endpoint" in provider_info) == client_registration_enabled
404435

0 commit comments

Comments
 (0)