Skip to content

Commit 8c9d380

Browse files
Merge pull request #231 from skoranda/saml_unsolicited_idp
Add SAMLUnsolicitedFrontend class
2 parents dde0613 + a10250c commit 8c9d380

File tree

1 file changed

+224
-1
lines changed

1 file changed

+224
-1
lines changed

src/satosa/frontends/saml2.py

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

0 commit comments

Comments
 (0)