Skip to content

Commit 548ac44

Browse files
skorandac00kiemon5ter
authored andcommitted
First commit of the SAMLUnsolicitedFrontend class
First commit of the SAMLUnsolicitedFrontend class that provides all of the functionality of the base SAMLFrontend class but also enables an unsolicited endpoint that can be used to initiate a SAML flow using a proprietary set of query string parameters that are not part of any SAML standard but follow closely similar functionality from the Shibboleth project.
1 parent dde0613 commit 548ac44

File tree

1 file changed

+193
-1
lines changed

1 file changed

+193
-1
lines changed

src/satosa/frontends/saml2.py

Lines changed: 193 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import re
99
from base64 import urlsafe_b64decode
1010
from base64 import urlsafe_b64encode
11+
from base64 import b64encode
1112
from urllib.parse import quote
1213
from urllib.parse import quote_plus
1314
from urllib.parse import unquote
@@ -16,7 +17,9 @@
1617
from http.cookies import SimpleCookie
1718

1819
from saml2 import SAMLError, xmldsig
19-
from saml2.config import IdPConfig
20+
from saml2 import BINDING_HTTP_POST
21+
from saml2.client_base import Base
22+
from saml2.config import IdPConfig, SPConfig
2023
from saml2.extension.ui import NAMESPACE as UI_NAMESPACE
2124
from saml2.metadata import create_metadata_string
2225
from saml2.saml import NameID
@@ -28,6 +31,7 @@
2831
from saml2.server import Server
2932

3033
from satosa.base import SAMLBaseModule
34+
from satosa.backends.saml2 import SAMLBackend
3135
from satosa.context import Context
3236
from .base import FrontendModule
3337
from ..logging_util import satosa_logging
@@ -1014,3 +1018,191 @@ def _register_endpoints(self, backend_names):
10141018
logger.debug("Adding mapping {}".format(mapping))
10151019

10161020
return url_to_callable_mappings
1021+
1022+
1023+
class SAMLUnsolicitedFrontend(SAMLFrontend):
1024+
"""
1025+
Frontend module that provides all of the functionality of the base class
1026+
SAMLFrontend but also provides a proprietary endpoint for initiating
1027+
unsolicted SAML flows. The unsolicited SAML flows are not part of any
1028+
SAML standard.
1029+
"""
1030+
KEY_ENDPOINT = "endpoint"
1031+
KEY_DISCO_WHITE = "discovery_service_whitelist"
1032+
KEY_QUERY_SP = "providerId"
1033+
KEY_QUERY_ACS = "shire"
1034+
KEY_QUERY_RELAY = "target"
1035+
KEY_QUERY_DISCO = "discoveryURL"
1036+
KEY_SAML_DISCOVERY_SERVICE_URL = SAMLBackend.KEY_SAML_DISCOVERY_SERVICE_URL
1037+
KEY_UNSOLICITED = "unsolicited"
1038+
1039+
def __init__(self, auth_req_callback_func, internal_attributes, config,
1040+
base_url, name):
1041+
super().__init__(auth_req_callback_func, internal_attributes, config,
1042+
base_url, name)
1043+
1044+
def register_endpoints(self, backend_names):
1045+
"""
1046+
See super class
1047+
satosa.frontends.saml2.SAMLFrontend#register_endpoints
1048+
1049+
:type providers: list[str]
1050+
:rtype: list[(str, ((satosa.context.Context, Any)
1051+
-> satosa.response.Response, Any))]
1052+
:param providers: A list of backend providers
1053+
:return: A list of endpoint/method pairs
1054+
"""
1055+
url_map = super().register_endpoints(backend_names)
1056+
1057+
path = urlparse(
1058+
self.config[self.KEY_UNSOLICITED]
1059+
.get(self.KEY_ENDPOINT)).path
1060+
1061+
for backend in backend_names:
1062+
pat = '(^{})/{}$'.format(backend, path)
1063+
url_map.append((pat, self.unsolicited_endpoint))
1064+
1065+
logger.debug("URL maps to be registered are {}".format(url_map))
1066+
1067+
return url_map
1068+
1069+
def unsolicited_endpoint(self, context):
1070+
"""
1071+
Endpoint to process unsolicited SAML flows. The unsolicited flows
1072+
are proprietary and not defined as part of any SAML standard.
1073+
1074+
:type context: satosa.context.Context
1075+
:rtype: satosa.response.Response
1076+
1077+
:param context: The current context
1078+
:return: response
1079+
"""
1080+
request = context.request
1081+
1082+
target_sp_entity_id = request.get(self.KEY_QUERY_SP, None)
1083+
target_sp_acs_url = request.get(self.KEY_QUERY_ACS, None)
1084+
target_sp_relay_state_url = request.get(self.KEY_QUERY_RELAY, None)
1085+
requested_disco_url = request.get(self.KEY_QUERY_DISCO, None)
1086+
1087+
logger.debug("Unsolicited target SP is {}".format(target_sp_entity_id))
1088+
logger.debug("Unsolicited ACS URL is {}".format(target_sp_acs_url))
1089+
logger.debug("Unsolicited relay state is {}".format(
1090+
target_sp_relay_state_url))
1091+
logger.debug("Unsolicted discovery URL is {}".format(
1092+
requested_disco_url))
1093+
1094+
# We only proceed with known federated SPs.
1095+
try:
1096+
target_sp_metadata = self.idp.metadata[target_sp_entity_id]
1097+
except KeyError:
1098+
msg = "Target SP with entityID {} is unknown in metadata"
1099+
msg = msg.format(target_sp_entity_id)
1100+
satosa_logging(logger, logging.ERROR, msg, context.state)
1101+
raise SATOSAError(msg)
1102+
1103+
# The SP ACS URL if input must match one from the trusted metadata.
1104+
# We assume the SP only has one SPSSODescriptor element in metadata.
1105+
acs_ob_list = (target_sp_metadata.get("spsso_descriptor", [{}])[0]
1106+
.get("assertion_consumer_service", [{}]))
1107+
acs_locations = [acs_ob['location'] for acs_ob in acs_ob_list]
1108+
1109+
if target_sp_acs_url:
1110+
if target_sp_acs_url not in acs_locations:
1111+
msg = "Target ACS URL {} not allowed"
1112+
msg = msg.format(target_sp_acs_url)
1113+
satosa_logging(logger, logging.ERROR, msg, context.state)
1114+
raise SATOSAError(msg)
1115+
else:
1116+
for acs_ob in acs_ob_list:
1117+
# We assume the SP has HTTP_POST binding and we simply
1118+
# take the first one we find.
1119+
if acs_ob['binding'] == BINDING_HTTP_POST:
1120+
target_sp_acs_url = acs_ob['location']
1121+
logger.debug("Unsolicited found SP ACS URL {}".format(
1122+
target_sp_acs_url))
1123+
break
1124+
1125+
if not target_sp_acs_url:
1126+
msg = "No ACS for SP with entityID {}".format(target_sp_entity_id)
1127+
satosa_logging(logger, logging.ERROR, msg, context.state)
1128+
raise SATOSAError(msg)
1129+
1130+
# If provided the exact scheme, host, and port for relay state URL
1131+
# must match that of the target SP ACS URL.
1132+
if target_sp_relay_state_url:
1133+
target = urlparse(target_sp_relay_state_url)
1134+
acs = urlparse(target_sp_acs_url)
1135+
if not (target.scheme == acs.scheme and
1136+
target.netloc == acs.netloc and target.port == acs.port):
1137+
msg = "RelayState {} is not permitted"
1138+
msg = msg.format(target_sp_relay_state_url)
1139+
satosa_logging(logger, logging.ERROR, msg, context.state)
1140+
raise SATOSAError(msg)
1141+
1142+
# Create a temporary SP configuration to represent the target SP.
1143+
acs = [[target_sp_acs_url, BINDING_HTTP_POST]]
1144+
sp_config_dict = {
1145+
"entityid": target_sp_entity_id,
1146+
"service": {
1147+
"sp": {
1148+
"endpoints": {
1149+
"assertion_consumer_service": acs
1150+
}
1151+
}
1152+
}
1153+
}
1154+
sp_config = SPConfig().load(sp_config_dict, False)
1155+
1156+
# Create a temporary SP object and use it to create a authn request
1157+
# with a destination of our own SingleSignOnService location with
1158+
# HTTP-POST binding.
1159+
target_sp = Base(sp_config)
1160+
1161+
destination = None
1162+
endpoints = self.idp.config.getattr('endpoints')
1163+
sso_service_list = endpoints['single_sign_on_service']
1164+
for location, binding in sso_service_list:
1165+
if binding == BINDING_HTTP_POST:
1166+
destination = location
1167+
break
1168+
1169+
if not destination:
1170+
msg = ("Could not determine location for SingleSignOnService "
1171+
"with HTTP-POST binding")
1172+
satosa_logging(logger, logging.ERROR, msg, context.state)
1173+
raise SATOSAError(msg)
1174+
1175+
logger.debug("Unsolicited using destination {}".format(destination))
1176+
1177+
req_id, authn_request = target_sp.create_authn_request(destination)
1178+
1179+
# Convert the authn request object to an encoded set of bytes.
1180+
authn_request_str = "{}".format(authn_request)
1181+
logger.debug("Unsolicted authn request is {}".format(
1182+
authn_request_str))
1183+
authn_request_bytes = authn_request_str.encode('utf-8')
1184+
authn_request_encoded = b64encode(authn_request_bytes)
1185+
1186+
# Add the authn request to the context as if it arrived through
1187+
# an endpoint.
1188+
context.request["SAMLRequest"] = authn_request_encoded
1189+
1190+
# Add the relay state to the context if provided.
1191+
if target_sp_relay_state_url:
1192+
context.request["RelayState"] = target_sp_relay_state_url
1193+
1194+
# If provided and is whitelisted set the discovery service to use.
1195+
if requested_disco_url:
1196+
allowed = (self.config[self.KEY_UNSOLICITED]
1197+
.get(self.KEY_DISCO_WHITE))
1198+
if requested_disco_url not in allowed:
1199+
msg = "Discovery service {} not allowed"
1200+
msg = msg.format(requested_disco_url)
1201+
satosa_logging(logger, logging.ERROR, msg, context.state)
1202+
raise SATOSAError(msg)
1203+
1204+
context.decorate(self.KEY_SAML_DISCOVERY_SERVICE_URL,
1205+
requested_disco_url)
1206+
1207+
# Handle the authn request use the base class.
1208+
return self._handle_authn_request(context, BINDING_HTTP_POST, self.idp)

0 commit comments

Comments
 (0)