From d06b67add454805437f38d2418fe03fea0547743 Mon Sep 17 00:00:00 2001 From: Martin Obersteiner Date: Mon, 22 Jul 2024 10:07:44 +0200 Subject: [PATCH] cli: make saml-config output configurable --- invenio_config_tugraz/cli.py | 163 +++++------------- .../config_templates/tugraz/notes.jinja | 12 ++ .../config_templates/tugraz/saml.cfg.jinja | 115 ++++++++++++ 3 files changed, 166 insertions(+), 124 deletions(-) create mode 100644 invenio_config_tugraz/config_templates/tugraz/notes.jinja create mode 100644 invenio_config_tugraz/config_templates/tugraz/saml.cfg.jinja diff --git a/invenio_config_tugraz/cli.py b/invenio_config_tugraz/cli.py index dd7e01a..621db1c 100644 --- a/invenio_config_tugraz/cli.py +++ b/invenio_config_tugraz/cli.py @@ -12,13 +12,17 @@ """Click-based command-line interface for invenio-config-tugraz package.""" +import pathlib import sys from collections.abc import Iterable -from copy import deepcopy +from functools import partial +from importlib import resources +from importlib.abc import Traversable from itertools import chain import black import click +from jinja2 import Environment, loaders from saml2.config import Config from saml2.mdstore import MetadataStore @@ -45,100 +49,6 @@ def __str__(self) -> str: return self.string -# when echoed, this represents as the configuration for TU Graz's SSO-IdP -TUGRAZ_IDP_ECHO_DICT = { - # Basic info - "title": "TUGRAZ", - "description": "TUGRAZ shibboleth Authentication Service", - "icon": "", - # path to the file i.e. "./saml/sp.crt" - "sp_cert_file": "./saml/idp/cert/sp.crt", - "sp_key_file": "./saml/idp/cert/sp.key", - "settings": { - # If strict is True, then the Python Toolkit will reject unsigned - # or unencrypted messages if it expects them to be signed or encrypted. - # Also it will reject the messages if the SAML standard is not strictly - # followed. Destination, NameId, Conditions ... are validated too. - "strict": True, - # Enable debug mode (outputs errors). - "debug": True, - # Service Provider Data that we are deploying. - "sp": { - # Specifies the constraints on the name identifier to be used to - # represent the requested subject. - # Take a look on https://github.com/onelogin/python-saml/blob/master/src/onelogin/saml2/constants.py - # to see the NameIdFormat that are supported. - "NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", - }, - # Identity Provider Data that we want connected with our SP. - "idp": { - # Identifier of the IdP entity (must be a URI) - "entityId": "https://auth.tugraz.at/auth/realms/tugraz", - # SSO endpoint info of the IdP. (Authentication Request protocol) - "singleSignOnService": { - # URL Target of the IdP where the Authentication Request Message - # will be sent. - "url": "https://auth.tugraz.at/auth/realms/tugraz/protocol/saml", - # SAML protocol binding to be used when returning the - # message. OneLogin Toolkit supports the HTTP-Redirect binding - # only for this endpoint. - "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", - }, - # SLO endpoint info of the IdP. - "singleLogoutService": { - # URL Location where the from the IdP will be sent (IdP-initiated logout) - "url": "https://sso.tugraz.at/slo/Logout", - # SAML protocol binding to be used when returning the - # message. OneLogin Toolkit supports the HTTP-Redirect binding - # only for this endpoint. - "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", - }, - # Public X.509 certificate of the IdP - "x509cert": "MIICmzCCAYMCBgFu/kDRhjANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZ0dWdyYXowHhcNMTkxMjEzMDc1MzExWhcNMjkxMjEzMDc1NDUxWjARMQ8wDQYDVQQDDAZ0dWdyYXowggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDXnaCOxODAXaobWyU0dBxlDSBC0n6YwvYyR1WCaCG57M2DZuA90aILgejhWn4X71al/+CixU5xRegIv7z843+CWBAcXkGQT/O/bfklzF2CvW2XtVftgCUwNOqnOynXA92Ge3YuJbIBxmK3/9XDiAuQ06+tmdZdTOaFyxfLX4TD+agwDd1v5MyK0B3f7yKZ+DEkXVhawj5gAgG+2XJFnM+3kY6tMmSG8af/GdXqnr3bYn1lAWzcRQgSkjasdMUgHpzp3NY2f48uQqoFuZ3frahNT+dl+hrfDC3Ix9D6ePtLBGRrraWBec/BrlcRr9SuaFq1SLGVSRKmkwE3KyyqLCLlAgMBAAEwDQYJKoZIhvcNAQELBQADggEBANMkjmxhXmiNe+uznV4SEWBrMpKEevOkwqrGnSEtx/QSZZ3G0GVHOSRTo+v6G7CukES2zSV1NHSTRbJSbrDK1UmS66N+x9PWfFMLIn0WN1acef5zp516F9qhVgcztjQPmfexIbpe5FYTuYWvptBWs5m4GgbWeBxtjimKS5dOjG5TskFtVH/MEcJk3LRqy8fIksg3Z5eREXQWzbjpvtz/9L/7n4+DzZprVr6VoBjsTn/AJ1d5Q0U9elKOM0o5G3pJWPhT6/gwVpkpqnH6AuGhcXZpxpAS+PGNJghhiJT8odFmoBur24ubYZVPVPDc10/1LKFIJT1vkB8bem/PrhHZDjw=", - }, - # Security settings - # more on https://github.com/onelogin/python-saml - "security": { - "authnRequestsSigned": False, - "failOnAuthnContextMismatch": False, - "logoutRequestSigned": False, - "logoutResponseSigned": False, - "metadataCacheDuration": None, - "metadataValidUntil": None, - "nameIdEncrypted": False, - "requestedAuthnContext": False, - "requestedAuthnContextComparison": "exact", - "signMetadata": False, - "signatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", - "wantAssertionsEncrypted": False, - "wantAssertionsSigned": False, - "wantAttributeStatement": False, - "wantMessagesSigned": False, - "wantNameId": True, - "wantNameIdEncrypted": False, - "digestAlgorithm": "http://www.w3.org/2001/04/xmlenc#sha256", - }, - }, - # Account Mapping - # replace `origin_field` to IDP Attributes - "mappings": { - "email": "urn:oid:0.9.2342.19200300.100.1.3", - "name": "urn:oid:2.5.4.42", - "surname": "urn:oid:2.5.4.4", - "external_id": "urn:oid:1.3.6.1.4.1.5923.1.1.1.6", - # Custom - not used - # 'org_id': 'urn:oid:CO-ORGUNITID', # orgunitid - # 'org_name': 'urn:oid:CO-ORGUNITNAME', # orgunitname - # 'identifier': 'urn:oid:CO-IDENTNR-C-oid' # oid:CO-IDENTNR-C-oid - }, - # Inject your remote_app to handler - # Note: keep in mind the string should match - # given name for authentication provider - "acs_handler": StringRepresenter('acs_handler_factory("idp")'), - "auto_confirm": True, -} - - def pick_squarest_logo(logo_dicts: Iterable[dict], default: str = "") -> str: """Pick from logo_dicts a logo whose width/height-ratio is closest to 1.""" pick = default @@ -238,10 +148,9 @@ def parse_into_config(mds: MetadataStore, idp_id: str, langpref: str = "en") -> } -def should_be_parsed(idp_id: str) -> bool: - """Whether `idp_id` should be parsed.""" - # tugraz config isn't parsed from SAML-XML, but rather custom-configured to use our SSO - return "tugraz" not in idp_id +def exclude_item_if_includes(input_dict: dict, *exclusions: str): + """Jinja-filter for excluding items from a dict.""" + return {k: v for k, v in input_dict.items() if all(e not in k for e in exclusions)} @config_tugraz.command() @@ -251,7 +160,17 @@ def should_be_parsed(idp_id: str) -> bool: @click.option( "-u", "--url", help="url to SAML-XML file, mutually exclusive with --file" ) -def echo_saml_config(file: str | None = None, url: str | None = None): +@click.option( + "-t", + "--template-path", + help="path to jinja-template for formatting output", + type=click.Path(exists=True, dir_okay=False, readable=True, path_type=pathlib.Path), +) +def echo_saml_config( + file: str | None = None, + url: str | None = None, + template_path: pathlib.Path | None = None, +): """Parse SAML-XML into `invenio-saml`-compatible config, echo that config to stdout. Prints configuration to stdout, prints some notes to stderr. @@ -261,6 +180,17 @@ def echo_saml_config(file: str | None = None, url: str | None = None): invenio config-tugraz echo-saml-config --url "https://eduid.at/md/aconet-registered.xml" > output invenio config-tugraz echo-saml-config --file "/path/to/eduid.xml" | my-clipboard """ + if template_path is None: + template_path: Traversable = resources.files(__package__).joinpath( + "config_templates", "tugraz", "saml.cfg.jinja" + ) + + jinja_env = Environment(loader=loaders.BaseLoader()) + jinja_env.globals["format_str"] = partial(black.format_str, mode=black.FileMode()) + jinja_env.globals["repr"] = repr + jinja_env.filters["exclude_item_if_includes"] = exclude_item_if_includes + template = jinja_env.from_string(template_path.read_text()) + if file and url: click.secho( "`--file` and `--url` are mutually exclusive", file=sys.stderr, fg="red" @@ -281,36 +211,21 @@ def echo_saml_config(file: str | None = None, url: str | None = None): # parse into dict whose `repr` is copyable to config-file echo_dict = { - "idp": deepcopy(TUGRAZ_IDP_ECHO_DICT), + idp_id: parse_into_config(mds, idp_id) for idp_id in mds.identity_providers() } - echo_dict.update( - { - idp_id: parse_into_config(mds, idp_id) - for idp_id in mds.identity_providers() - if should_be_parsed(idp_id) - } - ) # create to-be-echoed output-string - output = ( - "from invenio_saml.handlers import default_account_setup, acs_handler_factory\n" - "from invenio_config_tugraz.utils import tugraz_account_setup_extension\n" - "\n" - "def tugraz_account_setup(user, account_info):\n" - " default_account_setup(user, account_info)\n" - " tugraz_account_setup_extension(user, account_info)\n" - "\n" - f"SSO_SAML_IDPS = {echo_dict!r}\n" - ) - output = black.format_str(output, mode=black.FileMode()) + output = template.render(echo_dict=echo_dict) click.echo(output) + # output notes to stderr + notes_path = template_path.parent.joinpath("notes.jinja") + notes = "add the stdout-output to your invenio.cfg file" + if notes_path.exists(): + notes_template = jinja_env.from_string(notes_path.read_text()) + notes = notes_template.render() click.secho( - "add the stdout-output to your invenio.cfg file\n" - "\n" - "NOTE: for this to work correctly:\n" - "- the role `tugraz_authenticated_user` must exist\n" - "- `RDM_PERMISSION_POLICY` should be set to `TUGrazRDMRecordPermissionPolicy`", + notes, file=sys.stderr, fg="yellow", ) diff --git a/invenio_config_tugraz/config_templates/tugraz/notes.jinja b/invenio_config_tugraz/config_templates/tugraz/notes.jinja new file mode 100644 index 0000000..34c9bd5 --- /dev/null +++ b/invenio_config_tugraz/config_templates/tugraz/notes.jinja @@ -0,0 +1,12 @@ +{#- -*- coding: utf-8 -*- + + Copyright (C) 2024 Graz University of Technology. + + invenio-config-tugraz is free software; you can redistribute it and/or modify it + under the terms of the MIT License; see LICENSE file for more details. +-#} +add the stdout-output to your invenio.cfg file + +NOTE: for this to work correctly: +- the role `tugraz_authenticated_user` must exist +- `RDM_PERMISSION_POLICY` should be set to `TUGrazRDMRecordPermissionPolicy` diff --git a/invenio_config_tugraz/config_templates/tugraz/saml.cfg.jinja b/invenio_config_tugraz/config_templates/tugraz/saml.cfg.jinja new file mode 100644 index 0000000..5c88ba7 --- /dev/null +++ b/invenio_config_tugraz/config_templates/tugraz/saml.cfg.jinja @@ -0,0 +1,115 @@ +{#- -*- coding: utf-8 -*- + + Copyright (C) 2024 Graz University of Technology. + + invenio-config-tugraz is free software; you can redistribute it and/or modify it + under the terms of the MIT License; see LICENSE file for more details. +-#} + +from invenio_saml.handlers import default_account_setup, acs_handler_factory +from invenio_config_tugraz.utils import tugraz_account_setup_extension + + +def tugraz_account_setup(user, account_info): + """Adds tugraz-authentication in addition to default setup.""" + default_account_setup(user, account_info) + tugraz_account_setup_extension(user, account_info) + + +SSO_SAML_IDPS = { + "idp": { + # Basic info + "title": "TUGRAZ", + "description": "TUGRAZ shibboleth Authentication Service", + "icon": "", + # path to the file i.e. "./saml/sp.crt" + "sp_cert_file": "./saml/idp/cert/sp.crt", + "sp_key_file": "./saml/idp/cert/sp.key", + "settings": { + # If strict is True, then the Python Toolkit will reject unsigned + # or unencrypted messages if it expects them to be signed or encrypted. + # Also it will reject the messages if the SAML standard is not strictly + # followed. Destination, NameId, Conditions ... are validated too. + "strict": True, + # Enable debug mode (outputs errors). + "debug": True, + # Service Provider Data that we are deploying. + "sp": { + # Specifies the constraints on the name identifier to be used to + # represent the requested subject. + # Take a look on https://github.com/onelogin/python-saml/blob/master/src/onelogin/saml2/constants.py + # to see the NameIdFormat that are supported. + "NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", + }, + # Identity Provider Data that we want connected with our SP. + "idp": { + # Identifier of the IdP entity (must be a URI) + "entityId": "https://auth.tugraz.at/auth/realms/tugraz", + # SSO endpoint info of the IdP. (Authentication Request protocol) + "singleSignOnService": { + # URL Target of the IdP where the Authentication Request Message + # will be sent. + "url": "https://auth.tugraz.at/auth/realms/tugraz/protocol/saml", + # SAML protocol binding to be used when returning the + # message. OneLogin Toolkit supports the HTTP-Redirect binding + # only for this endpoint. + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", + }, + # SLO endpoint info of the IdP. + "singleLogoutService": { + # URL Location where the from the IdP will be sent (IdP-initiated logout) + "url": "https://sso.tugraz.at/slo/Logout", + # SAML protocol binding to be used when returning the + # message. OneLogin Toolkit supports the HTTP-Redirect binding + # only for this endpoint. + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", + }, + # Public X.509 certificate of the IdP + "x509cert": "MIICmzCCAYMCBgFu/kDRhjANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZ0dWdyYXowHhcNMTkxMjEzMDc1MzExWhcNMjkxMjEzMDc1NDUxWjARMQ8wDQYDVQQDDAZ0dWdyYXowggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDXnaCOxODAXaobWyU0dBxlDSBC0n6YwvYyR1WCaCG57M2DZuA90aILgejhWn4X71al/+CixU5xRegIv7z843+CWBAcXkGQT/O/bfklzF2CvW2XtVftgCUwNOqnOynXA92Ge3YuJbIBxmK3/9XDiAuQ06+tmdZdTOaFyxfLX4TD+agwDd1v5MyK0B3f7yKZ+DEkXVhawj5gAgG+2XJFnM+3kY6tMmSG8af/GdXqnr3bYn1lAWzcRQgSkjasdMUgHpzp3NY2f48uQqoFuZ3frahNT+dl+hrfDC3Ix9D6ePtLBGRrraWBec/BrlcRr9SuaFq1SLGVSRKmkwE3KyyqLCLlAgMBAAEwDQYJKoZIhvcNAQELBQADggEBANMkjmxhXmiNe+uznV4SEWBrMpKEevOkwqrGnSEtx/QSZZ3G0GVHOSRTo+v6G7CukES2zSV1NHSTRbJSbrDK1UmS66N+x9PWfFMLIn0WN1acef5zp516F9qhVgcztjQPmfexIbpe5FYTuYWvptBWs5m4GgbWeBxtjimKS5dOjG5TskFtVH/MEcJk3LRqy8fIksg3Z5eREXQWzbjpvtz/9L/7n4+DzZprVr6VoBjsTn/AJ1d5Q0U9elKOM0o5G3pJWPhT6/gwVpkpqnH6AuGhcXZpxpAS+PGNJghhiJT8odFmoBur24ubYZVPVPDc10/1LKFIJT1vkB8bem/PrhHZDjw=", + }, + # Security settings + # more on https://github.com/onelogin/python-saml + "security": { + "authnRequestsSigned": False, + "failOnAuthnContextMismatch": False, + "logoutRequestSigned": False, + "logoutResponseSigned": False, + "metadataCacheDuration": None, + "metadataValidUntil": None, + "nameIdEncrypted": False, + "requestedAuthnContext": False, + "requestedAuthnContextComparison": "exact", + "signMetadata": False, + "signatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + "wantAssertionsEncrypted": False, + "wantAssertionsSigned": False, + "wantAttributeStatement": False, + "wantMessagesSigned": False, + "wantNameId": True, + "wantNameIdEncrypted": False, + "digestAlgorithm": "http://www.w3.org/2001/04/xmlenc#sha256", + }, + }, + # Account Mapping + # replace `origin_field` to IDP Attributes + "mappings": { + "email": "urn:oid:0.9.2342.19200300.100.1.3", + "name": "urn:oid:2.5.4.42", + "surname": "urn:oid:2.5.4.4", + "external_id": "urn:oid:1.3.6.1.4.1.5923.1.1.1.6", + # Custom - not used + # 'org_id': 'urn:oid:CO-ORGUNITID', # orgunitid + # 'org_name': 'urn:oid:CO-ORGUNITNAME', # orgunitname + # 'identifier': 'urn:oid:CO-IDENTNR-C-oid' # oid:CO-IDENTNR-C-oid + }, + # Inject your remote_app to handler + # Note: keep in mind the string should match + # given name for authentication provider + "acs_handler": acs_handler_factory("idp", account_setup=tugraz_account_setup), + "auto_confirm": True, + }, + {#- don't output tugraz's dict entry as that is custom-configured above #} + {%- set echo_dict = echo_dict|exclude_item_if_includes("tugraz") %} + {#- ...[1:-1] removes initial `{` and trailing `}` from formatted dict #} + {{- format_str(repr(echo_dict)).strip()[1:-1] }} +}