|
| 1 | +""" |
| 2 | +SATOSA microservice that includes in the assertion |
| 3 | +attributes taken from SAML metadata about the SAML |
| 4 | +IdP used for authentication. |
| 5 | +
|
| 6 | +The attributes that may be asserted from the SAML |
| 7 | +metadata for the IdP include |
| 8 | +
|
| 9 | +<mdui:DisplayName> |
| 10 | +<OrganiationName> |
| 11 | +<OrganizationDisplayName> |
| 12 | +
|
| 13 | +A typical configuration would be |
| 14 | +
|
| 15 | +module: idp_metadata_attribute_store.IdpMetadataAttributeStore |
| 16 | +name: IdpMetadataAttributeStore |
| 17 | +config: |
| 18 | + default: |
| 19 | + display_name: |
| 20 | + # SATOSA internal attribute name to use |
| 21 | + internal_attribute_name: idpdisplayname |
| 22 | + # Language preference with 'en' or English as default |
| 23 | + lang: en |
| 24 | + organization_name: |
| 25 | + internal_attribute_name: idporgname |
| 26 | + lang: en |
| 27 | + organization_display_name: |
| 28 | + internal_attribute_name: idporgdisplayname |
| 29 | + lang: en |
| 30 | + |
| 31 | + # Configuration may also be done per-IdP with any |
| 32 | + # missing parameters taken from the default if any. |
| 33 | + # The configuration key is the entityID of the IdP. |
| 34 | + # |
| 35 | + # For example: |
| 36 | + https://login.myorg.edu/idp/shibboleth: |
| 37 | + display_name: |
| 38 | + internal_attribute_name: othername |
| 39 | + lang: jp |
| 40 | + # The microservice may be configured to ignore a particular IdP. |
| 41 | + https://login.other.org.edu/idp/shibboleth: |
| 42 | + ignore: true |
| 43 | +""" |
| 44 | + |
| 45 | +import satosa.micro_services.base |
| 46 | +from satosa.logging_util import satosa_logging |
| 47 | +from satosa.exception import SATOSAError |
| 48 | + |
| 49 | +import copy |
| 50 | +import logging |
| 51 | + |
| 52 | +logger = logging.getLogger(__name__) |
| 53 | + |
| 54 | +class IdpMetadataAttributeStoreError(SATOSAError): |
| 55 | + """ |
| 56 | + LDAP attribute store error |
| 57 | + """ |
| 58 | + pass |
| 59 | + |
| 60 | +class IdpMetadataAttributeStore(satosa.micro_services.base.ResponseMicroService): |
| 61 | + """ |
| 62 | + Use the metadata store attached to the proxy SP in the context |
| 63 | + to lookup metadata about the IdP entity making the assertion |
| 64 | + and include metadata details as attributes in the assertion sent |
| 65 | + to the SP that made the request. |
| 66 | + """ |
| 67 | + |
| 68 | + config_defaults = { 'ignore' : False } |
| 69 | + |
| 70 | + def __init__(self, config, *args, **kwargs): |
| 71 | + super().__init__(*args, **kwargs) |
| 72 | + |
| 73 | + if 'default' in config and "" in config: |
| 74 | + msg = """Use either 'default' or "" in config but not both""" |
| 75 | + satosa_logging(logger, logging.ERROR, msg, None) |
| 76 | + raise IdpMetadataAttributeStoreError(msg) |
| 77 | + |
| 78 | + if "" in config: |
| 79 | + config['default'] = config.pop("") |
| 80 | + |
| 81 | + if 'default' not in config: |
| 82 | + msg = "No default configuration is present" |
| 83 | + satosa_logging(logger, logging.ERROR, msg, None) |
| 84 | + raise IdpMetadataAttributeStoreError(msg) |
| 85 | + |
| 86 | + self.config = {} |
| 87 | + |
| 88 | + # Process the default configuration first then any per-IdP overrides. |
| 89 | + idp_list = ['default'] |
| 90 | + idp_list.extend([ key for key in config.keys() if key != 'default' ]) |
| 91 | + |
| 92 | + for idp in idp_list: |
| 93 | + if not isinstance(config[idp], dict): |
| 94 | + msg = "Configuration value for {} must be a dictionary" |
| 95 | + satosa_logging(logger, logging.ERROR, msg, None) |
| 96 | + raise IdpMetadataAttributeStoreError(msg) |
| 97 | + |
| 98 | + # Initialize configuration using module defaults then update |
| 99 | + # with configuration defaults and then per-IdP overrides. |
| 100 | + idp_config = copy.deepcopy(IdpMetadataAttributeStore.config_defaults) |
| 101 | + if 'default' in self.config: |
| 102 | + idp_config.update(self.config['default']) |
| 103 | + idp_config.update(config[idp]) |
| 104 | + |
| 105 | + self.config[idp] = idp_config |
| 106 | + |
| 107 | + satosa_logging(logger, logging.INFO, "IdP Metadata Attribute Store microservice initialized", None) |
| 108 | + |
| 109 | + def _first_lang_element_text(self, elements, lang='en'): |
| 110 | + """ |
| 111 | + Loop over the list representing XML elements that contain text and find |
| 112 | + the first text value for the input lang where 'en' or English is the |
| 113 | + default lang. |
| 114 | +
|
| 115 | + Each item in the list is a dictionary with keys |
| 116 | +
|
| 117 | + __class__ |
| 118 | + lang |
| 119 | + text |
| 120 | +
|
| 121 | + as expected from the metadata returned for an entity by the MetadataStore |
| 122 | + class from pysaml2. |
| 123 | +
|
| 124 | + If no element has the input lang then return the text from the first |
| 125 | + element. |
| 126 | +
|
| 127 | + If no element has text then return an empty string. |
| 128 | + """ |
| 129 | + for e in elements: |
| 130 | + if lang in e: |
| 131 | + if 'text' in e: |
| 132 | + return e['text'] |
| 133 | + |
| 134 | + for e in elements: |
| 135 | + if 'text' in e: |
| 136 | + return e['text'] |
| 137 | + |
| 138 | + return '' |
| 139 | + |
| 140 | + def process(self, context, data): |
| 141 | + """ |
| 142 | + Default interface for microservices. Process the input data for |
| 143 | + the input context. |
| 144 | + """ |
| 145 | + self.context = context |
| 146 | + |
| 147 | + # Find the entityID for the IdP that issued the assertion. |
| 148 | + try: |
| 149 | + idp_entity_id = data.to_dict()['auth_info']['issuer'] |
| 150 | + except KeyError as err: |
| 151 | + satosa_logging(logger, logging.ERROR, "Unable to determine the entityID for the IdP issuer", context.state) |
| 152 | + return super().process(context, data) |
| 153 | + |
| 154 | + # Get the configuration for the IdP. |
| 155 | + if idp_entity_id in self.config.keys(): |
| 156 | + config = self.config[idp_entity_id] |
| 157 | + else: |
| 158 | + config = self.config['default'] |
| 159 | + |
| 160 | + satosa_logging(logger, logging.DEBUG, "Using config {}".format(config), context.state) |
| 161 | + |
| 162 | + # Ignore this IdP if so configured. |
| 163 | + if config['ignore']: |
| 164 | + satosa_logging(logger, logging.INFO, "Ignoring IdP {}".format(idp_entity_id), context.state) |
| 165 | + return super().process(context, data) |
| 166 | + |
| 167 | + # Get the metadata store the SP for the proxy is using. This |
| 168 | + # will be an instance of the class MetadataStore from mdstore.py |
| 169 | + # in pysaml2. |
| 170 | + metadata_store = context.internal_data['metadata_store'] |
| 171 | + |
| 172 | + # Get the metadata for the IdP. |
| 173 | + try: |
| 174 | + metadata = metadata_store[idp_entity_id] |
| 175 | + except Exception as err: |
| 176 | + satosa_logging(logger, logging.ERROR, "Unable to retrieve metadata for IdP {}".format(idp_entity_id), context.state) |
| 177 | + return super().process(context, data) |
| 178 | + |
| 179 | + satosa_logging(logger, logging.DEBUG, "Metadata for IdP {} is {}".format(idp_entity_id, metadata), context.state) |
| 180 | + |
| 181 | + # Find the mdui:DisplayName for the IdP if so configured. |
| 182 | + if 'display_name' in config: |
| 183 | + lang = config['display_name'].get('lang', 'en') |
| 184 | + try: |
| 185 | + # We assume there is only one IDPSSODescriptor in the IdP metadata. |
| 186 | + extensions = metadata['idpsso_descriptor'][0]['extensions']['extension_elements'] |
| 187 | + for e in extensions: |
| 188 | + if e['__class__'] == 'urn:oasis:names:tc:SAML:metadata:ui&UIInfo': |
| 189 | + display_name_elements = e['display_name'] |
| 190 | + display_name = self._first_lang_element_text(display_name_elements, lang) |
| 191 | + break |
| 192 | + |
| 193 | + if display_name: |
| 194 | + satosa_logging(logger, logging.DEBUG, "display_name is {}".format(display_name), context.state) |
| 195 | + data.attributes[config['display_name']['internal_attribute_name']] = display_name |
| 196 | + |
| 197 | + except Exception as err: |
| 198 | + satosa_logging(logger, logging.WARN, "Unable to determine display name for {}".format(idp_entity_id), context.state) |
| 199 | + |
| 200 | + # Find the OrganizationDisplayName for the IdP if so configured. |
| 201 | + if 'organization_display_name' in config: |
| 202 | + lang = config['organization_display_name'].get('lang', 'en') |
| 203 | + try: |
| 204 | + org_display_name_elements = metadata['organization']['organization_display_name'] |
| 205 | + organization_display_name = self._first_lang_element_text(org_display_name_elements, lang) |
| 206 | + |
| 207 | + if organization_display_name: |
| 208 | + satosa_logging(logger, logging.DEBUG, "organization_display_name is {}".format(organization_display_name), context.state) |
| 209 | + data.attributes[config['organization_display_name']['internal_attribute_name']] = organization_display_name |
| 210 | + |
| 211 | + except Exception as err: |
| 212 | + satosa_logging(logger, logging.WARN, "Unable to determine organization display name for {}".format(idp_entity_id), context.state) |
| 213 | + |
| 214 | + # Find the OrganizationName for the IdP if so configured. |
| 215 | + if 'organization_name' in config: |
| 216 | + lang = config['organization_name'].get('lang', 'en') |
| 217 | + try: |
| 218 | + org_name_elements = metadata['organization']['organization_name'] |
| 219 | + organization_name = self._first_lang_element_text(org_name_elements, lang) |
| 220 | + |
| 221 | + if organization_name: |
| 222 | + satosa_logging(logger, logging.DEBUG, "organization_name is {}".format(organization_name), context.state) |
| 223 | + data.attributes[config['organization_name']['internal_attribute_name']] = organization_name |
| 224 | + |
| 225 | + except Exception as err: |
| 226 | + satosa_logging(logger, logging.WARN, "Unable to determine organization display name for {}".format(idp_entity_id), context.state) |
| 227 | + |
| 228 | + satosa_logging(logger, logging.DEBUG, "Returning data.attributes {}".format(str(data.attributes)), context.state) |
| 229 | + return super().process(context, data) |
0 commit comments