Skip to content

Commit

Permalink
cli: make saml-config output configurable
Browse files Browse the repository at this point in the history
  • Loading branch information
martinobersteiner committed Jul 22, 2024
1 parent 9e6d45e commit d06b67a
Show file tree
Hide file tree
Showing 3 changed files with 166 additions and 124 deletions.
163 changes: 39 additions & 124 deletions invenio_config_tugraz/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 <Response>
# 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 <LogoutRequest> 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 <Response>
# 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
Expand Down Expand Up @@ -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()
Expand All @@ -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.
Expand All @@ -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"
Expand All @@ -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",
)
12 changes: 12 additions & 0 deletions invenio_config_tugraz/config_templates/tugraz/notes.jinja
Original file line number Diff line number Diff line change
@@ -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`
115 changes: 115 additions & 0 deletions invenio_config_tugraz/config_templates/tugraz/saml.cfg.jinja
Original file line number Diff line number Diff line change
@@ -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 <Response>
# 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 <LogoutRequest> 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 <Response>
# 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] }}
}

0 comments on commit d06b67a

Please sign in to comment.