diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..d95772fa --- /dev/null +++ b/.coveragerc @@ -0,0 +1,7 @@ +[run] +branch = true +omit = */tests/*,*/wsgi.py, fabfile.py, /usr/local/*, ./setup.py +source = . + +[report] +show_missing = true diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 427b8515..55315df5 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -48,7 +48,7 @@ jobs: if [ -f requirements-customizations.txt ]; then pip install -r requirements-customizations.txt; fi python -m pip install -U setuptools python -m pip install -e . - python -m pip install "Pillow>=10.0.0,<10.1" "device_detector>=5.0,<6" "satosa>=8.4,<8.6" "jinja2>=3.0,<4" "pymongo>=4.4.1,<4.5" + python -m pip install "Pillow>=10.0.0,<10.1" "device_detector>=5.0,<6" "satosa>=8.4,<8.6" "jinja2>=3.0,<4" "pymongo>=4.4.1,<4.5" aiohttp python -m pip install git+https://github.com/danielfett/sd-jwt.git - name: Lint with flake8 @@ -59,7 +59,27 @@ jobs: flake8 pyeudiw --count --exit-zero --statistics --max-line-length 160 - name: Tests run: | - pytest --cov=pyeudiw -v --cov-report term --cov-fail-under=80 pyeudiw/tests/ + pytest --cov=pyeudiw --cov-fail-under=80 + coverage report -m --skip-covered - name: Bandit Security Scan run: | bandit -r -x pyeudiw/tests* pyeudiw/* + - name: Lint with html linter + run: | + echo -e '\nHTML:' + readarray -d '' array < <(find $SRC example -name "*.html" -print0) + echo "Running linter on (${#array[@]}): " + printf '\t- %s\n' "${array[@]}" + echo "Linter output:" + + for file in "${array[@]}" + do + echo -e "\n$file:" + html_lint.py "$file" | awk -v path="file://$PWD/$file:" '$0=path$0' | sed -e 's/: /:\n\t/'; + done + + for file in "${array[@]}" + do + errors=$(html_lint.py "$file" | grep -c 'Error') + if [ "$errors" -gt 0 ]; then exit 1; fi; + done diff --git a/.gitignore b/.gitignore index b1ccd476..36f4d3f4 100644 --- a/.gitignore +++ b/.gitignore @@ -161,6 +161,7 @@ cython_debug/ .idea/ env +**/.DS_Store */wordpress-plugin */wordpress-theme diff --git a/README.md b/README.md index d97225d3..f8d0b88d 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,8 @@ The toolchain contains the following components: | __jwt__ | Signed and encrypted JSON Web Token (JWT) according to [RFC7519](https://datatracker.ietf.org/doc/html/rfc7519), [RFC7515](https://datatracker.ietf.org/doc/html/rfc7515) and [RFC7516](https://datatracker.ietf.org/doc/html/rfc7516) | | __tools.qrcode__ | QRCodes creation | | __oauth2.dpop__ | Tools for issuing and parsing DPoP artifacts, according to [OAuth 2.0 Demonstrating Proof-of-Possession at the Application Layer (DPoP)](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop) | -| __openid4vp.federation__ | OpenID Connect Federation Wallet Relying Party Entities and Trust Mechanisms | -| __satosa.openid4vp.backend__ | SATOSA OpenID4VP Relying Party backend | +| __openid4vp.federation__ | Trust evaluation mechanisms, according to [OpenID Connect Federation 1.0](https://openid.net/specs/openid-connect-federation-1_0.html) | +| __satosa.openid4vp.backend__ | SATOSA Relying Party backend, according to [OpenID for Verifiable Presentations](https://openid.bitbucket.io/connect/openid-4-verifiable-presentations-1_0.html) | ## Setup diff --git a/example/README.md b/example/README.md index 790f8209..1e05a318 100644 --- a/example/README.md +++ b/example/README.md @@ -43,4 +43,28 @@ After following these steps, your WordPress instance should be up and running wi 2. Under [plugins](http://localhost:8080/wp-admin/plugins.php), activate the plugin OneLogin SAML SSO. 3. Configure the plugin OneLogin SAML SSO in the [settings tab](http://localhost:8080/wp-admin/options-general.php?page=onelogin_saml_configuration). -To configure a generic SAML connection, you will need to enter appropriate values in OneLogin SAML SSO plugin settings. These include Identity Provider URL, Assertion Consumer Service URL, Single Logout Service URL, and other parameters specific to your SAML configuration. +To configure your test environment with the IAM Proxy instance, you'll need to undertake a configuration phase on the OneLogin plugin settings page. The required proxy service configuration metadata is obtainable from https://demo-it-wallet.westeurope.cloudapp.azure.com/Saml2IDP/metadata. + +Specifically, you should modify the following fields: + +- **IdP Entity Id**: Enter the entityID of the IAMProxy, which can be located within the metadata. +- **Single Sign-On Service Url**: Input the Location of the SingleSignOnService you desire to connect with, as specified in the metadata file. +- **X.509 Certificate**: Include the X.509 Certificate associated with the IAMProxy, found within the metadata. +- **Create user if not exists**: Set this to `true`. +- **Update user data**: Set this to `true`. +- **Attribute Mapping - Username**: Set this to `fiscalNumber`. +- **Attribute Mapping - E-mail**: Set this to `urn:oid:1.2.840.113549.1.9.1.1`. +- **Attribute Mapping - First Name**: Set this to `Name`. +- **Attribute Mapping - Last Name**: Set this to `familyName`. +- **Service Provider Entity Id**: Enter the URL of your SP metadata as the entityID. For example: http://\/wp-login.php?saml_metadata +- **Encrypt nameID**: Set this to `true`. +- **Sign AuthnRequest**: Set this to `true`. +- **Reject Unsigned Assertions**: Set this to `true`. +- **NameIDFormat**: Set this to `urn:oasis:names:tc:SAML:2.0:attrname-format:uri`. +- **requestedAuthnContext**: Set this to `urn:oasis:names:tc:SAML:2.0:ac:classes:X509`. +- **Service Provider X.509 Certificate**: Insert your SP's X.509 certificate here. +- **Service Provider Private Key**: Input the private key of your SP. +- **Signature Algorithm**: Set this to `rsa-sha256`. +- **Digest Algorithm**: Set this to `sha256`. + +After you've filled all the fields, save your settings and download the SP metadata for configuration on the IAM Proxy. \ No newline at end of file diff --git a/example/docker-compose.yml b/example/docker-compose.yml index dace46b3..5d7918f2 100644 --- a/example/docker-compose.yml +++ b/example/docker-compose.yml @@ -49,6 +49,10 @@ services: volumes: - ./wordpress-plugin/onelogin-saml-sso/:/var/www/html/wp-content/plugins/onelogin-saml-sso/ - ./wordpress-theme/italiawp2/:/var/www/html/wp-content/themes/italiawp2/ + - ./onelogin_custom_settings/functions.php:/var/www/html/wp-content/plugins/onelogin-saml-sso/php/functions.php + - ./onelogin_custom_settings/configuration.php:/var/www/html/wp-content/plugins/onelogin-saml-sso/php/configuration.php + - ./onelogin_custom_settings/settings.php:/var/www/html/wp-content/plugins/onelogin-saml-sso/php/settings.php + - ./italiaWP2_custom_settings/header.php:/var/www/html/wp-content/themes/italiawp2/header.php networks: - wordpress-network diff --git a/example/italiaWP2_custom_settings/header.php b/example/italiaWP2_custom_settings/header.php new file mode 100644 index 00000000..98bba0da --- /dev/null +++ b/example/italiaWP2_custom_settings/header.php @@ -0,0 +1,60 @@ + +> + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+ +
+ + ID, 'spidRole', true); + ?> +
+

Benvenuto first_name.' '.$user->last_name; ?>

+

Di seguito potrai trovare le tue informazioni principali:

+
    +
  • Nome: first_name; ?>
  • +
  • Cognome: last_name; ?>
  • +
  • Codice fiscale: nickname; ?>
  • +
  • Email: user_email; ?>
  • +
  • Spid level:
  • +
+
+ + + diff --git a/example/onelogin_custom_settings/configuration.php b/example/onelogin_custom_settings/configuration.php new file mode 100755 index 00000000..4dde00c4 --- /dev/null +++ b/example/onelogin_custom_settings/configuration.php @@ -0,0 +1,964 @@ + +
+
+ +
+
+
+ +
+
+

+
+ + + + +

+ +

+ +
+
+ ' . __('This plugin provides single sign-on via SAML and gives users one-click access to their WordPress accounts from identity providers like OneLogin', 'onelogin-saml-sso') . '

' . + '

' . __('For more information', 'onelogin-saml-sso') . ' '.__("access to the", 'onelogin-saml-sso').' '.__("Plugin Info", 'onelogin-saml-sso').' ' . + __("or visit", 'onelogin-saml-sso') . ' OneLogin, Inc.' . '

'; + + $current_screen = convert_to_screen($current_screen); + WP_Screen::add_old_compat_help($current_screen, $helpText); + + $option_group = 'onelogin_saml_configuration'; + + $sections = get_sections(); + foreach ($sections as $name => $description) { + add_settings_section($name, $description, 'plugin_section_'.$name.'_text', $option_group); + } + + $fields = get_onelogin_saml_settings(); + + $special_fields = array( + 'onelogin_saml_role_mapping_multivalued_in_one_attribute_value', + 'onelogin_saml_role_mapping_multivalued_pattern' + ); + + foreach ($fields as $section => $settings) { + foreach ($settings as $name => $data) { + $description = $data[0]; + $type = $data[1]; + register_setting($option_group, $name); + if ($section == 'role_mapping' && !in_array($name, $special_fields)) { + $role_value = str_replace('onelogin_saml_role_mapping_', '', $name); + add_settings_field($name, $description, "plugin_setting_".$type."_onelogin_saml_role_mapping", $option_group, 'role_mapping', $role_value); + } else if ($section == 'role_precedence') { + $role_value = str_replace('onelogin_saml_role_order_', '', $name); + add_settings_field($name, $description, "plugin_setting_".$type."_onelogin_saml_role_order", $option_group, 'role_precedence', $role_value); + } else { + add_settings_field($name, $description, "plugin_setting_".$type."_$name", $option_group, $section); + } + } + } +} + +function plugin_setting_boolean_onelogin_saml_enabled($network = false) { + $value = $network ? get_site_option('onelogin_saml_enabled') : get_option('onelogin_saml_enabled'); + echo ''. + '

'.__("Check it in order to enable the SAML plugin.", 'onelogin-saml-sso').'

'; +} + +function plugin_setting_string_onelogin_saml_idp_entityid($network = false) { + echo ''. + '

'.__('Identifier of the IdP entity. ("Issuer URL")', 'onelogin-saml-sso').'

'; +} + +function plugin_setting_string_onelogin_saml_idp_sso($network = false) { + echo ''. + '

'.__('SSO endpoint info of the IdP. URL target of the IdP where the SP will send the Authentication Request. ("SAML 2.0 Endpoint (HTTP)")', 'onelogin-saml-sso').'

'; +} + +function plugin_setting_string_onelogin_saml_idp_slo($network = false) { + echo ''. + '

'.__('SLO endpoint info of the IdP. URL target of the IdP where the SP will send the SLO Request. ("SLO Endpoint (HTTP)")', 'onelogin-saml-sso').'

'; +} + +function plugin_setting_textarea_onelogin_saml_idp_x509cert($network = false) { + echo ''; + echo '

'.__('Public x509 certificate of the IdP. ("X.509 certificate")', 'onelogin-saml-sso'); +} + +function plugin_setting_boolean_onelogin_saml_advanced_idp_lowercase_url_encoding($network = false) { + $value = $network ? get_site_option('onelogin_saml_advanced_idp_lowercase_url_encoding') : get_option('onelogin_saml_advanced_idp_lowercase_url_encoding'); + + echo ''. + '

'.__('Some IdPs like ADFS can use lowercase URL encoding, but the plugin expects uppercase URL encoding, enable it to fix incompatibility issues.', 'onelogin-saml-sso').'

'; +} + +function plugin_setting_boolean_onelogin_saml_autocreate($network = false) { + $value = $network ? get_site_option('onelogin_saml_autocreate') : get_option('onelogin_saml_autocreate'); + echo ''. + '

'.__('Auto-provisioning. If user not exists, WordPress will create a new user with the data provided by the IdP.
Review the Mapping section.', 'onelogin-saml-sso').'

'; +} + +function plugin_setting_boolean_onelogin_saml_updateuser($network = false) { + $value = $network ? get_site_option('onelogin_saml_updateuser') : get_option('onelogin_saml_updateuser'); + echo ''. + '

'.__('Auto-update. WordPress will update the account of the user with the data provided by the IdP.
Review the Mapping section.', 'onelogin-saml-sso').'

'; +} + +function plugin_setting_boolean_onelogin_saml_forcelogin($network = false) { + $value = $network ? get_site_option('onelogin_saml_forcelogin') : get_option('onelogin_saml_forcelogin'); + echo ''. + '

'.__('Protect WordPress and force the user to authenticate at the IdP in order to access when any WordPress page is loaded and no active session.', 'onelogin-saml-sso').'

'; +} + +function plugin_setting_boolean_onelogin_saml_slo($network = false) { + $value = $network ? get_site_option('onelogin_saml_slo') : get_option('onelogin_saml_slo'); + echo ''. + '

'.__('Enable/disable Single Log Out. SLO is a complex functionality, the most common SLO implementation is based on front-channel (redirections), sometimes if the SLO workflow fails a user can be blocked in an unhandled view. If the admin does not control the set of apps involved in the SLO process, you may want to disable this functionality to avoid more problems than benefits.', 'onelogin-saml-sso').'

'; +} + +function plugin_setting_boolean_onelogin_saml_keep_local_login($network = false) { + $value = $network ? get_site_option('onelogin_saml_keep_local_login') : get_option('onelogin_saml_keep_local_login'); + echo ''. + '

'.__('Enable/disable the normal login form. If disabled, instead of the WordPress login form, WordPress will excecute the SP-initiated SSO flow. If enabled the normal login form is displayed and a link to initiate that flow is displayed.', 'onelogin-saml-sso').'

'; +} + +function plugin_setting_select_onelogin_saml_account_matcher($network = false) { + $value = $network ? get_site_option('onelogin_saml_account_matcher') : get_option('onelogin_saml_account_matcher'); + echo ''. + '

'.__('Select what field will be used in order to find the user account. If "email", the plugin will prevent the user from changing their email address in their user profile.', 'onelogin-saml-sso').'

'; +} + +function plugin_setting_boolean_onelogin_saml_trigger_login_hook($network = false) { + $value = $network ? get_site_option('onelogin_saml_trigger_login_hook') : get_option('onelogin_saml_trigger_login_hook'); + echo ''. + '

'.__('When enabled, the wp_login hook will be triggered.', 'onelogin-saml-sso').'

'; +} + +function plugin_setting_boolean_onelogin_saml_multirole($network = false) { + $value = $network ? get_site_option('onelogin_saml_multirole') : get_option('onelogin_saml_multirole'); + echo ''. + '

'.__('Enable/disable the support of multiple roles. Not available in multi-site wordpress', 'onelogin-saml-sso').'

'; +} + +function plugin_setting_boolean_onelogin_saml_alternative_acs($network = false) { + $value = $network ? get_site_option('onelogin_saml_alternative_acs') : get_option('onelogin_saml_alternative_acs'); + echo ''. + '

'.__('Enable if you want to use a different Assertion Consumer Endpoint than /wp-login.php?saml_acs (Required if using WPEngine or any similar hosting service that prevents POST on wp-login.php). You must update the IdP with the new value after enabling/disabling this setting.', 'onelogin-saml-sso').'

'; +} + +function plugin_setting_textarea_onelogin_saml_trusted_url_domains($network = false) { + echo ''; + echo '

'.__("List here any domain (comma- separated) that you want to be trusted in the RelayState parameter, otherwise the parameter will be ignored. You don't need to include the domain of the wordpress instance", 'onelogin-saml-sso'); +} + +function plugin_setting_string_onelogin_saml_attr_mapping_username($network = false) { + $value = $network ? get_site_option('onelogin_saml_attr_mapping_username') : get_option('onelogin_saml_attr_mapping_username'); + echo ''; +} + +function plugin_setting_string_onelogin_saml_attr_mapping_mail($network = false) { + $value = $network ? get_site_option('onelogin_saml_attr_mapping_mail') : get_option('onelogin_saml_attr_mapping_mail'); + echo ''; +} + +function plugin_setting_string_onelogin_saml_attr_mapping_firstname($network = false) { + $value = $network ? get_site_option('onelogin_saml_attr_mapping_firstname') : get_option('onelogin_saml_attr_mapping_firstname'); + echo ''; +} + +function plugin_setting_string_onelogin_saml_attr_mapping_lastname($network = false) { + $value = $network ? get_site_option('onelogin_saml_attr_mapping_lastname') : get_option('onelogin_saml_attr_mapping_lastname'); + echo ''; +} + +function plugin_setting_string_onelogin_saml_attr_mapping_nickname($network = false) { + $value = $network ? get_site_option('onelogin_saml_attr_mapping_nickname') : get_option('onelogin_saml_attr_mapping_nickname'); + echo ''. + '

'.__("If not provided, default value is the user's username."); +} + +function plugin_setting_string_onelogin_saml_attr_mapping_rememberme($network = false) { + $value = $network ? get_site_option('onelogin_saml_attr_mapping_rememberme') : get_option('onelogin_saml_attr_mapping_rememberme'); + echo ''; +} + +function plugin_setting_string_onelogin_saml_attr_mapping_role($network = false) { + $value = $network ? get_site_option('onelogin_saml_attr_mapping_role') : get_option('onelogin_saml_attr_mapping_role'); + echo ''. + '

'.__("The attribute that contains the role of the user, For example 'memberOf'. If WordPress can't figure what role assign to the user, it will assign the default role defined at the general settings.", 'onelogin-saml-sso').'

'; +} + +function plugin_setting_string_onelogin_saml_role_mapping($role_value, $network = false) { + $value = $network ? get_site_option('onelogin_saml_role_mapping_'.$role_value) : get_option('onelogin_saml_role_mapping_'.$role_value); + if ($network) { + $value = get_site_option('onelogin_saml_role_mapping_'.$role_value); + } else { + $value = get_option('onelogin_saml_role_mapping_'.$role_value); + } + echo ''; +} + +function plugin_setting_string_onelogin_saml_role_order($role_value, $network = false) { + $value = $network ? get_site_option('onelogin_saml_role_order_'.$role_value) : get_option('onelogin_saml_role_order_'.$role_value); + echo ''; +} + +function plugin_setting_boolean_onelogin_saml_role_mapping_multivalued_in_one_attribute_value($network = false) { + $value = $network ? get_site_option('onelogin_saml_role_mapping_multivalued_in_one_attribute_value') : get_option('onelogin_saml_role_mapping_multivalued_in_one_attribute_value'); + echo ' +

'.__("Sometimes role values are provided in an unique attribute statement (instead multiple attribute statements). If that is the case, activate this and the plugin will try to split those values by ;
Use a regular expression pattern in order to extract complex data.", 'onelogin-saml-sso').'

'; +} + +function plugin_setting_string_onelogin_saml_role_mapping_multivalued_pattern($network = false) { + $value = $network ? get_site_option('onelogin_saml_role_mapping_multivalued_pattern') : get_option('onelogin_saml_role_mapping_multivalued_pattern'); + echo ' +

'.__("Regular expression that extract roles from complex multivalued data (required to active the previous option).
E.g. If the SAMLResponse has a role attribute like: CN=admin;CN=superuser;CN=europe-admin; , use the regular expression /CN=([A-Z0-9\s _-]*);/i to retrieve the values. Or use /CN=([^,;]*)/", 'onelogin-saml-sso').'

'; +} + +function plugin_setting_boolean_onelogin_saml_customize_action_prevent_local_login($network = false) { + $value = $network ? get_site_option('onelogin_saml_customize_action_prevent_local_login') : get_option('onelogin_saml_customize_action_prevent_local_login'); + echo ' +

'.__("Check to disable the ?normal option and offer the local login when it is not enabled.", 'onelogin-saml-sso').'

'; +} + +function plugin_setting_boolean_onelogin_saml_customize_action_prevent_reset_password($network = false) { + $value = $network ? get_site_option('onelogin_saml_customize_action_prevent_reset_password') : get_option('onelogin_saml_customize_action_prevent_reset_password'); + echo ' +

'.__("Check to disable resetting passwords in WordPress.", 'onelogin-saml-sso').'

'; +} + +function plugin_setting_boolean_onelogin_saml_customize_action_prevent_change_password($network = false) { + $value = $network ? get_site_option('onelogin_saml_customize_action_prevent_change_password') : get_option('onelogin_saml_customize_action_prevent_change_password'); + echo ' +

'.__("Check to disable changing passwords in WordPress.", 'onelogin-saml-sso').'

'; +} + +function plugin_setting_boolean_onelogin_saml_customize_action_prevent_change_mail($network = false) { + $value = $network ? get_site_option('onelogin_saml_customize_action_prevent_change_mail') : get_option('onelogin_saml_customize_action_prevent_change_mail'); + echo ' +

'.__("Check to disable changing the email addresses in WordPress (recommended if you are using email to match accounts).", 'onelogin-saml-sso').'

'; +} + +function plugin_setting_boolean_onelogin_saml_customize_stay_in_wordpress_after_slo($network = false) { + $value = $network ? get_site_option('onelogin_saml_customize_stay_in_wordpress_after_slo') : get_option('onelogin_saml_customize_stay_in_wordpress_after_slo'); + echo ' +

'.__("If SLO and Force SAML login are enabled, after the SLO process you will be redirected to the WordPress main page and a SAML SSO process will start. Check this to prevent that and stay at the WordPress login form. ", 'onelogin-saml-sso').'

'; +} + +function plugin_setting_string_onelogin_saml_customize_links_user_registration($network = false) { + $value = $network ? get_site_option('onelogin_saml_customize_links_user_registration') : get_option('onelogin_saml_customize_links_user_registration'); + echo ' +

'.__("Override the user registration link. ", 'onelogin-saml-sso').'

'; +} + +function plugin_setting_string_onelogin_saml_customize_links_lost_password($network = false) { + $value = $network ? get_site_option('onelogin_saml_customize_links_lost_password') : get_option('onelogin_saml_customize_links_lost_password'); + echo ' +

'.__("Override the lost password link. (Prevent reset password must be deactivated or the SAML SSO will be used.)", 'onelogin-saml-sso').'

'; +} + +function plugin_setting_string_onelogin_saml_customize_links_saml_login($network = false) { + $value = $network ? get_site_option('onelogin_saml_customize_links_saml_login') : get_option('onelogin_saml_customize_links_saml_login'); + echo ' +

'.__("If 'Keep Local login' enabled, this will be showed as message at the SAML link.", 'onelogin-saml-sso').'

'; +} + +function plugin_setting_boolean_onelogin_saml_advanced_settings_debug($network = false) { + $value = $network ? get_site_option('onelogin_saml_advanced_settings_debug') : get_option('onelogin_saml_advanced_settings_debug'); + echo ''. + '

'.__('Enable for debugging the SAML workflow. Errors and Warnings will be shown.', 'onelogin-saml-sso').'

'; +} + +function plugin_setting_boolean_onelogin_saml_advanced_settings_strict_mode($network = false) { + $value = $network ? get_site_option('onelogin_saml_advanced_settings_strict_mode') : get_option('onelogin_saml_advanced_settings_strict_mode'); + echo ''. + '

'.__("If Strict Mode is enabled, WordPress will reject unsigned or unencrypted messages if it expects them signed or encrypted. + It will also reject messages if not strictly following the SAML standard: Destination, NameId, Conditions ... are also validated.", 'onelogin-saml-sso').'

'; +} + +function plugin_setting_string_onelogin_saml_advanced_settings_sp_entity_id($network = false) { + $value = $network ? get_site_option('onelogin_saml_advanced_settings_sp_entity_id') : get_option('onelogin_saml_advanced_settings_sp_entity_id'); + echo ''. + '

'.__("Set the Entity ID for the Service Provider. If not provided, 'php-saml' will be used.", 'onelogin-saml-sso').'

'; +} + + +function plugin_setting_boolean_onelogin_saml_advanced_settings_nameid_encrypted($network = false) { + $value = $network ? get_site_option('onelogin_saml_advanced_settings_nameid_encrypted') : get_option('onelogin_saml_advanced_settings_nameid_encrypted'); + echo ''. + '

'.__('The nameID sent by this SP will be encrypted.', 'onelogin-saml-sso').'

'; +} + +function plugin_setting_boolean_onelogin_saml_advanced_settings_authn_request_signed($network = false) { + $value = $network ? get_site_option('onelogin_saml_advanced_settings_authn_request_signed') : get_option('onelogin_saml_advanced_settings_authn_request_signed'); + echo ''. + '

'.__('The samlp:AuthnRequest messages sent by this SP will be signed.', 'onelogin-saml-sso').'

'; +} + +function plugin_setting_boolean_onelogin_saml_advanced_settings_logout_request_signed($network = false) { + $value = $network ? get_site_option('onelogin_saml_advanced_settings_logout_request_signed') : get_option('onelogin_saml_advanced_settings_logout_request_signed'); + echo ''. + '

'.__('The samlp:logoutRequest messages sent by this SP will be signed.', 'onelogin-saml-sso').'

'; +} + +function plugin_setting_boolean_onelogin_saml_advanced_settings_logout_response_signed($network = false) { + $value = $network ? get_site_option('onelogin_saml_advanced_settings_logout_response_signed') : get_option('onelogin_saml_advanced_settings_logout_response_signed'); + echo ''. + '

'.__('The samlp:logoutResponse messages sent by this SP will be signed.', 'onelogin-saml-sso').'

'; +} + +function plugin_setting_boolean_onelogin_saml_advanced_settings_want_message_signed($network = false) { + $value = $network ? get_site_option('onelogin_saml_advanced_settings_want_message_signed') : get_option('onelogin_saml_advanced_settings_want_message_signed'); + echo ''. + '

'.__('Reject unsigned samlp:Response, samlp:LogoutRequest and samlp:LogoutResponse received', 'onelogin-saml-sso').'

'; +} + +function plugin_setting_boolean_onelogin_saml_advanced_settings_want_assertion_signed($network = false) { + $value = $network ? get_site_option('onelogin_saml_advanced_settings_want_assertion_signed') : get_option('onelogin_saml_advanced_settings_want_assertion_signed'); + echo ''. + '

'.__('Reject unsigned saml:Assertion received', 'onelogin-saml-sso').'

'; +} + +function plugin_setting_boolean_onelogin_saml_advanced_settings_want_assertion_encrypted($network = false) { + $value = $network ? get_site_option('onelogin_saml_advanced_settings_want_assertion_encrypted') : get_option('onelogin_saml_advanced_settings_want_assertion_encrypted'); + echo ''. + '

'.__('Reject unencrypted saml:Assertion received', 'onelogin-saml-sso').'

'; +} + +function plugin_setting_textarea_onelogin_saml_advanced_settings_sp_x509cert($network = false) { + $value = $network ? get_site_option('onelogin_saml_advanced_settings_sp_x509cert') : get_option('onelogin_saml_advanced_settings_sp_x509cert'); + echo ''; + echo '

'.__('Public x509 certificate of the SP. Leave this field empty if you are providing the cert by the sp.crt.', 'onelogin-saml-sso'); +} + +function plugin_setting_textarea_onelogin_saml_advanced_settings_sp_privatekey($network = false) { + $value = $network ? get_site_option('onelogin_saml_advanced_settings_sp_privatekey') : get_option('onelogin_saml_advanced_settings_sp_privatekey'); + echo ''; + echo '

'.__('Private Key of the SP. Leave this field empty if you are providing the private key by the sp.key.', 'onelogin-saml-sso'); +} + +function plugin_setting_boolean_onelogin_saml_advanced_settings_retrieve_parameters_from_server($network = false) { + $value = $network ? get_site_option('onelogin_saml_advanced_settings_retrieve_parameters_from_server') : get_option('onelogin_saml_advanced_settings_retrieve_parameters_from_server'); + echo ''. + '

'.__('Sometimes when the app is behind a firewall or proxy, the query parameters can be modified an this affects the signature validation process on HTTP-Redirectbinding. Active this if you are seeing signature validation failures. The plugin will try to extract the original query parameters.', 'onelogin-saml-sso').'

'; +} + +function plugin_setting_select_onelogin_saml_advanced_nameidformat($network = false) { + $nameidformat_value = $network ? get_site_option('onelogin_saml_advanced_nameidformat') : get_option('onelogin_saml_advanced_nameidformat'); + $posible_nameidformat_values = array( + 'unspecified' => Constants::NAMEID_UNSPECIFIED, + 'emailAddress' => Constants::NAMEID_EMAIL_ADDRESS, + 'transient' => Constants::NAMEID_TRANSIENT, + 'persistent' => Constants::NAMEID_PERSISTENT, + 'entity' => Constants::NAMEID_ENTITY, + 'encrypted' => Constants::NAMEID_ENCRYPTED, + 'kerberos' => Constants::NAMEID_KERBEROS, + 'x509subjecname' => Constants::NAMEID_X509_SUBJECT_NAME, + 'windowsdomainqualifiedname' => Constants::NAMEID_WINDOWS_DOMAIN_QUALIFIED_NAME, + 'uri' => 'urn:oasis:names:tc:SAML:2.0:attrname-format:uri' #ITWallet: added uri option non nameidformat droplist + ); + + echo ''. + '

'.__("Specifies constraints on the name identifier to be used to represent the requested subject.", 'onelogin-saml-sso').'

'; +} + +function plugin_setting_select_onelogin_saml_advanced_requestedauthncontext($network = false) { + if ($network) { + $requestedauthncontext_values = get_site_option('onelogin_saml_advanced_requestedauthncontext', array()); + } else { + $requestedauthncontext_values = get_option('onelogin_saml_advanced_requestedauthncontext', array()); + } + + if (!is_array($requestedauthncontext_values)) { + $requestedauthncontext_values = array($requestedauthncontext_values); + } + + $posible_requestedauthncontext_values = array( + 'unspecified' => Constants::AC_UNSPECIFIED, + 'password' => Constants::AC_PASSWORD, + 'passwordprotectedtransport' => Constants::AC_PASSWORD_PROTECTED, + 'x509' => Constants::AC_X509, + 'smartcard' => Constants::AC_SMARTCARD, + 'kerberos' => Constants::AC_KERBEROS, + ); + + echo ''. + '

'.__("AuthContext sent in the AuthNRequest. You can select none, one or multiple values", 'onelogin-saml-sso').'

'; + +} + +function plugin_setting_select_onelogin_saml_advanced_signaturealgorithm($network = false) { + $signaturealgorithm_value = $network ? get_site_option('onelogin_saml_advanced_signaturealgorithm') : get_option('onelogin_saml_advanced_signaturealgorithm'); + $posible_signaturealgorithm_values = array( + XMLSecurityKey::RSA_SHA1 => XMLSecurityKey::RSA_SHA1, + XMLSecurityKey::DSA_SHA1 => XMLSecurityKey::DSA_SHA1, + XMLSecurityKey::RSA_SHA256 => XMLSecurityKey::RSA_SHA256, + XMLSecurityKey::RSA_SHA384 => XMLSecurityKey::RSA_SHA384, + XMLSecurityKey::RSA_SHA512 => XMLSecurityKey::RSA_SHA512 + ); + + echo ''. + '

'.__("Algorithm that will be used on signing process").'

'; +} + +function plugin_setting_select_onelogin_saml_advanced_digestalgorithm($network = false) { + $digestalgorithm_value = $network ? get_site_option('onelogin_saml_advanced_digestalgorithm') : get_option('onelogin_saml_advanced_digestalgorithm'); + $posible_digestalgorithm_values = array( + XMLSecurityDSig::SHA1 => XMLSecurityDSig::SHA1, + XMLSecurityDSig::SHA256 => XMLSecurityDSig::SHA256, + XMLSecurityDSig::SHA384 => XMLSecurityDSig::SHA384, + XMLSecurityDSig::SHA512 => XMLSecurityDSig::SHA512 + ); + + echo ''. + '

'.__("Algorithm that will be used on digest process").'

'; +} + +function plugin_section_status_text() { + echo "

".__("Use this flag for enable or disable the SAML support.", 'onelogin-saml-sso')."

"; +} + +function plugin_section_idp_text() { + echo "

".__("Set information relating to the IdP that will be connected with our WordPress. You can find these values at the Onelogin's platform inside WordPress on the Single Sign-On tab.", 'onelogin-saml-sso')."

"; +} + +function plugin_section_options_text() { + echo "

".__("This section customizes the behavior of the plugin.", 'onelogin-saml-sso')."

"; +} + +function plugin_section_attr_mapping_text() { + echo "

".__("Sometimes the names of the attributes sent by the IdP do not match the names used by WordPress for the user accounts. In this section you can set the mapping between IdP fields and WordPress fields. Note: This mapping could be also set at Onelogin's IdP.", 'onelogin-saml-sso')."

"; +} + +function plugin_section_role_mapping_text() { + echo "

".__("The IdP can use its own roles. In this section, you can set the mapping between IdP and WordPress roles. Accepts comma separated values. Example: admin,owner,superuser", 'onelogin-saml-sso')."

"; +} + +function plugin_section_role_precedence_text() { + echo "

".__("In some cases, the IdP returns more than one role. In this secion, you can set the precedence of the different roles which makes sense if multi-role support is not enabled. The smallest integer will be the role chosen.", 'onelogin-saml-sso')."

"; +} + +function plugin_section_customize_links_text() { + echo "

".__("When SAML SSO is enabled to be integrated with an IdP, some WordPress actions and links could be changed. In this section, you will be able to enable or disable the ability for users to change their email address, password and reset their password. You can also override the user registration and the lost password links.", 'onelogin-saml-sso')."

"; +} + +function plugin_section_advanced_settings_text() { + echo "

".__("Handle some other parameters related to customizations and security issues.
If signing/encryption is enabled, then x509 cert and private key for the SP must be provided. There are 2 ways:
+ 1. Store them as files named sp.key and sp.crt on the 'certs' folder of the plugin. (Make sure that the /cert folder is read-protected and not exposed to internet.)
+ 2. Store them at the database, filling the corresponding textareas.", 'onelogin-saml-sso')."

"; +} + +function plugin_section_text() {} + +function onelogin_saml_configuration_multisite() { + add_menu_page(__("Network SAML Settings"), __("Network SAML Settings"), 'manage_options', 'network_saml_settings', 'load_saml_network_config_page'); + add_submenu_page('network_saml_settings', __("Network Global Settings"), __("Network Global Settings"), 'manage_options','network_saml_global_settings', 'load_saml_network_global_config_page'); + add_submenu_page('network_saml_settings', __("Inject SAML Settings in sites"), __("Inject SAML Settings in sites"), 'manage_options','network_saml_injection', 'load_saml_network_injection'); + add_submenu_page('network_saml_settings', __("Enable/Disable SAML on sites"), __("Enable/Disable SAML on sites"), 'manage_options','network_saml_enabler', 'load_saml_network_enabler'); +} + +function load_saml_network_global_config_page() { + require "network_saml_global_settings.php"; +} + +function load_saml_network_config_page() { + require "network.php"; +} + +function load_saml_network_injection() { + require "network_saml_injection.php"; +} + +function load_saml_network_enabler() { + require "network_saml_enabler.php"; +} + +function onelogin_saml_global_configuration_multisite_save() { + check_admin_referer('network_saml_global_settings_validate'); // Nonce security check + + if (isset($_POST)) { + if (isset($_POST['global_jit']) && $_POST['global_jit'] === 'on') { + $global_jit = true; + } else { + $global_jit = false; + } + update_site_option("onelogin_network_saml_global_jit", $global_jit); + } + + wp_redirect(add_query_arg( array( + 'page' => 'network_saml_global_settings', + 'updated' => true ), network_admin_url('admin.php') + )); + + exit; +} + +function onelogin_saml_configuration_multisite_save() { + check_admin_referer('network_saml_settings_validate'); // Nonce security check + + $fields = get_onelogin_saml_settings(); + + foreach (array_keys($fields) as $section) { + foreach (array_keys($fields[$section]) as $name) { + update_site_option($name, wp_unslash($_POST[$name])); + } + } + + wp_redirect(add_query_arg( array( + 'page' => 'network_saml_settings', + 'updated' => true ), network_admin_url('admin.php') + )); + + exit; +} + +function onelogin_saml_configuration_multisite_injection() { + $updated = false; + if (!empty($_POST) && isset($_POST['inject_saml_in_site'])) { + $fields = get_onelogin_saml_settings(); + $sites = sanitize_array_int($_POST['inject_saml_in_site']); + foreach ($sites as $site_id) { + foreach (array_keys($fields) as $section) { + foreach (array_keys($fields[$section]) as $name) { + $name = sanitize_key($name); + update_blog_option($site_id, $name, get_site_option($name, '')); + } + } + } + $updated = true; + } + + wp_redirect(add_query_arg( array( + 'page' => 'network_saml_injection', + 'updated' => $updated ), network_admin_url('admin.php') + )); + + exit(); +} + +function onelogin_saml_configuration_multisite_enabler() { + $updated = false; + if (!empty($_POST)) { + $enable_on_sites = array(); + if (isset($_POST['enable_saml_in_site'])) { + $enable_on_sites = sanitize_array_int($_POST['enable_saml_in_site']); + } + + $opts = array('number' => 1000); + $sites = get_sites($opts); + foreach ($sites as $site) { + $value = false; + if (in_array($site->id, $enable_on_sites)) { + $value = "on"; + } + update_blog_option($site->id, 'onelogin_saml_enabled', $value); + } + $updated = true; + } + + wp_redirect(add_query_arg( array( + 'page' => 'network_saml_enabler', + 'updated' => $updated ), network_admin_url('admin.php') + )); + + exit(); +} + +function get_onelogin_saml_settings() { + $status_fields = array( + 'onelogin_saml_enabled' => array( + __('Enable', 'onelogin-saml-sso'), + 'boolean' + ) + ); + + $idp_fields = get_onelogin_saml_settings_idp(); + $options_fields = get_onelogin_saml_settings_options(); + $attr_mapping_fields = get_onelogin_saml_settings_attribute_mapping(); + $role_mapping_fields = get_onelogin_saml_settings_role_mapping(); + $role_precedence_fields = get_onelogin_saml_settings_role_precedence(); + $customize_links_fields = get_onelogin_saml_settings_customize_links(); + $advanced_fields = get_onelogin_saml_settings_advanced(); + + $settings = array ( + 'status' => $status_fields, + 'idp' => $idp_fields, + 'options' => $options_fields, + 'attr_mapping' => $attr_mapping_fields, + 'role_mapping' => $role_mapping_fields, + 'role_precedence' => $role_precedence_fields, + 'customize_links' => $customize_links_fields, + 'advanced_settings' => $advanced_fields + ); + + return $settings; +} + +function get_sections() { + return array ( + 'status' => __('STATUS', 'onelogin-saml-sso'), + 'idp' => __('IDENTITY PROVIDER SETTINGS', 'onelogin-saml-sso'), + 'options' => __('OPTIONS', 'onelogin-saml-sso'), + 'attr_mapping' => __('ATTRIBUTE MAPPING', 'onelogin-saml-sso'), + 'role_mapping' => __('ROLE MAPPING', 'onelogin-saml-sso'), + 'role_precedence' => __('ROLE PRECEDENCE', 'onelogin-saml-sso'), + 'customize_links' => __('CUSTOMIZE ACTIONS AND LINKS', 'onelogin-saml-sso'), + 'advanced_settings' => __('ADVANCED SETTINGS', 'onelogin-saml-sso'), + ); +} + +function get_onelogin_saml_settings_idp() { + return array ( + 'onelogin_saml_idp_entityid' => array( + __('IdP Entity Id', 'onelogin-saml-sso') . ' *', + 'string' + ), + 'onelogin_saml_idp_sso' => array( + __('Single Sign On Service Url', 'onelogin-saml-sso') . ' *', + 'string' + ), + 'onelogin_saml_idp_slo' => array( + __('Single Log Out Service Url', 'onelogin-saml-sso'), + 'string' + ), + 'onelogin_saml_idp_x509cert' => array( + __('X.509 Certificate', 'onelogin-saml-sso'), + 'textarea' + ), + ); +} + +function get_onelogin_saml_settings_options() { + return array ( + 'onelogin_saml_autocreate' => array( + __('Create user if not exists', 'onelogin-saml-sso'), + 'boolean' + ), + 'onelogin_saml_updateuser' => array( + __('Update user data', 'onelogin-saml-sso'), + 'boolean' + ), + 'onelogin_saml_forcelogin' => array( + __('Force SAML login', 'onelogin-saml-sso'), + 'boolean' + ), + 'onelogin_saml_slo' => array( + __('Single Log Out', 'onelogin-saml-sso'), + 'boolean' + ), + 'onelogin_saml_keep_local_login' => array( + __('Keep Local login', 'onelogin-saml-sso'), + 'boolean' + ), + 'onelogin_saml_alternative_acs' => array( + __('Alternative ACS Endpoint', 'onelogin-saml-sso'), + 'boolean' + ), + 'onelogin_saml_account_matcher' => array( + __('Match Wordpress account by', 'onelogin-saml-sso'), + 'select' + ), + 'onelogin_saml_trigger_login_hook' => array( + __('Trigger wp_login hook', 'onelogin-saml-sso'), + 'boolean' + ), + 'onelogin_saml_multirole' => array( + __('Multi Role Support', 'onelogin-saml-sso'), + 'boolean' + ), + 'onelogin_saml_trusted_url_domains' => array( + __('Trust URL domains on RelayState', 'onelogin-saml-sso'), + 'textarea' + ) + ); +} + +function get_onelogin_saml_settings_attribute_mapping() { + return array ( + 'onelogin_saml_attr_mapping_username' => array( + __('Username', 'onelogin-saml-sso') . ' *', + 'string' + ), + 'onelogin_saml_attr_mapping_mail' => array( + __('E-mail', 'onelogin-saml-sso') . ' *', + 'string' + ), + 'onelogin_saml_attr_mapping_firstname' => array( + __('First Name', 'onelogin-saml-sso'), + 'string' + ), + 'onelogin_saml_attr_mapping_lastname' => array( + __('Last Name', 'onelogin-saml-sso'), + 'string' + ), + 'onelogin_saml_attr_mapping_nickname' => array( + __('Nickname', 'onelogin-saml-sso'), + 'string' + ), + 'onelogin_saml_attr_mapping_role' => array( + __('Role', 'onelogin-saml-sso'), + 'string' + ), + 'onelogin_saml_attr_mapping_rememberme' => array( + __('Remember Me', 'onelogin-saml-sso'), + 'string' + ) + ); +} + +function get_onelogin_saml_settings_role_mapping() { + $fields = array(); + foreach (wp_roles()->get_names() as $role_value => $role_name) { + $name = 'onelogin_saml_role_mapping_'.$role_value; + $fields[$name] = array( + $role_name, + 'string' + ); + } + + $fields['onelogin_saml_role_mapping_multivalued_in_one_attribute_value'] = array( + __('Multiple role values in one saml attribute value', 'onelogin-saml-sso'), + 'boolean' + ); + + $fields['onelogin_saml_role_mapping_multivalued_pattern'] = array( + __('Regular expression for multiple role values', 'onelogin-saml-sso'), + 'string' + ); + + return $fields; +} + + +function get_onelogin_saml_settings_role_precedence() { + $fields = array(); + foreach (wp_roles()->get_names() as $role_value => $role_name) { + $name = 'onelogin_saml_role_order_'.$role_value; + $fields[$name] = array( + $role_name, + 'string' + ); + } + + return $fields; + } + +function get_onelogin_saml_settings_customize_links() { + return array ( + 'onelogin_saml_customize_action_prevent_local_login' => array( + __('Prevent use of ?normal', 'onelogin-saml-sso'), + 'boolean' + ), + 'onelogin_saml_customize_action_prevent_reset_password' => array( + __('Prevent reset password', 'onelogin-saml-sso'), + 'boolean' + ), + 'onelogin_saml_customize_action_prevent_change_password' => array( + __('Prevent change password', 'onelogin-saml-sso'), + 'boolean' + ), + 'onelogin_saml_customize_action_prevent_change_mail' => array( + __('Prevent change mail', 'onelogin-saml-sso'), + 'boolean' + ), + 'onelogin_saml_customize_stay_in_wordpress_after_slo' => array( + __('Stay in WordPress after SLO', 'onelogin-saml-sso'), + 'boolean' + ), + 'onelogin_saml_customize_links_user_registration' => array( + __('User Registration', 'onelogin-saml-sso'), + 'string' + ), + 'onelogin_saml_customize_links_lost_password' => array( + __('Lost Password', 'onelogin-saml-sso'), + 'string' + ), + 'onelogin_saml_customize_links_saml_login' => array( + __('SAML Link Message', 'onelogin-saml-sso'), + 'string' + ) + ); +} + +function get_onelogin_saml_settings_advanced() { + return array ( + 'onelogin_saml_advanced_settings_debug' => array( + __('Debug Mode', 'onelogin-saml-sso'), + 'boolean' + ), + 'onelogin_saml_advanced_settings_strict_mode' => array( + __('Strict Mode', 'onelogin-saml-sso'), + 'boolean' + ), + 'onelogin_saml_advanced_settings_sp_entity_id' => array( + __('Service Provider Entity Id', 'onelogin-saml-sso'), + 'string' + ), + 'onelogin_saml_advanced_idp_lowercase_url_encoding' => array( + __('Lowercase URL encoding?', 'onelogin-saml-sso'), + 'boolean' + ), + 'onelogin_saml_advanced_settings_nameid_encrypted' => array( + __('Encrypt nameID', 'onelogin-saml-sso'), + 'boolean' + ), + 'onelogin_saml_advanced_settings_authn_request_signed' => array( + __('Sign AuthnRequest', 'onelogin-saml-sso'), + 'boolean' + ), + 'onelogin_saml_advanced_settings_logout_request_signed' => array( + __('Sign LogoutRequest', 'onelogin-saml-sso'), + 'boolean' + ), + 'onelogin_saml_advanced_settings_logout_response_signed' => array( + __('Sign LogoutResponse', 'onelogin-saml-sso'), + 'boolean' + ), + 'onelogin_saml_advanced_settings_want_message_signed' => array( + __('Reject Unsigned Messages', 'onelogin-saml-sso'), + 'boolean' + ), + 'onelogin_saml_advanced_settings_want_assertion_signed' => array( + __('Reject Unsigned Assertions', 'onelogin-saml-sso'), + 'boolean' + ), + 'onelogin_saml_advanced_settings_want_assertion_encrypted' => array( + __('Reject Unencrypted Assertions', 'onelogin-saml-sso'), + 'boolean' + ), + 'onelogin_saml_advanced_settings_retrieve_parameters_from_server' => array( + __('Retrieve Parameters From Server', 'onelogin-saml-sso'), + 'boolean' + ), + 'onelogin_saml_advanced_nameidformat' => array( + __('NameIDFormat', 'onelogin-saml-sso'), + 'select' + ), + 'onelogin_saml_advanced_requestedauthncontext' => array( + __('requestedAuthnContext', 'onelogin-saml-sso'), + 'select' + ), + 'onelogin_saml_advanced_settings_sp_x509cert' => array( + __('Service Provider X.509 Certificate', 'onelogin-saml-sso'), + 'textarea' + ), + 'onelogin_saml_advanced_settings_sp_privatekey' => array( + __('Service Provider Private Key', 'onelogin-saml-sso'), + 'textarea' + ), + 'onelogin_saml_advanced_signaturealgorithm' => array( + __('Signature Algorithm', 'onelogin-saml-sso'), + 'select' + ), + 'onelogin_saml_advanced_digestalgorithm' => array( + __('Digest Algorithm', 'onelogin-saml-sso'), + 'select' + ) + ); +} diff --git a/example/onelogin_custom_settings/functions.php b/example/onelogin_custom_settings/functions.php new file mode 100755 index 00000000..943a7db1 --- /dev/null +++ b/example/onelogin_custom_settings/functions.php @@ -0,0 +1,729 @@ +'.esc_html($saml_login_message).'
'; +} + +function saml_load_translations() { + $domain = 'onelogin-saml-sso'; + $mo_file = plugin_dir_path(dirname(__FILE__)) . 'lang/'.get_locale() . '/' . $domain . '.mo'; + + load_textdomain($domain, $mo_file ); + load_plugin_textdomain($domain, false, dirname( plugin_basename( __FILE__ ) ) . '/lang/'. get_locale() . '/' ); +} + +function saml_lostpassword() { + $target = get_option('onelogin_saml_customize_links_lost_password'); + if (!empty($target)) { + wp_redirect($target); + exit; + } +} + +function saml_user_register() { + $target = get_option('onelogin_saml_customize_links_user_registration'); + if (!empty($target)) { + wp_redirect($target); + exit; + } +} + +function saml_sso() { + if (may_disable_saml()) { + return true; + } + + if (is_user_logged_in()) { + return true; + } + $auth = initialize_saml(); + if ($auth == false) { + wp_redirect(home_url()); + exit(); + } + + if (isset($_GET["target"])) { + $auth->login($_GET["target"]); + } else if (isset($_GET['redirect_to'])) { + $auth->login($_GET['redirect_to']); + } else if (isset($_SERVER['REQUEST_URI']) && !isset($_GET['saml_sso'])) { + $auth->login($_SERVER['REQUEST_URI']); + } else { + $auth->login(); + } + exit(); +} + +function saml_slo() { + if (may_disable_saml()) { + return true; + } + + $slo = get_option('onelogin_saml_slo'); + + if (isset($_GET['action']) && $_GET['action'] == 'logout') { + if (!$slo) { + wp_logout(); + return false; + } else { + $nameId = null; + $sessionIndex = null; + $nameIdFormat = null; + $samlNameIdNameQualifier = null; + $samlNameIdSPNameQualifier = null; + + if (isset($_COOKIE[SAML_NAMEID_COOKIE])) { + $nameId = sanitize_text_field($_COOKIE[SAML_NAMEID_COOKIE]); + } + if (isset($_COOKIE[SAML_SESSIONINDEX_COOKIE])) { + $sessionIndex = sanitize_text_field($_COOKIE[SAML_SESSIONINDEX_COOKIE]); + } + if (isset($_COOKIE[SAML_NAMEID_FORMAT_COOKIE])) { + $nameIdFormat = sanitize_text_field($_COOKIE[SAML_NAMEID_FORMAT_COOKIE]); + } + if (isset($_COOKIE[SAML_NAMEID_NAME_QUALIFIER_COOKIE])) { + $nameIdNameQualifier = sanitize_text_field($_COOKIE[SAML_NAMEID_NAME_QUALIFIER_COOKIE]); + } + if (isset($_COOKIE[SAML_NAMEID_SP_NAME_QUALIFIER_COOKIE])) { + $nameIdSPNameQualifier = sanitize_text_field($_COOKIE[SAML_NAMEID_SP_NAME_QUALIFIER_COOKIE]); + } + + $auth = initialize_saml(); + if ($auth == false) { + wp_redirect(home_url()); + exit(); + } + $auth->logout(home_url(), array(), $nameId, $sessionIndex, false, $nameIdFormat, $nameIdNameQualifier, $nameIdSPNameQualifier); + return false; + } + } +} + +function saml_role_order_get($role) { + static $role_defaults = array( + 'administrator' => 1, + 'editor' => 2, + 'author' => 3, + 'contributor' => 4, + 'subscriber' => 5); + $rv = get_option(sanitize_key('onelogin_saml_role_order_'.$role)); + if (empty($rv)) + if (isset($role_defaults[$role])) { + return $role_defaults[$role]; + } else { + return PHP_INT_MAX; + } + else { + return (int)$rv; + } +} + +function saml_role_order_compare($role1, $role2) { + $r1 = saml_role_order_get($role1); + $r2 = saml_role_order_get($role2); + if ($r1 > $r2) + return 1; + else if ($r1 < $r2) + return -1; + else return 0; +} + +function saml_acs() { + if (may_disable_saml()) { + return true; + } + + $auth = initialize_saml(); + if ($auth == false) { + wp_redirect(home_url()); + exit(); + } + + $auth->processResponse(); + + $errors = $auth->getErrors(); + if (!empty($errors)) { + // Don't raise an error on passive mode + $errorReason = $auth->getLastErrorReason(); + if (strpos($errorReason, 'Responder') != false && strpos($errorReason, 'Passive') !== false ) { + $relayState = esc_url_raw( $_REQUEST['RelayState'], ['https','http']); + + if (empty($relayState)) { + wp_redirect(home_url()); + } else { + if (strpos($relayState, 'redirect_to') !== false) { + $query = wp_parse_url($relayState, PHP_URL_QUERY); + parse_str($query, $parameters); + redirect_to_relaystate_if_trusted(urldecode($parameters['redirect_to'])); + } else { + redirect_to_relaystate_if_trusted($relayState); + } + } + exit(); + } + + echo '
'.__("There was at least one error processing the SAML Response").': '; + foreach($errors as $error) { + echo esc_html($error).'
'; + } + echo __("Contact the administrator"); + exit(); + } + + $attrs = $auth->getAttributes(); + + if (empty($attrs)) { + $nameid = $auth->getNameId(); + if (empty($nameid)) { + echo __("The SAMLResponse may contain NameID or AttributeStatement"); + exit(); + } + $username = sanitize_user($nameid); + $email = sanitize_email($nameid); + } else { + $usernameMapping = get_option('onelogin_saml_attr_mapping_username'); + $mailMapping = get_option('onelogin_saml_attr_mapping_mail'); + + if (!empty($usernameMapping) && isset($attrs[$usernameMapping]) && !empty($attrs[$usernameMapping][0])){ + $username = sanitize_user($attrs[$usernameMapping][0]); + } + if (!empty($mailMapping) && isset($attrs[$mailMapping]) && !empty($attrs[$mailMapping][0])){ + $email = sanitize_email($attrs[$mailMapping][0]); + } + } + + if (empty($username)) { + echo __("The username could not be retrieved from the IdP and is required"); + exit(); + } + else if (empty($email)) { + echo __("The email could not be retrieved from the IdP and is required"); + exit(); + } else if (!is_email($email)) { + echo __("The email provided is invalid"); + exit(); + } else { + $userdata = array(); + $userdata['user_login'] = wp_slash($username); + $userdata['user_email'] = wp_slash($email); + } + + if (!empty($attrs)) { + $firstNameMapping = get_option('onelogin_saml_attr_mapping_firstname'); + $lastNameMapping = get_option('onelogin_saml_attr_mapping_lastname'); + $nickNameMapping = get_option('onelogin_saml_attr_mapping_nickname'); + $roleMapping = get_option('onelogin_saml_attr_mapping_role'); + $spidCodeMapping = 'spidCodes'; #ITWallet: added uri option non nameidformat droplist + + if (!empty($firstNameMapping) && isset($attrs[$firstNameMapping]) && !empty($attrs[$firstNameMapping][0])){ + $userdata['first_name'] = $attrs[$firstNameMapping][0]; + } + + if (!empty($lastNameMapping) && isset($attrs[$lastNameMapping]) && !empty($attrs[$lastNameMapping][0])){ + $userdata['last_name'] = $attrs[$lastNameMapping][0]; + } + if (!empty($nickNameMapping) && isset($attrs[$nickNameMapping]) && !empty($attrs[$nickNameMapping][0])){ + $userdata['nickname'] = $attrs[$nickNameMapping][0]; + } + #ITWallet: adding spidCode to the user object + if (!empty($spidCodeMapping) && isset($attrs[$spidCodeMapping]) && !empty($attrs[$spidCodeMapping][0])){ + $userdata[SPIDCODENAME] = $attrs[$spidCodeMapping][0]; + } + + if (!empty($roleMapping) && isset($attrs[$roleMapping])){ + $multiValued = get_option('onelogin_saml_role_mapping_multivalued_in_one_attribute_value', false); + if ($multiValued && count($attrs[$roleMapping]) == 1) { + $roleValues = array(); + $pattern = get_option('onelogin_saml_role_mapping_multivalued_pattern'); + if (!empty($pattern)) { + preg_match_all($pattern, $attrs[$roleMapping][0], $roleValues); + if (!empty($roleValues)) { + $attrs[$roleMapping] = $roleValues[1]; + } + } else { + $roleValues = explode(';', $attrs[$roleMapping][0]); + $attrs[$roleMapping] = $roleValues; + } + } + + $all_roles = wp_roles()->get_names(); + $roles_found = array(); + + foreach ($attrs[$roleMapping] as $samlRole) { + $samlRole = trim($samlRole); + if (empty($samlRole)) { + continue; + } + + foreach ($all_roles as $role_value => $role_name) { + $role_value = sanitize_key($role_value); + $matchList = explode(',', get_option('onelogin_saml_role_mapping_'.$role_value)); + if (in_array($samlRole, $matchList)) { + $roles_found[$role_value] = true; + } + } + } + + $multirole = get_site_option('onelogin_saml_multirole'); + $userdata['roles'] = []; + + uksort($roles_found, 'saml_role_order_compare'); + foreach ($roles_found as $role_value => $_role_found) { + $userdata['roles'][] = $role_value; + if (!$multirole || is_multisite()) { + break; + } + } + } + } + + $matcher = get_option('onelogin_saml_account_matcher'); + $newuser = false; + + if (empty($matcher) || $matcher == 'username') { + $matcherValue = $userdata['user_login']; + $user_id = username_exists($matcherValue); + } else { + $matcherValue = $userdata['user_email']; + $user_id = email_exists($matcherValue); + } + + if ($user_id) { + if (is_multisite()) { + if (get_site_option('onelogin_network_saml_global_jit')) { + enroll_user_on_sites($user_id, $userdata['roles']); + } else if (!is_user_member_of_blog($user_id)) { + if (get_option('onelogin_saml_autocreate')) { + //Exist's but is not user to the current blog id + $blog_id = get_current_blog_id(); + enroll_user_on_blogs($blog_id, $user_id, $userdata['roles']); + } else { + $user_id = null; + echo __("User provided by the IdP "). ' "'. esc_attr($matcherValue). '" '. __("does not exist in this wordpress site and auto-provisioning is disabled."); + exit(); + } + } + } + + if (get_option('onelogin_saml_updateuser')) { + $userdata['ID'] = $user_id; + unset($userdata['$user_pass']); + + $roles = []; + if (isset($userdata['roles'])) { + // Prevent to change the role to the superuser (id=1) + if ($user_id == 1) { + unset($userdata['roles']); + } else { + $roles = $userdata['roles']; + unset($userdata['roles']); + } + } + + $user_id = wp_update_user($userdata); + if (isset($user_id) && !empty($roles)) { + update_user_role($user_id, $roles); + } + #ITWallet: updating spidCode value on the db for the existing user + update_user_meta($user_id, SPIDCODENAME, $userdata[SPIDCODENAME]); + } + } else if (get_option('onelogin_saml_autocreate')) { + $newuser = true; + if (!validate_username($username)) { + echo __("The username provided by the IdP"). ' "'. esc_attr($username). '" '. __("is not valid and can't create the user at wordpress"); + exit(); + } + + if (!isset($userdata['roles'])) { + $userdata['roles'] = array(); + $userdata['roles'][] = get_option('default_role'); + } + $userdata['role'] = array_shift($userdata['roles']); + $roles = $userdata['roles']; + unset($userdata['roles']); + $userdata['user_pass'] = wp_generate_password(); + $user_id = wp_insert_user($userdata); + if ($user_id && !is_a($user_id, 'WP_Error')) { + if (is_multisite()) { + if (get_site_option('onelogin_network_saml_global_jit')) { + enroll_user_on_sites($user_id, $userdata['roles']); + } else { + $blog_id = get_current_blog_id(); + enroll_user_on_blogs($blog_id, $user_id, $userdata['roles']); + } + } else if (!empty($roles)) { + add_roles_to_user($user_id, $roles); + } + + #ITWallet: adding spidCode value on the db for new user + update_user_meta($user_id, SPIDCODENAME, $userdata[SPIDCODENAME]); + } + } else { + echo __("User provided by the IdP "). ' "'. esc_attr($matcherValue). '" '. __("does not exist in wordpress and auto-provisioning is disabled."); + exit(); + } + + if (is_a($user_id, 'WP_Error')) { + $errors = $user_id->get_error_messages(); + foreach($errors as $error) { + echo esc_html($error).'
'; + } + exit(); + } else if ($user_id) { + wp_set_current_user($user_id); + + $rememberme = false; + $remembermeMapping = get_option('onelogin_saml_attr_mapping_rememberme'); + if (!empty($remembermeMapping) && isset($attrs[$remembermeMapping]) && !empty($attrs[$remembermeMapping][0])) { + $rememberme = in_array($attrs[$remembermeMapping][0], array(1, true, '1', 'yes', 'on')) ? true : false; + } + wp_set_auth_cookie($user_id, $rememberme); + + $secure = is_ssl(); + setcookie(SAML_LOGIN_COOKIE, 1, time() + MONTH_IN_SECONDS, SITECOOKIEPATH, COOKIE_DOMAIN, $secure, true); + setcookie(SAML_NAMEID_COOKIE, $auth->getNameId(), time() + MONTH_IN_SECONDS, SITECOOKIEPATH, COOKIE_DOMAIN, $secure, true); + setcookie(SAML_SESSIONINDEX_COOKIE, $auth->getSessionIndex(), time() + MONTH_IN_SECONDS, SITECOOKIEPATH, COOKIE_DOMAIN, $secure, true); + setcookie(SAML_NAMEID_FORMAT_COOKIE, $auth->getNameIdFormat(), time() + MONTH_IN_SECONDS, SITECOOKIEPATH, COOKIE_DOMAIN, $secure, true); + setcookie(SAML_NAMEID_NAME_QUALIFIER_COOKIE, $auth->getNameIdNameQualifier(), time() + MONTH_IN_SECONDS, SITECOOKIEPATH, COOKIE_DOMAIN, $secure, true); + setcookie(SAML_NAMEID_SP_NAME_QUALIFIER_COOKIE, $auth->getNameIdSPNameQualifier(), time() + MONTH_IN_SECONDS, SITECOOKIEPATH, COOKIE_DOMAIN, $secure, true); + } + + do_action( 'onelogin_saml_attrs', $attrs, wp_get_current_user(), get_current_user_id(), $newuser); + + // Trigger the wp_login hook used by wp_signon() + // @see https://developer.wordpress.org/reference/hooks/wp_login/ + $trigger_wp_login_hook = get_site_option( 'onelogin_saml_trigger_login_hook' ); + + if ( $trigger_wp_login_hook ) { + $user = get_user_by( 'id', $user_id ); + + if ( false !== $user ) { + do_action( 'wp_login', $user->user_login, $user ); + } + } + + if (isset($_REQUEST['RelayState'])) { + $relayState = esc_url_raw( $_REQUEST['RelayState'], ['https','http']); + + if (!empty($relayState) && ((substr($relayState, -strlen('/wp-login.php')) === '/wp-login.php') || (substr($relayState, -strlen('/alternative_acs.php')) === '/alternative_acs.php'))) { + wp_redirect(home_url()); + } else { + if (strpos($relayState, 'redirect_to') !== false) { + $query = wp_parse_url($relayState, PHP_URL_QUERY); + parse_str($query, $parameters); + redirect_to_relaystate_if_trusted(urldecode($parameters['redirect_to'])); + } else { + redirect_to_relaystate_if_trusted($relayState); + } + } + } else { + wp_redirect(home_url()); + } + exit(); +} + +function saml_sls() { + if (may_disable_saml()) { + return true; + } + + $auth = initialize_saml(); + if ($auth == false) { + wp_redirect(home_url()); + exit(); + } + + $retrieve_parameters_from_server = get_option('onelogin_saml_advanced_settings_retrieve_parameters_from_server', false); + if (isset($_GET) && isset($_GET['SAMLRequest'])) { + // Close session before send the LogoutResponse to the IdP + $auth->processSLO(false, null, $retrieve_parameters_from_server, 'wp_logout'); + } else { + $auth->processSLO(false, null, $retrieve_parameters_from_server); + } + $errors = $auth->getErrors(); + if (empty($errors)) { + wp_logout(); + $secure = is_ssl(); + setcookie(SAML_LOGIN_COOKIE, 0, time() - 3600, SITECOOKIEPATH, COOKIE_DOMAIN, $secure, true); + setcookie(SAML_NAMEID_COOKIE, null, time() - 3600, SITECOOKIEPATH, COOKIE_DOMAIN, $secure, true); + setcookie(SAML_SESSIONINDEX_COOKIE, null, time() - 3600, SITECOOKIEPATH, COOKIE_DOMAIN, $secure, true); + setcookie(SAML_NAMEID_FORMAT_COOKIE, null, time() - 3600, SITECOOKIEPATH, COOKIE_DOMAIN, $secure, true); + setcookie(SAML_NAMEID_NAME_QUALIFIER_COOKIE, null, time() - 3600, SITECOOKIEPATH, COOKIE_DOMAIN, $secure, true); + setcookie(SAML_NAMEID_SP_NAME_QUALIFIER_COOKIE, null, time() - 3600, SITECOOKIEPATH, COOKIE_DOMAIN, $secure, true); + + if (get_option('onelogin_saml_forcelogin') && get_option('onelogin_saml_customize_stay_in_wordpress_after_slo')) { + wp_redirect(home_url().'/wp-login.php?loggedout=true'); + } else { + if (isset($_REQUEST['RelayState'])) { + redirect_to_relaystate_if_trusted($_REQUEST['RelayState']); + } else { + wp_redirect(home_url()); + } + } + exit(); + } else { + echo __("SLS endpoint found an error."); + foreach($errors as $error) { + echo esc_html($error).'
'; + } + exit(); + } +} + +function saml_metadata() { + require_once plugin_dir_path(__FILE__).'_toolkit_loader.php'; + require plugin_dir_path(__FILE__).'settings.php'; + + $samlSettings = new Settings($settings, true); + $metadata = $samlSettings->getSPMetadata(); + + header('Content-Type: text/xml'); + echo ent2ncr($metadata); + exit(); +} + + +function saml_validate_config() { + saml_load_translations(); + require_once plugin_dir_path(__FILE__).'_toolkit_loader.php'; + require plugin_dir_path(__FILE__).'settings.php'; + require_once plugin_dir_path(__FILE__)."validate.php"; + exit(); +} + +function initialize_saml() { + require_once plugin_dir_path(__FILE__).'_toolkit_loader.php'; + require plugin_dir_path(__FILE__).'settings.php'; + + if (!is_saml_enabled()) { + return false; + } + + try { + $auth = new Auth($settings); + } catch (\Exception $e) { + echo '
'.__("The Onelogin SSO/SAML plugin is not correctly configured.", 'onelogin-saml-sso').'
'; + echo esc_html($e->getMessage()); + echo '
'.__("If you are the administrator", 'onelogin-saml-sso').', '.__("access using your wordpress credentials", 'onelogin-saml-sso').' '.__("and fix the problem", 'onelogin-saml-sso'); + exit(); + } + + return $auth; +} + +function is_saml_enabled() { + $saml_enabled = get_option('onelogin_saml_enabled', 'not defined'); + if ($saml_enabled == 'not defined') { + // If no data was saved about enable/disable saml, then + // check if entityId also is not defined and then consider the + // plugin disabled + if (get_option('onelogin_saml_idp_entityid', 'not defined') == 'not defined') { + $saml_enabled = false; + } else { + $saml_enabled = true; + } + } else { + $saml_enabled = $saml_enabled == 'on'? true : false; + } + return $saml_enabled; +} + +function enroll_user_on_sites($user_id, $roles) { + $opts = array('number' => 1000); + $sites = get_sites($opts); + foreach ($sites as $site) { + if (get_blog_option($site_id, "onelogin_saml_autocreate") && !is_user_member_of_blog($user_id, $site->id)) { + foreach($roles as $role) { + add_user_to_blog($site->id, $user_id, $role); + } + } + } +} + +function enroll_user_on_blogs($blog_id, $user_id, $roles) { + foreach($roles as $role) { + add_user_to_blog($blog_id, $user_id, $role); + } +} + +function update_user_role($user_id, $roles) +{ + $user = get_user_by('id', $user_id); + $role = array_shift($roles); + $user->set_role($role); // This removes previous assignations + + foreach($roles as $role) { + $user->add_role($role); + } +} + +function add_roles_to_user($user_id, $roles) +{ + $user = get_user_by('id', $user_id); + + foreach($roles as $role) { + $user->add_role($role); + } +} + +// Prevent that the user change important fields +class preventLocalChanges +{ + function __construct() + { + if (get_option('onelogin_saml_customize_action_prevent_change_mail', false)) { + add_action('admin_footer', array($this, 'disable_email')); + } + if (get_option('onelogin_saml_customize_action_prevent_change_password', false)) { + add_action('admin_footer', array($this, 'disable_password')); + } + } + + function disable_email() + { + global $pagenow; + if ($pagenow == 'profile.php' && !current_user_can( 'manage_options' )) { + + ?> + + + + Constants::NAMEID_UNSPECIFIED, + 'emailAddress' => Constants::NAMEID_EMAIL_ADDRESS, + 'transient' => Constants::NAMEID_TRANSIENT, + 'persistent' => Constants::NAMEID_PERSISTENT, + 'entity' => Constants::NAMEID_ENTITY, + 'encrypted' => Constants::NAMEID_ENCRYPTED, + 'kerberos' => Constants::NAMEID_KERBEROS, + 'x509subjecname' => Constants::NAMEID_X509_SUBJECT_NAME, + 'windowsdomainqualifiedname' => Constants::NAMEID_WINDOWS_DOMAIN_QUALIFIED_NAME, + 'uri' => 'urn:oasis:names:tc:SAML:2.0:attrname-format:uri' #ITWallet: add uri name format +); +$posible_requestedauthncontext_values = array( + 'unspecified' => Constants::AC_UNSPECIFIED, + 'password' => Constants::AC_PASSWORD, + 'passwordprotectedtransport' => "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport", + 'x509' => Constants::AC_X509, + 'smartcard' => Constants::AC_SMARTCARD, + 'kerberos' => Constants::AC_KERBEROS, +); + + +$opt['strict'] = get_option('onelogin_saml_advanced_settings_strict_mode', 'on'); +$opt['debug'] = get_option('onelogin_saml_advanced_settings_debug', 'on'); +$opt['sp_entity_id'] = get_option('onelogin_saml_advanced_settings_sp_entity_id', 'php-saml'); + +$opt['nameIdEncrypted'] = get_option('onelogin_saml_advanced_settings_nameid_encrypted', false); +$opt['authnRequestsSigned'] = get_option('onelogin_saml_advanced_settings_authn_request_signed', false); +$opt['logoutRequestSigned'] = get_option('onelogin_saml_advanced_settings_logout_request_signed', false); +$opt['logoutResponseSigned'] = get_option('onelogin_saml_advanced_settings_logout_response_signed', false); +$opt['wantMessagesSigned'] = get_option('onelogin_saml_advanced_settings_want_message_signed', false); +$opt['wantAssertionsSigned'] = get_option('onelogin_saml_advanced_settings_want_assertion_signed', false); +$opt['wantAssertionsEncrypted'] = get_option('onelogin_saml_advanced_settings_want_assertion_encrypted', false); + +$nameIDformat = get_option('onelogin_saml_advanced_nameidformat', 'unspecified'); +$opt['NameIDFormat'] = $posible_nameidformat_values[$nameIDformat]; + + +$requested_authncontext_values = get_option('onelogin_saml_advanced_requestedauthncontext', array()); +if ((is_array($requested_authncontext_values) && empty(array_filter($requested_authncontext_values))) || empty($requested_authncontext_values)) { + $opt['requestedAuthnContext'] = false; +} else { + $opt['requestedAuthnContext'] = array(); + foreach ($requested_authncontext_values as $value) { + if (isset($posible_requestedauthncontext_values[$value])) { + $opt['requestedAuthnContext'][] = $posible_requestedauthncontext_values[$value]; + } + } +} + +$acs_endpoint = get_option('onelogin_saml_alternative_acs', false) + ? plugins_url( 'alternative_acs.php', dirname( __FILE__ ) ) + : add_query_arg( [ 'saml_acs' => '' ], wp_login_url() ); + +$settings = array ( + + 'strict' => $opt['strict'] == 'on'? true : false, + 'debug' => $opt['debug'] == 'on'? true : false, + + 'sp' => array ( + 'entityId' => (!empty($opt['sp_entity_id'])? $opt['sp_entity_id'] : 'php-saml'), + 'assertionConsumerService' => array ( + 'url' => $acs_endpoint + ), + 'singleLogoutService' => array ( + 'url' => add_query_arg( [ 'saml_sls' => '' ], wp_login_url() ) + ), + 'NameIDFormat' => $opt['NameIDFormat'], + 'x509cert' => get_option('onelogin_saml_advanced_settings_sp_x509cert'), + 'privateKey' => get_option('onelogin_saml_advanced_settings_sp_privatekey'), + #ITWallet: Adding the attributeConsumingService section in the metadata + 'attributeConsumingService' => array ( + 'serviceName' => (!empty($opt['sp_entity_id'])? $opt['sp_entity_id'] : 'php-saml'), + 'requestedAttributes' => array ( + '0' => array ( + 'name' => 'spidCode', + 'isRequired' => true + ), + '1' => array ( + 'name' => 'name', + 'isRequired' => true + ), + '2' => array ( + 'name' => 'familyName', + 'isRequired' => true + ), + '3' => array ( + 'name' => 'fiscalNumber', + 'isRequired' => true + ), + '4' => array ( + 'name' => 'email', + 'isRequired' => true + ) + ) + ) + ), + + 'idp' => array ( + 'entityId' => get_option('onelogin_saml_idp_entityid'), + 'singleSignOnService' => array ( + 'url' => get_option('onelogin_saml_idp_sso'), + ), + 'singleLogoutService' => array ( + 'url' => get_option('onelogin_saml_idp_slo'), + ), + 'x509cert' => get_option('onelogin_saml_idp_x509cert'), + ), + + 'security' => array ( + 'signMetadata' => true, #ITWallet: enables metadata signature + 'nameIdEncrypted' => $opt['nameIdEncrypted'] == 'on'? true: false, + 'authnRequestsSigned' => $opt['authnRequestsSigned'] == 'on'? true: false, + 'logoutRequestSigned' => $opt['logoutRequestSigned'] == 'on'? true: false, + 'logoutResponseSigned' => $opt['logoutResponseSigned'] == 'on'? true: false, + 'wantMessagesSigned' => $opt['wantMessagesSigned'] == 'on'? true: false, + 'wantAssertionsSigned' => $opt['wantAssertionsSigned'] == 'on'? true: false, + 'wantAssertionsEncrypted' => $opt['wantAssertionsEncrypted'] == 'on'? true: false, + 'wantNameId' => false, + 'requestedAuthnContext' => $opt['requestedAuthnContext'], + 'relaxDestinationValidation' => true, + 'lowercaseUrlencoding' => get_option(' + onelogin_saml_advanced_idp_lowercase_url_encoding', false), + 'signatureAlgorithm' => get_option('onelogin_saml_advanced_signaturealgorithm', 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256'), + 'digestAlgorithm' => get_option('onelogin_saml_advanced_digestalgorithm', 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256'), + ) +); diff --git a/example/satosa/.DS_Store b/example/satosa/.DS_Store deleted file mode 100644 index fff91349..00000000 Binary files a/example/satosa/.DS_Store and /dev/null differ diff --git a/example/satosa/bootstrap-italia/.DS_Store b/example/satosa/bootstrap-italia/.DS_Store deleted file mode 100644 index 169156e0..00000000 Binary files a/example/satosa/bootstrap-italia/.DS_Store and /dev/null differ diff --git a/example/satosa/bootstrap-italia/fonts/.DS_Store b/example/satosa/bootstrap-italia/fonts/.DS_Store deleted file mode 100644 index be4298d2..00000000 Binary files a/example/satosa/bootstrap-italia/fonts/.DS_Store and /dev/null differ diff --git a/example/satosa/bootstrap-italia/svg/.DS_Store b/example/satosa/bootstrap-italia/svg/.DS_Store deleted file mode 100644 index a613dd04..00000000 Binary files a/example/satosa/bootstrap-italia/svg/.DS_Store and /dev/null differ diff --git a/example/satosa/disco.html b/example/satosa/disco.html index ee5c1870..4ac2f0c2 100644 --- a/example/satosa/disco.html +++ b/example/satosa/disco.html @@ -5,15 +5,15 @@ Accedi Con SPID - + - - + + - - - - + + + + - - +
@@ -170,7 +176,7 @@

SPID o CIE

@@ -180,8 +186,8 @@

SPID o CIE

@@ -207,7 +213,7 @@

Altre identità digitali

@@ -218,8 +224,8 @@

Altre identità digitali

@@ -237,6 +243,33 @@

Altre identità digitali

+ +
+
+ +
+ +
+
+ + - - - + + + diff --git a/example/satosa/eidas/.DS_Store b/example/satosa/eidas/.DS_Store deleted file mode 100644 index 5ff72db3..00000000 Binary files a/example/satosa/eidas/.DS_Store and /dev/null differ diff --git a/example/satosa/pyeudiw_backend.yaml b/example/satosa/pyeudiw_backend.yaml index 78fbeae7..f17efd01 100644 --- a/example/satosa/pyeudiw_backend.yaml +++ b/example/satosa/pyeudiw_backend.yaml @@ -16,16 +16,39 @@ config: request: '//request_uri' entity_configuration: '//.well-known/openid-federation' - qrcode_settings: + qrcode: size: 100 color: '#2B4375' logo_path: use_zlib: false - jwt_settings: + jwt: default_sig_alg: ES256 # or RS256 + default_enc_alg: RSA-OAEP + default_enc_enc: A256CBC-HS512 default_exp: 6 # minutes - + enc_alg_supported: + - RSA-OAEP + - RSA-OAEP-256 + - ECDH-ES + - ECDH-ES+A128KW + - ECDH-ES+A192KW + - ECDH-ES+A256KW + enc_enc_supported: + - A128CBC-HS256 + - A192CBC-HS384 + - A256CBC-HS512 + - A128GCM + - A192GCM + - A256GCM + sig_alg_supported: + - RS256 + - RS384 + - RS512 + - ES256 + - ES384 + - ES512 + authorization: url_scheme: "eudiw" # eudiw:// scopes: @@ -34,7 +57,7 @@ config: federation: metadata_type: "wallet_relying_party" federation_authorities: - - https://localhost:8000 + - http://127.0.0.1:8000 default_sig_alg: "RS256" # private jwk @@ -52,43 +75,49 @@ config: # private jwk metadata_jwks: - - crv: P-256 - d: KzQBowMMoPmSZe7G8QsdEWc1IvR2nsgE8qTOYmMcLtc - kid: dDwPWXz5sCtczj7CJbqgPGJ2qQ83gZ9Sfs-tJyULi6s - kty: EC - x: TSO-KOqdnUj5SUuasdlRB2VVFSqtJOxuR5GftUTuBdk - y: ByWgQt1wGBSnF56jQqLdoO1xKUynMY-BHIDB3eXlR7 + - crv: P-256 + d: KzQBowMMoPmSZe7G8QsdEWc1IvR2nsgE8qTOYmMcLtc + kid: dDwPWXz5sCtczj7CJbqgPGJ2qQ83gZ9Sfs-tJyULi6s + use: sig + kty: EC + x: TSO-KOqdnUj5SUuasdlRB2VVFSqtJOxuR5GftUTuBdk + y: ByWgQt1wGBSnF56jQqLdoO1xKUynMY-BHIDB3eXlR7 + - kty: RSA + d: QUZsh1NqvpueootsdSjFQz-BUvxwd3Qnzm5qNb-WeOsvt3rWMEv0Q8CZrla2tndHTJhwioo1U4NuQey7znijhZ177bUwPPxSW1r68dEnL2U74nKwwoYeeMdEXnUfZSPxzs7nY6b7vtyCoA-AjiVYFOlgKNAItspv1HxeyGCLhLYhKvS_YoTdAeLuegETU5D6K1xGQIuw0nS13Icjz79Y8jC10TX4FdZwdX-NmuIEDP5-s95V9DMENtVqJAVE3L-wO-NdDilyjyOmAbntgsCzYVGH9U3W_djh4t3qVFCv3r0S-DA2FD3THvlrFi655L0QHR3gu_Fbj3b9Ybtajpue_Q + e: AQAB + use: enc + kid: 9Cquk0X-fNPSdePQIgQcQZtD6J0IjIRrFigW2PPK_-w + n: utqtxbs-jnK0cPsV7aRkkZKA9t4S-WSZa3nCZtYIKDpgLnR_qcpeF0diJZvKOqXmj2cXaKFUE-8uHKAHo7BL7T-Rj2x3vGESh7SG1pE0thDGlXj4yNsg0qNvCXtk703L2H3i1UXwx6nq1uFxD2EcOE4a6qDYBI16Zl71TUZktJwmOejoHl16CPWqDLGo9GUSk_MmHOV20m4wXWkB4qbvpWVY8H6b2a0rB1B1YPOs5ZLYarSYZgjDEg6DMtZ4NgiwZ-4N1aaLwyO-GLwt9Vf-NBKwoxeRyD3zWE2FXRFBbhKGksMrCGnFDsNl5JTlPjaM3kYyImE941ggcuc495m-Fw + p: 2zmGXIMCEHPphw778YjVTar1eycih6fFSJ4I4bl1iq167GqO0PjlOx6CZ1-OdBTVU7HfrYRiUK_BnGRdPDn-DQghwwkB79ZdHWL14wXnpB5y-boHz_LxvjsEqXtuQYcIkidOGaMG68XNT1nM4F9a8UKFr5hHYT5_UIQSwsxlRQ0 + q: 2jMFt2iFrdaYabdXuB4QMboVjPvbLA-IVb6_0hSG_-EueGBvgcBxdFGIZaG6kqHqlB7qMsSzdptU0vn6IgmCZnX-Hlt6c5X7JB_q91PZMLTO01pbZ2Bk58GloalCHnw_mjPh0YPviH5jGoWM5RHyl_HDDMI-UeLkzP7ImxGizrM + # Mongodb database configuration - mongo_db_settings: - cache: - url: mongodb://localhost:27017/ - conf: - db_name: eudiw - storage: - url: mongodb://localhost:27017/ - conf: - db_name: eudiw - db_collection: sessions + storage: + mongo_db: + cache: + module: pyeudiw.storage.mongo_cache + class: MongoCache + config: + url: mongodb://localhost:27017/ + conf: + db_name: eudiw + storage: + module: pyeudiw.storage.mongo_storage + class: MongoStorage + config: + url: mongodb://localhost:27017/ + db_name: eudiw + db_collection: sessions #This is the configuration for the relaying party metadata metadata: application_type: web #The following section contains all the algorithms supported for the encryption of response - authorization_encrypted_response_alg: - - RSA-OAEP - - RSA-OAEP-256 - authorization_encrypted_response_enc: - - A128CBC-HS256 - - A192CBC-HS384 - - A256CBC-HS512 - - A128GCM - - A192GCM - - A256GCM - authorization_signed_response_alg: - - RS256 - - ES256 + authorization_encrypted_response_alg: + authorization_encrypted_response_enc: + authorization_signed_response_alg: #Various informations of the client client_id: "/" @@ -102,19 +131,9 @@ config: default_max_age: 1111 #The following section contains all the algorithms supported for the encryption of id token response - id_token_encrypted_response_alg: - - RSA-OAEP - - RSA-OAEP-256 - id_token_encrypted_response_enc: - - A128CBC-HS256 - - A192CBC-HS384 - - A256CBC-HS512 - - A128GCM - - A192GCM - - A256GCM - id_token_signed_response_alg: - - RS256 - - ES256 + id_token_encrypted_response_alg: + id_token_encrypted_response_enc: + id_token_signed_response_alg: # loaded in the __init__ # jwks: diff --git a/example/satosa/qr-code-page.html b/example/satosa/qr-code-page.html new file mode 100644 index 00000000..8a8ae8fd --- /dev/null +++ b/example/satosa/qr-code-page.html @@ -0,0 +1,66 @@ + + + + + + IT Wallet QRCode + + + + + + + +
+
+
+
+
+
+
+
+ +
+

IT Wallet

+
+
+
Richiesta di accesso con IT Wallet
+
+
+
+ Logo CIE +

Accedi più rapidamente. Inquadra il QR Code con l'App IT Wallet

+
+
+
+
+
+
+
+ Le informazioni e la disposizione degli elementi visivi di questa pagina non sono definiti a livello normativo e potrebbero essere soggetti a modifiche. +
+
+
+
+ + + + +
+
+
+
+ Caricamento... +
+
+ + + diff --git a/example/satosa/spid/.DS_Store b/example/satosa/spid/.DS_Store deleted file mode 100644 index 508f9092..00000000 Binary files a/example/satosa/spid/.DS_Store and /dev/null differ diff --git a/example/satosa/spid/fonts/.DS_Store b/example/satosa/spid/fonts/.DS_Store deleted file mode 100644 index 8989a5dd..00000000 Binary files a/example/satosa/spid/fonts/.DS_Store and /dev/null differ diff --git a/example/satosa/bootstrap-italia/assets/upload-drag-drop-icon.svg b/example/satosa/static/bootstrap-italia/assets/upload-drag-drop-icon.svg similarity index 100% rename from example/satosa/bootstrap-italia/assets/upload-drag-drop-icon.svg rename to example/satosa/static/bootstrap-italia/assets/upload-drag-drop-icon.svg diff --git a/example/satosa/bootstrap-italia/bootstrap-italia-comuni.js b/example/satosa/static/bootstrap-italia/bootstrap-italia-comuni.js similarity index 100% rename from example/satosa/bootstrap-italia/bootstrap-italia-comuni.js rename to example/satosa/static/bootstrap-italia/bootstrap-italia-comuni.js diff --git a/example/satosa/bootstrap-italia/bootstrap-italia.esm.js b/example/satosa/static/bootstrap-italia/bootstrap-italia.esm.js similarity index 100% rename from example/satosa/bootstrap-italia/bootstrap-italia.esm.js rename to example/satosa/static/bootstrap-italia/bootstrap-italia.esm.js diff --git a/example/satosa/bootstrap-italia/bootstrap-italia.esm.js.map b/example/satosa/static/bootstrap-italia/bootstrap-italia.esm.js.map similarity index 100% rename from example/satosa/bootstrap-italia/bootstrap-italia.esm.js.map rename to example/satosa/static/bootstrap-italia/bootstrap-italia.esm.js.map diff --git a/example/satosa/bootstrap-italia/css/bootstrap-italia-comuni.min.css b/example/satosa/static/bootstrap-italia/css/bootstrap-italia-comuni.min.css similarity index 100% rename from example/satosa/bootstrap-italia/css/bootstrap-italia-comuni.min.css rename to example/satosa/static/bootstrap-italia/css/bootstrap-italia-comuni.min.css diff --git a/example/satosa/bootstrap-italia/css/bootstrap-italia.min.css b/example/satosa/static/bootstrap-italia/css/bootstrap-italia.min.css similarity index 100% rename from example/satosa/bootstrap-italia/css/bootstrap-italia.min.css rename to example/satosa/static/bootstrap-italia/css/bootstrap-italia.min.css diff --git a/example/satosa/bootstrap-italia/css/bootstrap-italia.min.css.map b/example/satosa/static/bootstrap-italia/css/bootstrap-italia.min.css.map similarity index 100% rename from example/satosa/bootstrap-italia/css/bootstrap-italia.min.css.map rename to example/satosa/static/bootstrap-italia/css/bootstrap-italia.min.css.map diff --git a/example/satosa/bootstrap-italia/fonts/Lora/OFL.txt b/example/satosa/static/bootstrap-italia/fonts/Lora/OFL.txt similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Lora/OFL.txt rename to example/satosa/static/bootstrap-italia/fonts/Lora/OFL.txt diff --git a/example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700.eot b/example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700.eot similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700.eot rename to example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700.eot diff --git a/example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700.svg b/example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700.svg similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700.svg rename to example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700.svg diff --git a/example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700.ttf b/example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700.ttf similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700.ttf rename to example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700.ttf diff --git a/example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700.woff b/example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700.woff similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700.woff rename to example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700.woff diff --git a/example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700.woff2 b/example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700.woff2 similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700.woff2 rename to example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700.woff2 diff --git a/example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700italic.eot b/example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700italic.eot similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700italic.eot rename to example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700italic.eot diff --git a/example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700italic.svg b/example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700italic.svg similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700italic.svg rename to example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700italic.svg diff --git a/example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700italic.ttf b/example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700italic.ttf similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700italic.ttf rename to example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700italic.ttf diff --git a/example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700italic.woff b/example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700italic.woff similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700italic.woff rename to example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700italic.woff diff --git a/example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700italic.woff2 b/example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700italic.woff2 similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700italic.woff2 rename to example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-700italic.woff2 diff --git a/example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-italic.eot b/example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-italic.eot similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-italic.eot rename to example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-italic.eot diff --git a/example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-italic.svg b/example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-italic.svg similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-italic.svg rename to example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-italic.svg diff --git a/example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-italic.ttf b/example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-italic.ttf similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-italic.ttf rename to example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-italic.ttf diff --git a/example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-italic.woff b/example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-italic.woff similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-italic.woff rename to example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-italic.woff diff --git a/example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-italic.woff2 b/example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-italic.woff2 similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-italic.woff2 rename to example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-italic.woff2 diff --git a/example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-regular.eot b/example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-regular.eot similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-regular.eot rename to example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-regular.eot diff --git a/example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-regular.svg b/example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-regular.svg similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-regular.svg rename to example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-regular.svg diff --git a/example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-regular.ttf b/example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-regular.ttf similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-regular.ttf rename to example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-regular.ttf diff --git a/example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-regular.woff b/example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-regular.woff similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-regular.woff rename to example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-regular.woff diff --git a/example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-regular.woff2 b/example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-regular.woff2 similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-regular.woff2 rename to example/satosa/static/bootstrap-italia/fonts/Lora/lora-v20-latin-ext_latin-regular.woff2 diff --git a/example/satosa/bootstrap-italia/fonts/Roboto_Mono/LICENSE.txt b/example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/LICENSE.txt similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Roboto_Mono/LICENSE.txt rename to example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/LICENSE.txt diff --git a/example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700.eot b/example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700.eot similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700.eot rename to example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700.eot diff --git a/example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700.svg b/example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700.svg similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700.svg rename to example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700.svg diff --git a/example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700.ttf b/example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700.ttf similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700.ttf rename to example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700.ttf diff --git a/example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700.woff b/example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700.woff similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700.woff rename to example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700.woff diff --git a/example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700.woff2 b/example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700.woff2 similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700.woff2 rename to example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700.woff2 diff --git a/example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700italic.eot b/example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700italic.eot similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700italic.eot rename to example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700italic.eot diff --git a/example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700italic.svg b/example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700italic.svg similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700italic.svg rename to example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700italic.svg diff --git a/example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700italic.ttf b/example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700italic.ttf similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700italic.ttf rename to example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700italic.ttf diff --git a/example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700italic.woff b/example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700italic.woff similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700italic.woff rename to example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700italic.woff diff --git a/example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700italic.woff2 b/example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700italic.woff2 similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700italic.woff2 rename to example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-700italic.woff2 diff --git a/example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-italic.eot b/example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-italic.eot similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-italic.eot rename to example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-italic.eot diff --git a/example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-italic.svg b/example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-italic.svg similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-italic.svg rename to example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-italic.svg diff --git a/example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-italic.ttf b/example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-italic.ttf similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-italic.ttf rename to example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-italic.ttf diff --git a/example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-italic.woff b/example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-italic.woff similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-italic.woff rename to example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-italic.woff diff --git a/example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-italic.woff2 b/example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-italic.woff2 similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-italic.woff2 rename to example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-italic.woff2 diff --git a/example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-regular.eot b/example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-regular.eot similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-regular.eot rename to example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-regular.eot diff --git a/example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-regular.svg b/example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-regular.svg similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-regular.svg rename to example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-regular.svg diff --git a/example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-regular.ttf b/example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-regular.ttf similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-regular.ttf rename to example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-regular.ttf diff --git a/example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-regular.woff b/example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-regular.woff similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-regular.woff rename to example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-regular.woff diff --git a/example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-regular.woff2 b/example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-regular.woff2 similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-regular.woff2 rename to example/satosa/static/bootstrap-italia/fonts/Roboto_Mono/roboto-mono-v13-latin-ext_latin-regular.woff2 diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/OFL.txt b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/OFL.txt similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/OFL.txt rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/OFL.txt diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300.eot b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300.eot similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300.eot rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300.eot diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300.svg b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300.svg similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300.svg rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300.svg diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300.ttf b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300.ttf similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300.ttf rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300.ttf diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300.woff b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300.woff similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300.woff rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300.woff diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300.woff2 b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300.woff2 similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300.woff2 rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300.woff2 diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300italic.eot b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300italic.eot similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300italic.eot rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300italic.eot diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300italic.svg b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300italic.svg similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300italic.svg rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300italic.svg diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300italic.ttf b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300italic.ttf similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300italic.ttf rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300italic.ttf diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300italic.woff b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300italic.woff similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300italic.woff rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300italic.woff diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300italic.woff2 b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300italic.woff2 similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300italic.woff2 rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-300italic.woff2 diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600.eot b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600.eot similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600.eot rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600.eot diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600.svg b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600.svg similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600.svg rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600.svg diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600.ttf b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600.ttf similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600.ttf rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600.ttf diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600.woff b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600.woff similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600.woff rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600.woff diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600.woff2 b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600.woff2 similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600.woff2 rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600.woff2 diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600italic.eot b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600italic.eot similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600italic.eot rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600italic.eot diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600italic.svg b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600italic.svg similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600italic.svg rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600italic.svg diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600italic.ttf b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600italic.ttf similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600italic.ttf rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600italic.ttf diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600italic.woff b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600italic.woff similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600italic.woff rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600italic.woff diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600italic.woff2 b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600italic.woff2 similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600italic.woff2 rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-600italic.woff2 diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700.eot b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700.eot similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700.eot rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700.eot diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700.svg b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700.svg similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700.svg rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700.svg diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700.ttf b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700.ttf similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700.ttf rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700.ttf diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700.woff b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700.woff similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700.woff rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700.woff diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700.woff2 b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700.woff2 similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700.woff2 rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700.woff2 diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700italic.eot b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700italic.eot similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700italic.eot rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700italic.eot diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700italic.svg b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700italic.svg similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700italic.svg rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700italic.svg diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700italic.ttf b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700italic.ttf similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700italic.ttf rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700italic.ttf diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700italic.woff b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700italic.woff similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700italic.woff rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700italic.woff diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700italic.woff2 b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700italic.woff2 similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700italic.woff2 rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-700italic.woff2 diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-italic.eot b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-italic.eot similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-italic.eot rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-italic.eot diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-italic.svg b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-italic.svg similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-italic.svg rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-italic.svg diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-italic.ttf b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-italic.ttf similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-italic.ttf rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-italic.ttf diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-italic.woff b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-italic.woff similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-italic.woff rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-italic.woff diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-italic.woff2 b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-italic.woff2 similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-italic.woff2 rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-italic.woff2 diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-regular.eot b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-regular.eot similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-regular.eot rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-regular.eot diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-regular.svg b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-regular.svg similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-regular.svg rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-regular.svg diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-regular.ttf b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-regular.ttf similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-regular.ttf rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-regular.ttf diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-regular.woff b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-regular.woff similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-regular.woff rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-regular.woff diff --git a/example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-regular.woff2 b/example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-regular.woff2 similarity index 100% rename from example/satosa/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-regular.woff2 rename to example/satosa/static/bootstrap-italia/fonts/Titillium_Web/titillium-web-v10-latin-ext_latin-regular.woff2 diff --git a/example/satosa/bootstrap-italia/js/bootstrap-italia.bundle.min.js b/example/satosa/static/bootstrap-italia/js/bootstrap-italia.bundle.min.js similarity index 100% rename from example/satosa/bootstrap-italia/js/bootstrap-italia.bundle.min.js rename to example/satosa/static/bootstrap-italia/js/bootstrap-italia.bundle.min.js diff --git a/example/satosa/bootstrap-italia/js/bootstrap-italia.min.js b/example/satosa/static/bootstrap-italia/js/bootstrap-italia.min.js similarity index 100% rename from example/satosa/bootstrap-italia/js/bootstrap-italia.min.js rename to example/satosa/static/bootstrap-italia/js/bootstrap-italia.min.js diff --git a/example/satosa/bootstrap-italia/plugins/accept-overlay.js b/example/satosa/static/bootstrap-italia/plugins/accept-overlay.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/accept-overlay.js rename to example/satosa/static/bootstrap-italia/plugins/accept-overlay.js diff --git a/example/satosa/bootstrap-italia/plugins/accept-overlay.js.map b/example/satosa/static/bootstrap-italia/plugins/accept-overlay.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/accept-overlay.js.map rename to example/satosa/static/bootstrap-italia/plugins/accept-overlay.js.map diff --git a/example/satosa/bootstrap-italia/plugins/accordion.js b/example/satosa/static/bootstrap-italia/plugins/accordion.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/accordion.js rename to example/satosa/static/bootstrap-italia/plugins/accordion.js diff --git a/example/satosa/bootstrap-italia/plugins/accordion.js.map b/example/satosa/static/bootstrap-italia/plugins/accordion.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/accordion.js.map rename to example/satosa/static/bootstrap-italia/plugins/accordion.js.map diff --git a/example/satosa/bootstrap-italia/plugins/alert.js b/example/satosa/static/bootstrap-italia/plugins/alert.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/alert.js rename to example/satosa/static/bootstrap-italia/plugins/alert.js diff --git a/example/satosa/bootstrap-italia/plugins/alert.js.map b/example/satosa/static/bootstrap-italia/plugins/alert.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/alert.js.map rename to example/satosa/static/bootstrap-italia/plugins/alert.js.map diff --git a/example/satosa/bootstrap-italia/plugins/backToTop.js b/example/satosa/static/bootstrap-italia/plugins/backToTop.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/backToTop.js rename to example/satosa/static/bootstrap-italia/plugins/backToTop.js diff --git a/example/satosa/bootstrap-italia/plugins/backToTop.js.map b/example/satosa/static/bootstrap-italia/plugins/backToTop.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/backToTop.js.map rename to example/satosa/static/bootstrap-italia/plugins/backToTop.js.map diff --git a/example/satosa/bootstrap-italia/plugins/button.js b/example/satosa/static/bootstrap-italia/plugins/button.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/button.js rename to example/satosa/static/bootstrap-italia/plugins/button.js diff --git a/example/satosa/bootstrap-italia/plugins/button.js.map b/example/satosa/static/bootstrap-italia/plugins/button.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/button.js.map rename to example/satosa/static/bootstrap-italia/plugins/button.js.map diff --git a/example/satosa/bootstrap-italia/plugins/carousel-bi.js b/example/satosa/static/bootstrap-italia/plugins/carousel-bi.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/carousel-bi.js rename to example/satosa/static/bootstrap-italia/plugins/carousel-bi.js diff --git a/example/satosa/bootstrap-italia/plugins/carousel-bi.js.map b/example/satosa/static/bootstrap-italia/plugins/carousel-bi.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/carousel-bi.js.map rename to example/satosa/static/bootstrap-italia/plugins/carousel-bi.js.map diff --git a/example/satosa/bootstrap-italia/plugins/carousel.js b/example/satosa/static/bootstrap-italia/plugins/carousel.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/carousel.js rename to example/satosa/static/bootstrap-italia/plugins/carousel.js diff --git a/example/satosa/bootstrap-italia/plugins/carousel.js.map b/example/satosa/static/bootstrap-italia/plugins/carousel.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/carousel.js.map rename to example/satosa/static/bootstrap-italia/plugins/carousel.js.map diff --git a/example/satosa/bootstrap-italia/plugins/collapse.js b/example/satosa/static/bootstrap-italia/plugins/collapse.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/collapse.js rename to example/satosa/static/bootstrap-italia/plugins/collapse.js diff --git a/example/satosa/bootstrap-italia/plugins/collapse.js.map b/example/satosa/static/bootstrap-italia/plugins/collapse.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/collapse.js.map rename to example/satosa/static/bootstrap-italia/plugins/collapse.js.map diff --git a/example/satosa/bootstrap-italia/plugins/cookiebar.js b/example/satosa/static/bootstrap-italia/plugins/cookiebar.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/cookiebar.js rename to example/satosa/static/bootstrap-italia/plugins/cookiebar.js diff --git a/example/satosa/bootstrap-italia/plugins/cookiebar.js.map b/example/satosa/static/bootstrap-italia/plugins/cookiebar.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/cookiebar.js.map rename to example/satosa/static/bootstrap-italia/plugins/cookiebar.js.map diff --git a/example/satosa/bootstrap-italia/plugins/dimmer.js b/example/satosa/static/bootstrap-italia/plugins/dimmer.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/dimmer.js rename to example/satosa/static/bootstrap-italia/plugins/dimmer.js diff --git a/example/satosa/bootstrap-italia/plugins/dimmer.js.map b/example/satosa/static/bootstrap-italia/plugins/dimmer.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/dimmer.js.map rename to example/satosa/static/bootstrap-italia/plugins/dimmer.js.map diff --git a/example/satosa/bootstrap-italia/plugins/dropdown.js b/example/satosa/static/bootstrap-italia/plugins/dropdown.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/dropdown.js rename to example/satosa/static/bootstrap-italia/plugins/dropdown.js diff --git a/example/satosa/bootstrap-italia/plugins/dropdown.js.map b/example/satosa/static/bootstrap-italia/plugins/dropdown.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/dropdown.js.map rename to example/satosa/static/bootstrap-italia/plugins/dropdown.js.map diff --git a/example/satosa/bootstrap-italia/plugins/fonts-loader.js b/example/satosa/static/bootstrap-italia/plugins/fonts-loader.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/fonts-loader.js rename to example/satosa/static/bootstrap-italia/plugins/fonts-loader.js diff --git a/example/satosa/bootstrap-italia/plugins/fonts-loader.js.map b/example/satosa/static/bootstrap-italia/plugins/fonts-loader.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/fonts-loader.js.map rename to example/satosa/static/bootstrap-italia/plugins/fonts-loader.js.map diff --git a/example/satosa/bootstrap-italia/plugins/form-validate.js b/example/satosa/static/bootstrap-italia/plugins/form-validate.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/form-validate.js rename to example/satosa/static/bootstrap-italia/plugins/form-validate.js diff --git a/example/satosa/bootstrap-italia/plugins/form-validate.js.map b/example/satosa/static/bootstrap-italia/plugins/form-validate.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/form-validate.js.map rename to example/satosa/static/bootstrap-italia/plugins/form-validate.js.map diff --git a/example/satosa/bootstrap-italia/plugins/forward.js b/example/satosa/static/bootstrap-italia/plugins/forward.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/forward.js rename to example/satosa/static/bootstrap-italia/plugins/forward.js diff --git a/example/satosa/bootstrap-italia/plugins/forward.js.map b/example/satosa/static/bootstrap-italia/plugins/forward.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/forward.js.map rename to example/satosa/static/bootstrap-italia/plugins/forward.js.map diff --git a/example/satosa/bootstrap-italia/plugins/header-sticky.js b/example/satosa/static/bootstrap-italia/plugins/header-sticky.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/header-sticky.js rename to example/satosa/static/bootstrap-italia/plugins/header-sticky.js diff --git a/example/satosa/bootstrap-italia/plugins/header-sticky.js.map b/example/satosa/static/bootstrap-italia/plugins/header-sticky.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/header-sticky.js.map rename to example/satosa/static/bootstrap-italia/plugins/header-sticky.js.map diff --git a/example/satosa/bootstrap-italia/plugins/history-back.js b/example/satosa/static/bootstrap-italia/plugins/history-back.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/history-back.js rename to example/satosa/static/bootstrap-italia/plugins/history-back.js diff --git a/example/satosa/bootstrap-italia/plugins/history-back.js.map b/example/satosa/static/bootstrap-italia/plugins/history-back.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/history-back.js.map rename to example/satosa/static/bootstrap-italia/plugins/history-back.js.map diff --git a/example/satosa/bootstrap-italia/plugins/init.js b/example/satosa/static/bootstrap-italia/plugins/init.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/init.js rename to example/satosa/static/bootstrap-italia/plugins/init.js diff --git a/example/satosa/bootstrap-italia/plugins/init.js.map b/example/satosa/static/bootstrap-italia/plugins/init.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/init.js.map rename to example/satosa/static/bootstrap-italia/plugins/init.js.map diff --git a/example/satosa/bootstrap-italia/plugins/input-label.js b/example/satosa/static/bootstrap-italia/plugins/input-label.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/input-label.js rename to example/satosa/static/bootstrap-italia/plugins/input-label.js diff --git a/example/satosa/bootstrap-italia/plugins/input-label.js.map b/example/satosa/static/bootstrap-italia/plugins/input-label.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/input-label.js.map rename to example/satosa/static/bootstrap-italia/plugins/input-label.js.map diff --git a/example/satosa/bootstrap-italia/plugins/input-number.js b/example/satosa/static/bootstrap-italia/plugins/input-number.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/input-number.js rename to example/satosa/static/bootstrap-italia/plugins/input-number.js diff --git a/example/satosa/bootstrap-italia/plugins/input-number.js.map b/example/satosa/static/bootstrap-italia/plugins/input-number.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/input-number.js.map rename to example/satosa/static/bootstrap-italia/plugins/input-number.js.map diff --git a/example/satosa/bootstrap-italia/plugins/input-password.js b/example/satosa/static/bootstrap-italia/plugins/input-password.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/input-password.js rename to example/satosa/static/bootstrap-italia/plugins/input-password.js diff --git a/example/satosa/bootstrap-italia/plugins/input-password.js.map b/example/satosa/static/bootstrap-italia/plugins/input-password.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/input-password.js.map rename to example/satosa/static/bootstrap-italia/plugins/input-password.js.map diff --git a/example/satosa/bootstrap-italia/plugins/input-search-autocomplete.js b/example/satosa/static/bootstrap-italia/plugins/input-search-autocomplete.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/input-search-autocomplete.js rename to example/satosa/static/bootstrap-italia/plugins/input-search-autocomplete.js diff --git a/example/satosa/bootstrap-italia/plugins/input-search-autocomplete.js.map b/example/satosa/static/bootstrap-italia/plugins/input-search-autocomplete.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/input-search-autocomplete.js.map rename to example/satosa/static/bootstrap-italia/plugins/input-search-autocomplete.js.map diff --git a/example/satosa/bootstrap-italia/plugins/input.js b/example/satosa/static/bootstrap-italia/plugins/input.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/input.js rename to example/satosa/static/bootstrap-italia/plugins/input.js diff --git a/example/satosa/bootstrap-italia/plugins/input.js.map b/example/satosa/static/bootstrap-italia/plugins/input.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/input.js.map rename to example/satosa/static/bootstrap-italia/plugins/input.js.map diff --git a/example/satosa/bootstrap-italia/plugins/list.js b/example/satosa/static/bootstrap-italia/plugins/list.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/list.js rename to example/satosa/static/bootstrap-italia/plugins/list.js diff --git a/example/satosa/bootstrap-italia/plugins/list.js.map b/example/satosa/static/bootstrap-italia/plugins/list.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/list.js.map rename to example/satosa/static/bootstrap-italia/plugins/list.js.map diff --git a/example/satosa/bootstrap-italia/plugins/masonry.js b/example/satosa/static/bootstrap-italia/plugins/masonry.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/masonry.js rename to example/satosa/static/bootstrap-italia/plugins/masonry.js diff --git a/example/satosa/bootstrap-italia/plugins/masonry.js.map b/example/satosa/static/bootstrap-italia/plugins/masonry.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/masonry.js.map rename to example/satosa/static/bootstrap-italia/plugins/masonry.js.map diff --git a/example/satosa/bootstrap-italia/plugins/modal.js b/example/satosa/static/bootstrap-italia/plugins/modal.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/modal.js rename to example/satosa/static/bootstrap-italia/plugins/modal.js diff --git a/example/satosa/bootstrap-italia/plugins/modal.js.map b/example/satosa/static/bootstrap-italia/plugins/modal.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/modal.js.map rename to example/satosa/static/bootstrap-italia/plugins/modal.js.map diff --git a/example/satosa/bootstrap-italia/plugins/navbar-collapsible.js b/example/satosa/static/bootstrap-italia/plugins/navbar-collapsible.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/navbar-collapsible.js rename to example/satosa/static/bootstrap-italia/plugins/navbar-collapsible.js diff --git a/example/satosa/bootstrap-italia/plugins/navbar-collapsible.js.map b/example/satosa/static/bootstrap-italia/plugins/navbar-collapsible.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/navbar-collapsible.js.map rename to example/satosa/static/bootstrap-italia/plugins/navbar-collapsible.js.map diff --git a/example/satosa/bootstrap-italia/plugins/navscroll.js b/example/satosa/static/bootstrap-italia/plugins/navscroll.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/navscroll.js rename to example/satosa/static/bootstrap-italia/plugins/navscroll.js diff --git a/example/satosa/bootstrap-italia/plugins/navscroll.js.map b/example/satosa/static/bootstrap-italia/plugins/navscroll.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/navscroll.js.map rename to example/satosa/static/bootstrap-italia/plugins/navscroll.js.map diff --git a/example/satosa/bootstrap-italia/plugins/notification.js b/example/satosa/static/bootstrap-italia/plugins/notification.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/notification.js rename to example/satosa/static/bootstrap-italia/plugins/notification.js diff --git a/example/satosa/bootstrap-italia/plugins/notification.js.map b/example/satosa/static/bootstrap-italia/plugins/notification.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/notification.js.map rename to example/satosa/static/bootstrap-italia/plugins/notification.js.map diff --git a/example/satosa/bootstrap-italia/plugins/offcanvas.js b/example/satosa/static/bootstrap-italia/plugins/offcanvas.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/offcanvas.js rename to example/satosa/static/bootstrap-italia/plugins/offcanvas.js diff --git a/example/satosa/bootstrap-italia/plugins/offcanvas.js.map b/example/satosa/static/bootstrap-italia/plugins/offcanvas.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/offcanvas.js.map rename to example/satosa/static/bootstrap-italia/plugins/offcanvas.js.map diff --git a/example/satosa/bootstrap-italia/plugins/popover.js b/example/satosa/static/bootstrap-italia/plugins/popover.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/popover.js rename to example/satosa/static/bootstrap-italia/plugins/popover.js diff --git a/example/satosa/bootstrap-italia/plugins/popover.js.map b/example/satosa/static/bootstrap-italia/plugins/popover.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/popover.js.map rename to example/satosa/static/bootstrap-italia/plugins/popover.js.map diff --git a/example/satosa/bootstrap-italia/plugins/progress-donut.js b/example/satosa/static/bootstrap-italia/plugins/progress-donut.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/progress-donut.js rename to example/satosa/static/bootstrap-italia/plugins/progress-donut.js diff --git a/example/satosa/bootstrap-italia/plugins/progress-donut.js.map b/example/satosa/static/bootstrap-italia/plugins/progress-donut.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/progress-donut.js.map rename to example/satosa/static/bootstrap-italia/plugins/progress-donut.js.map diff --git a/example/satosa/bootstrap-italia/plugins/scrollspy.js b/example/satosa/static/bootstrap-italia/plugins/scrollspy.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/scrollspy.js rename to example/satosa/static/bootstrap-italia/plugins/scrollspy.js diff --git a/example/satosa/bootstrap-italia/plugins/scrollspy.js.map b/example/satosa/static/bootstrap-italia/plugins/scrollspy.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/scrollspy.js.map rename to example/satosa/static/bootstrap-italia/plugins/scrollspy.js.map diff --git a/example/satosa/bootstrap-italia/plugins/select-autocomplete.js b/example/satosa/static/bootstrap-italia/plugins/select-autocomplete.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/select-autocomplete.js rename to example/satosa/static/bootstrap-italia/plugins/select-autocomplete.js diff --git a/example/satosa/bootstrap-italia/plugins/select-autocomplete.js.map b/example/satosa/static/bootstrap-italia/plugins/select-autocomplete.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/select-autocomplete.js.map rename to example/satosa/static/bootstrap-italia/plugins/select-autocomplete.js.map diff --git a/example/satosa/bootstrap-italia/plugins/sticky.js b/example/satosa/static/bootstrap-italia/plugins/sticky.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/sticky.js rename to example/satosa/static/bootstrap-italia/plugins/sticky.js diff --git a/example/satosa/bootstrap-italia/plugins/sticky.js.map b/example/satosa/static/bootstrap-italia/plugins/sticky.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/sticky.js.map rename to example/satosa/static/bootstrap-italia/plugins/sticky.js.map diff --git a/example/satosa/bootstrap-italia/plugins/tab.js b/example/satosa/static/bootstrap-italia/plugins/tab.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/tab.js rename to example/satosa/static/bootstrap-italia/plugins/tab.js diff --git a/example/satosa/bootstrap-italia/plugins/tab.js.map b/example/satosa/static/bootstrap-italia/plugins/tab.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/tab.js.map rename to example/satosa/static/bootstrap-italia/plugins/tab.js.map diff --git a/example/satosa/bootstrap-italia/plugins/toast.js b/example/satosa/static/bootstrap-italia/plugins/toast.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/toast.js rename to example/satosa/static/bootstrap-italia/plugins/toast.js diff --git a/example/satosa/bootstrap-italia/plugins/toast.js.map b/example/satosa/static/bootstrap-italia/plugins/toast.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/toast.js.map rename to example/satosa/static/bootstrap-italia/plugins/toast.js.map diff --git a/example/satosa/bootstrap-italia/plugins/tooltip.js b/example/satosa/static/bootstrap-italia/plugins/tooltip.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/tooltip.js rename to example/satosa/static/bootstrap-italia/plugins/tooltip.js diff --git a/example/satosa/bootstrap-italia/plugins/tooltip.js.map b/example/satosa/static/bootstrap-italia/plugins/tooltip.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/tooltip.js.map rename to example/satosa/static/bootstrap-italia/plugins/tooltip.js.map diff --git a/example/satosa/bootstrap-italia/plugins/track-focus.js b/example/satosa/static/bootstrap-italia/plugins/track-focus.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/track-focus.js rename to example/satosa/static/bootstrap-italia/plugins/track-focus.js diff --git a/example/satosa/bootstrap-italia/plugins/track-focus.js.map b/example/satosa/static/bootstrap-italia/plugins/track-focus.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/track-focus.js.map rename to example/satosa/static/bootstrap-italia/plugins/track-focus.js.map diff --git a/example/satosa/bootstrap-italia/plugins/transfer.js b/example/satosa/static/bootstrap-italia/plugins/transfer.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/transfer.js rename to example/satosa/static/bootstrap-italia/plugins/transfer.js diff --git a/example/satosa/bootstrap-italia/plugins/transfer.js.map b/example/satosa/static/bootstrap-italia/plugins/transfer.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/transfer.js.map rename to example/satosa/static/bootstrap-italia/plugins/transfer.js.map diff --git a/example/satosa/bootstrap-italia/plugins/upload-dragdrop.js b/example/satosa/static/bootstrap-italia/plugins/upload-dragdrop.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/upload-dragdrop.js rename to example/satosa/static/bootstrap-italia/plugins/upload-dragdrop.js diff --git a/example/satosa/bootstrap-italia/plugins/upload-dragdrop.js.map b/example/satosa/static/bootstrap-italia/plugins/upload-dragdrop.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/upload-dragdrop.js.map rename to example/satosa/static/bootstrap-italia/plugins/upload-dragdrop.js.map diff --git a/example/satosa/bootstrap-italia/plugins/util/cookies.js b/example/satosa/static/bootstrap-italia/plugins/util/cookies.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/util/cookies.js rename to example/satosa/static/bootstrap-italia/plugins/util/cookies.js diff --git a/example/satosa/bootstrap-italia/plugins/util/cookies.js.map b/example/satosa/static/bootstrap-italia/plugins/util/cookies.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/util/cookies.js.map rename to example/satosa/static/bootstrap-italia/plugins/util/cookies.js.map diff --git a/example/satosa/bootstrap-italia/plugins/util/device.js b/example/satosa/static/bootstrap-italia/plugins/util/device.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/util/device.js rename to example/satosa/static/bootstrap-italia/plugins/util/device.js diff --git a/example/satosa/bootstrap-italia/plugins/util/device.js.map b/example/satosa/static/bootstrap-italia/plugins/util/device.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/util/device.js.map rename to example/satosa/static/bootstrap-italia/plugins/util/device.js.map diff --git a/example/satosa/bootstrap-italia/plugins/util/dom.js b/example/satosa/static/bootstrap-italia/plugins/util/dom.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/util/dom.js rename to example/satosa/static/bootstrap-italia/plugins/util/dom.js diff --git a/example/satosa/bootstrap-italia/plugins/util/dom.js.map b/example/satosa/static/bootstrap-italia/plugins/util/dom.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/util/dom.js.map rename to example/satosa/static/bootstrap-italia/plugins/util/dom.js.map diff --git a/example/satosa/bootstrap-italia/plugins/util/observer.js b/example/satosa/static/bootstrap-italia/plugins/util/observer.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/util/observer.js rename to example/satosa/static/bootstrap-italia/plugins/util/observer.js diff --git a/example/satosa/bootstrap-italia/plugins/util/observer.js.map b/example/satosa/static/bootstrap-italia/plugins/util/observer.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/util/observer.js.map rename to example/satosa/static/bootstrap-italia/plugins/util/observer.js.map diff --git a/example/satosa/bootstrap-italia/plugins/util/on-document-scroll.js b/example/satosa/static/bootstrap-italia/plugins/util/on-document-scroll.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/util/on-document-scroll.js rename to example/satosa/static/bootstrap-italia/plugins/util/on-document-scroll.js diff --git a/example/satosa/bootstrap-italia/plugins/util/on-document-scroll.js.map b/example/satosa/static/bootstrap-italia/plugins/util/on-document-scroll.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/util/on-document-scroll.js.map rename to example/satosa/static/bootstrap-italia/plugins/util/on-document-scroll.js.map diff --git a/example/satosa/bootstrap-italia/plugins/util/pageScroll.js b/example/satosa/static/bootstrap-italia/plugins/util/pageScroll.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/util/pageScroll.js rename to example/satosa/static/bootstrap-italia/plugins/util/pageScroll.js diff --git a/example/satosa/bootstrap-italia/plugins/util/pageScroll.js.map b/example/satosa/static/bootstrap-italia/plugins/util/pageScroll.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/util/pageScroll.js.map rename to example/satosa/static/bootstrap-italia/plugins/util/pageScroll.js.map diff --git a/example/satosa/bootstrap-italia/plugins/util/tween.js b/example/satosa/static/bootstrap-italia/plugins/util/tween.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/util/tween.js rename to example/satosa/static/bootstrap-italia/plugins/util/tween.js diff --git a/example/satosa/bootstrap-italia/plugins/util/tween.js.map b/example/satosa/static/bootstrap-italia/plugins/util/tween.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/util/tween.js.map rename to example/satosa/static/bootstrap-italia/plugins/util/tween.js.map diff --git a/example/satosa/bootstrap-italia/plugins/videoplayer.js b/example/satosa/static/bootstrap-italia/plugins/videoplayer.js similarity index 100% rename from example/satosa/bootstrap-italia/plugins/videoplayer.js rename to example/satosa/static/bootstrap-italia/plugins/videoplayer.js diff --git a/example/satosa/bootstrap-italia/plugins/videoplayer.js.map b/example/satosa/static/bootstrap-italia/plugins/videoplayer.js.map similarity index 100% rename from example/satosa/bootstrap-italia/plugins/videoplayer.js.map rename to example/satosa/static/bootstrap-italia/plugins/videoplayer.js.map diff --git a/example/satosa/bootstrap-italia/svg/sprites.svg b/example/satosa/static/bootstrap-italia/svg/sprites.svg similarity index 100% rename from example/satosa/bootstrap-italia/svg/sprites.svg rename to example/satosa/static/bootstrap-italia/svg/sprites.svg diff --git a/example/satosa/bootstrap-italia/version.js b/example/satosa/static/bootstrap-italia/version.js similarity index 100% rename from example/satosa/bootstrap-italia/version.js rename to example/satosa/static/bootstrap-italia/version.js diff --git a/example/satosa/bootstrap-italia/version.js.map b/example/satosa/static/bootstrap-italia/version.js.map similarity index 100% rename from example/satosa/bootstrap-italia/version.js.map rename to example/satosa/static/bootstrap-italia/version.js.map diff --git a/example/satosa/cie/cie_black.svg b/example/satosa/static/cie/cie_black.svg similarity index 100% rename from example/satosa/cie/cie_black.svg rename to example/satosa/static/cie/cie_black.svg diff --git a/example/satosa/cie/cie_blue.svg b/example/satosa/static/cie/cie_blue.svg similarity index 100% rename from example/satosa/cie/cie_blue.svg rename to example/satosa/static/cie/cie_blue.svg diff --git a/example/satosa/cie/cie_white.svg b/example/satosa/static/cie/cie_white.svg similarity index 100% rename from example/satosa/cie/cie_white.svg rename to example/satosa/static/cie/cie_white.svg diff --git a/example/satosa/css/style.css b/example/satosa/static/css/style.css similarity index 90% rename from example/satosa/css/style.css rename to example/satosa/static/css/style.css index cda99692..76800cad 100644 --- a/example/satosa/css/style.css +++ b/example/satosa/static/css/style.css @@ -283,4 +283,113 @@ .buttonicon{ margin-right: 0.5em; - } \ No newline at end of file + } + +.qr-code-box{ + padding: 2em; +} + +.qr-code-title{ + font-size: 30px; + font-weight: 600; +} + +.it-wallet-logo{ + height: 2em; + width: 100px !important; + width: fit-content; + } + + .title-wallet{ + color:#0065cc; + } + + .flex-container{ + display: flex; + align-items: center; + } + + .border-md-bottom-title{ + margin-bottom: 1em; + padding-bottom: 1em; + + } + + .main-title{ + text-align:center; + background-color: aqua; + } + + .text-helper{ + margin-top:2em; + margin-bottom:1em; + } + +#qrcodeImage{ + width: 12em; + height: 12em; +} + + /* SPINNER */ + + .fullscreen-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(232, 232, 232, 0.5); + display: flex; + justify-content: center; + align-items: center; +} + +/* iframe qrcode page */ +#iframeContainer { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); +} + +#innerBox { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 35em; + height: 35em; +} + +#inneriframeContainer{ + width: 100%; + height: 100%; +} + +#inneriframeContainer iframe { + width: 100%; + height: 100%; +} + +#closeButton { + position: absolute; + top: 10px; + right: 10px; + font-size: 20px; + background-color: rgba(0, 0, 0, 0); + border: none; + color: black; + cursor: pointer; + z-index: 1; +} + +#closeButton svg { + fill: black; +} + +.qr-code-text{ + font-size: 15px; +} diff --git a/example/satosa/eidas/css/eidas-sp-access-button.css b/example/satosa/static/eidas/css/eidas-sp-access-button.css similarity index 100% rename from example/satosa/eidas/css/eidas-sp-access-button.css rename to example/satosa/static/eidas/css/eidas-sp-access-button.css diff --git a/example/satosa/eidas/css/eidas-sp-access-button.min.css b/example/satosa/static/eidas/css/eidas-sp-access-button.min.css similarity index 100% rename from example/satosa/eidas/css/eidas-sp-access-button.min.css rename to example/satosa/static/eidas/css/eidas-sp-access-button.min.css diff --git a/example/satosa/eidas/img/ficep-it-eidas-bn.svg b/example/satosa/static/eidas/img/ficep-it-eidas-bn.svg similarity index 100% rename from example/satosa/eidas/img/ficep-it-eidas-bn.svg rename to example/satosa/static/eidas/img/ficep-it-eidas-bn.svg diff --git a/example/satosa/eidas/img/ficep-it-eidas-db.png b/example/satosa/static/eidas/img/ficep-it-eidas-db.png similarity index 100% rename from example/satosa/eidas/img/ficep-it-eidas-db.png rename to example/satosa/static/eidas/img/ficep-it-eidas-db.png diff --git a/example/satosa/eidas/img/ficep-it-eidas-db.svg b/example/satosa/static/eidas/img/ficep-it-eidas-db.svg similarity index 100% rename from example/satosa/eidas/img/ficep-it-eidas-db.svg rename to example/satosa/static/eidas/img/ficep-it-eidas-db.svg diff --git a/example/satosa/eidas/img/ficep-it-eidas-lb.png b/example/satosa/static/eidas/img/ficep-it-eidas-lb.png similarity index 100% rename from example/satosa/eidas/img/ficep-it-eidas-lb.png rename to example/satosa/static/eidas/img/ficep-it-eidas-lb.png diff --git a/example/satosa/eidas/img/ficep-it-eidas-lb.svg b/example/satosa/static/eidas/img/ficep-it-eidas-lb.svg similarity index 100% rename from example/satosa/eidas/img/ficep-it-eidas-lb.svg rename to example/satosa/static/eidas/img/ficep-it-eidas-lb.svg diff --git a/example/satosa/eidas/img/ficep-it-eidas-ybw.png b/example/satosa/static/eidas/img/ficep-it-eidas-ybw.png similarity index 100% rename from example/satosa/eidas/img/ficep-it-eidas-ybw.png rename to example/satosa/static/eidas/img/ficep-it-eidas-ybw.png diff --git a/example/satosa/eidas/img/ficep-it-eidas-ybw.svg b/example/satosa/static/eidas/img/ficep-it-eidas-ybw.svg similarity index 100% rename from example/satosa/eidas/img/ficep-it-eidas-ybw.svg rename to example/satosa/static/eidas/img/ficep-it-eidas-ybw.svg diff --git a/example/satosa/eidas/img/ficep-it-eidas-ywb.png b/example/satosa/static/eidas/img/ficep-it-eidas-ywb.png similarity index 100% rename from example/satosa/eidas/img/ficep-it-eidas-ywb.png rename to example/satosa/static/eidas/img/ficep-it-eidas-ywb.png diff --git a/example/satosa/eidas/img/ficep-it-eidas-ywb.svg b/example/satosa/static/eidas/img/ficep-it-eidas-ywb.svg similarity index 100% rename from example/satosa/eidas/img/ficep-it-eidas-ywb.svg rename to example/satosa/static/eidas/img/ficep-it-eidas-ywb.svg diff --git a/example/satosa/idem/img/IDEM.svg b/example/satosa/static/idem/img/IDEM.svg similarity index 100% rename from example/satosa/idem/img/IDEM.svg rename to example/satosa/static/idem/img/IDEM.svg diff --git a/example/satosa/static/idem/img/IDEM_icona_blu.eps b/example/satosa/static/idem/img/IDEM_icona_blu.eps new file mode 100644 index 00000000..e460d773 Binary files /dev/null and b/example/satosa/static/idem/img/IDEM_icona_blu.eps differ diff --git a/example/satosa/static/idem/img/IDEM_icona_blu.png b/example/satosa/static/idem/img/IDEM_icona_blu.png new file mode 100644 index 00000000..b1022eb5 Binary files /dev/null and b/example/satosa/static/idem/img/IDEM_icona_blu.png differ diff --git a/example/satosa/static/idem/img/IDEM_icona_blu.svg b/example/satosa/static/idem/img/IDEM_icona_blu.svg new file mode 100644 index 00000000..80ebff50 --- /dev/null +++ b/example/satosa/static/idem/img/IDEM_icona_blu.svg @@ -0,0 +1,18 @@ + + + + + + diff --git a/example/satosa/static/idem/img/IDEM_logo_mono_blu.eps b/example/satosa/static/idem/img/IDEM_logo_mono_blu.eps new file mode 100644 index 00000000..7c8a1973 Binary files /dev/null and b/example/satosa/static/idem/img/IDEM_logo_mono_blu.eps differ diff --git a/example/satosa/static/idem/img/IDEM_logo_mono_blu.png b/example/satosa/static/idem/img/IDEM_logo_mono_blu.png new file mode 100644 index 00000000..261626fa Binary files /dev/null and b/example/satosa/static/idem/img/IDEM_logo_mono_blu.png differ diff --git a/example/satosa/static/idem/img/IDEM_logo_mono_blu.svg b/example/satosa/static/idem/img/IDEM_logo_mono_blu.svg new file mode 100644 index 00000000..3103b4b6 --- /dev/null +++ b/example/satosa/static/idem/img/IDEM_logo_mono_blu.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/satosa/static/js/jquery-3.7.0.min.js b/example/satosa/static/js/jquery-3.7.0.min.js new file mode 100644 index 00000000..e7e29d5b --- /dev/null +++ b/example/satosa/static/js/jquery-3.7.0.min.js @@ -0,0 +1,2 @@ +/*! jQuery v3.7.0 | (c) OpenJS Foundation and other contributors | jquery.org/license */ +!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(ie,e){"use strict";var oe=[],r=Object.getPrototypeOf,ae=oe.slice,g=oe.flat?function(e){return oe.flat.call(e)}:function(e){return oe.concat.apply([],e)},s=oe.push,se=oe.indexOf,n={},i=n.toString,ue=n.hasOwnProperty,o=ue.toString,a=o.call(Object),le={},v=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},y=function(e){return null!=e&&e===e.window},C=ie.document,u={type:!0,src:!0,nonce:!0,noModule:!0};function m(e,t,n){var r,i,o=(n=n||C).createElement("script");if(o.text=e,t)for(r in u)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function x(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[i.call(e)]||"object":typeof e}var t="3.7.0",l=/HTML$/i,ce=function(e,t){return new ce.fn.init(e,t)};function c(e){var t=!!e&&"length"in e&&e.length,n=x(e);return!v(e)&&!y(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+ge+")"+ge+"*"),x=new RegExp(ge+"|>"),j=new RegExp(g),A=new RegExp("^"+t+"$"),D={ID:new RegExp("^#("+t+")"),CLASS:new RegExp("^\\.("+t+")"),TAG:new RegExp("^("+t+"|[*])"),ATTR:new RegExp("^"+p),PSEUDO:new RegExp("^"+g),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+ge+"*(even|odd|(([+-]|)(\\d*)n|)"+ge+"*(?:([+-]|)"+ge+"*(\\d+)|))"+ge+"*\\)|)","i"),bool:new RegExp("^(?:"+f+")$","i"),needsContext:new RegExp("^"+ge+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+ge+"*((?:-\\d)?\\d*)"+ge+"*\\)|)(?=[^-]|$)","i")},N=/^(?:input|select|textarea|button)$/i,q=/^h\d$/i,L=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,H=/[+~]/,O=new RegExp("\\\\[\\da-fA-F]{1,6}"+ge+"?|\\\\([^\\r\\n\\f])","g"),P=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},R=function(){V()},M=J(function(e){return!0===e.disabled&&fe(e,"fieldset")},{dir:"parentNode",next:"legend"});try{k.apply(oe=ae.call(ye.childNodes),ye.childNodes),oe[ye.childNodes.length].nodeType}catch(e){k={apply:function(e,t){me.apply(e,ae.call(t))},call:function(e){me.apply(e,ae.call(arguments,1))}}}function I(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(V(e),e=e||T,C)){if(11!==p&&(u=L.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return k.call(n,a),n}else if(f&&(a=f.getElementById(i))&&I.contains(e,a)&&a.id===i)return k.call(n,a),n}else{if(u[2])return k.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&e.getElementsByClassName)return k.apply(n,e.getElementsByClassName(i)),n}if(!(h[t+" "]||d&&d.test(t))){if(c=t,f=e,1===p&&(x.test(t)||m.test(t))){(f=H.test(t)&&z(e.parentNode)||e)==e&&le.scope||((s=e.getAttribute("id"))?s=ce.escapeSelector(s):e.setAttribute("id",s=S)),o=(l=Y(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+Q(l[o]);c=l.join(",")}try{return k.apply(n,f.querySelectorAll(c)),n}catch(e){h(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return re(t.replace(ve,"$1"),e,n,r)}function W(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function F(e){return e[S]=!0,e}function $(e){var t=T.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function B(t){return function(e){return fe(e,"input")&&e.type===t}}function _(t){return function(e){return(fe(e,"input")||fe(e,"button"))&&e.type===t}}function X(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&M(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function U(a){return F(function(o){return o=+o,F(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function z(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}function V(e){var t,n=e?e.ownerDocument||e:ye;return n!=T&&9===n.nodeType&&n.documentElement&&(r=(T=n).documentElement,C=!ce.isXMLDoc(T),i=r.matches||r.webkitMatchesSelector||r.msMatchesSelector,ye!=T&&(t=T.defaultView)&&t.top!==t&&t.addEventListener("unload",R),le.getById=$(function(e){return r.appendChild(e).id=ce.expando,!T.getElementsByName||!T.getElementsByName(ce.expando).length}),le.disconnectedMatch=$(function(e){return i.call(e,"*")}),le.scope=$(function(){return T.querySelectorAll(":scope")}),le.cssHas=$(function(){try{return T.querySelector(":has(*,:jqfake)"),!1}catch(e){return!0}}),le.getById?(b.filter.ID=function(e){var t=e.replace(O,P);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&C){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(O,P);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&C){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):t.querySelectorAll(e)},b.find.CLASS=function(e,t){if("undefined"!=typeof t.getElementsByClassName&&C)return t.getElementsByClassName(e)},d=[],$(function(e){var t;r.appendChild(e).innerHTML="",e.querySelectorAll("[selected]").length||d.push("\\["+ge+"*(?:value|"+f+")"),e.querySelectorAll("[id~="+S+"-]").length||d.push("~="),e.querySelectorAll("a#"+S+"+*").length||d.push(".#.+[+~]"),e.querySelectorAll(":checked").length||d.push(":checked"),(t=T.createElement("input")).setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),r.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&d.push(":enabled",":disabled"),(t=T.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||d.push("\\["+ge+"*name"+ge+"*="+ge+"*(?:''|\"\")")}),le.cssHas||d.push(":has"),d=d.length&&new RegExp(d.join("|")),l=function(e,t){if(e===t)return a=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!le.sortDetached&&t.compareDocumentPosition(e)===n?e===T||e.ownerDocument==ye&&I.contains(ye,e)?-1:t===T||t.ownerDocument==ye&&I.contains(ye,t)?1:o?se.call(o,e)-se.call(o,t):0:4&n?-1:1)}),T}for(e in I.matches=function(e,t){return I(e,null,null,t)},I.matchesSelector=function(e,t){if(V(e),C&&!h[t+" "]&&(!d||!d.test(t)))try{var n=i.call(e,t);if(n||le.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){h(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(O,P),e[3]=(e[3]||e[4]||e[5]||"").replace(O,P),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||I.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&I.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return D.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&j.test(n)&&(t=Y(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(O,P).toLowerCase();return"*"===e?function(){return!0}:function(e){return fe(e,t)}},CLASS:function(e){var t=s[e+" "];return t||(t=new RegExp("(^|"+ge+")"+e+"("+ge+"|$)"))&&s(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=I.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function T(e,n,r){return v(n)?ce.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?ce.grep(e,function(e){return e===n!==r}):"string"!=typeof n?ce.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(ce.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||k,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:S.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof ce?t[0]:t,ce.merge(this,ce.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:C,!0)),w.test(r[1])&&ce.isPlainObject(t))for(r in t)v(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=C.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):v(e)?void 0!==n.ready?n.ready(e):e(ce):ce.makeArray(e,this)}).prototype=ce.fn,k=ce(C);var E=/^(?:parents|prev(?:Until|All))/,j={children:!0,contents:!0,next:!0,prev:!0};function A(e,t){while((e=e[t])&&1!==e.nodeType);return e}ce.fn.extend({has:function(e){var t=ce(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,Ce=/^$|^module$|\/(?:java|ecma)script/i;xe=C.createDocumentFragment().appendChild(C.createElement("div")),(be=C.createElement("input")).setAttribute("type","radio"),be.setAttribute("checked","checked"),be.setAttribute("name","t"),xe.appendChild(be),le.checkClone=xe.cloneNode(!0).cloneNode(!0).lastChild.checked,xe.innerHTML="",le.noCloneChecked=!!xe.cloneNode(!0).lastChild.defaultValue,xe.innerHTML="",le.option=!!xe.lastChild;var ke={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function Se(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&fe(e,t)?ce.merge([e],n):n}function Ee(e,t){for(var n=0,r=e.length;n",""]);var je=/<|&#?\w+;/;function Ae(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function Me(e,t){return fe(e,"table")&&fe(11!==t.nodeType?t:t.firstChild,"tr")&&ce(e).children("tbody")[0]||e}function Ie(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function We(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Fe(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(_.hasData(e)&&(s=_.get(e).events))for(i in _.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),C.head.appendChild(r[0])},abort:function(){i&&i()}}});var Jt,Kt=[],Zt=/(=)\?(?=&|$)|\?\?/;ce.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Kt.pop()||ce.expando+"_"+jt.guid++;return this[e]=!0,e}}),ce.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Zt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Zt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=v(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Zt,"$1"+r):!1!==e.jsonp&&(e.url+=(At.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||ce.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=ie[r],ie[r]=function(){o=arguments},n.always(function(){void 0===i?ce(ie).removeProp(r):ie[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Kt.push(r)),o&&v(i)&&i(o[0]),o=i=void 0}),"script"}),le.createHTMLDocument=((Jt=C.implementation.createHTMLDocument("").body).innerHTML="
",2===Jt.childNodes.length),ce.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(le.createHTMLDocument?((r=(t=C.implementation.createHTMLDocument("")).createElement("base")).href=C.location.href,t.head.appendChild(r)):t=C),o=!n&&[],(i=w.exec(e))?[t.createElement(i[1])]:(i=Ae([e],t,o),o&&o.length&&ce(o).remove(),ce.merge([],i.childNodes)));var r,i,o},ce.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(ce.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},ce.expr.pseudos.animated=function(t){return ce.grep(ce.timers,function(e){return t===e.elem}).length},ce.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=ce.css(e,"position"),c=ce(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=ce.css(e,"top"),u=ce.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),v(t)&&(t=t.call(e,n,ce.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},ce.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){ce.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===ce.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===ce.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=ce(e).offset()).top+=ce.css(e,"borderTopWidth",!0),i.left+=ce.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-ce.css(r,"marginTop",!0),left:t.left-i.left-ce.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===ce.css(e,"position"))e=e.offsetParent;return e||J})}}),ce.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;ce.fn[t]=function(e){return R(this,function(e,t,n){var r;if(y(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),ce.each(["top","left"],function(e,n){ce.cssHooks[n]=Ye(le.pixelPosition,function(e,t){if(t)return t=Ge(e,n),_e.test(t)?ce(e).position()[n]+"px":t})}),ce.each({Height:"height",Width:"width"},function(a,s){ce.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){ce.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return R(this,function(e,t,n){var r;return y(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?ce.css(e,t,i):ce.style(e,t,n,i)},s,n?e:void 0,n)}})}),ce.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){ce.fn[t]=function(e){return this.on(t,e)}}),ce.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),ce.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){ce.fn[n]=function(e,t){return 0 Math.random() - 0.5) // ** Values ** diff --git a/example/satosa/spid/spid-sp-access-button.css b/example/satosa/static/spid/spid-sp-access-button.css similarity index 100% rename from example/satosa/spid/spid-sp-access-button.css rename to example/satosa/static/spid/spid-sp-access-button.css diff --git a/example/satosa/spid/spid-sp-access-button.js b/example/satosa/static/spid/spid-sp-access-button.js similarity index 100% rename from example/satosa/spid/spid-sp-access-button.js rename to example/satosa/static/spid/spid-sp-access-button.js diff --git a/example/satosa/spid/spid_button.js b/example/satosa/static/spid/spid_button.js similarity index 100% rename from example/satosa/spid/spid_button.js rename to example/satosa/static/spid/spid_button.js diff --git a/example/satosa/spid/spid_icon.svg b/example/satosa/static/spid/spid_icon.svg similarity index 100% rename from example/satosa/spid/spid_icon.svg rename to example/satosa/static/spid/spid_icon.svg diff --git a/example/satosa/spid/svg/sprite.svg b/example/satosa/static/spid/svg/sprite.svg similarity index 100% rename from example/satosa/spid/svg/sprite.svg rename to example/satosa/static/spid/svg/sprite.svg diff --git a/example/satosa/static/wallet-it/wallet_icon.svg b/example/satosa/static/wallet-it/wallet_icon.svg new file mode 100644 index 00000000..957fe49c --- /dev/null +++ b/example/satosa/static/wallet-it/wallet_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/example/satosa/templates/base.html b/example/satosa/templates/base.html index e26062eb..27177dbb 100644 --- a/example/satosa/templates/base.html +++ b/example/satosa/templates/base.html @@ -5,10 +5,18 @@ {% block title %}{{ title }}{% endblock title %} + + + + + + + -

{{ test_name }} Results

+

{{ title }}

{% block body %}{% endblock %} + diff --git a/example/satosa/templates/qr_code.html b/example/satosa/templates/qr_code.html index 6be0d151..be220f08 100644 --- a/example/satosa/templates/qr_code.html +++ b/example/satosa/templates/qr_code.html @@ -2,9 +2,143 @@ {% block body %} +
+
+
+

Inquadra il QR code

+
+
+
+
Inquadra il QR code con la fotocamera del tuo smartphone, oppure utilizza la funzionalità "inquadra" disponibile nell'app IO.
+
+
+
+ +
+

Il codice è valido per secondi

+ +
+
+
+
+ +
+
+
+
+
-

Inquadra il qr code con il tuo smartphone

+ {% endblock body %} diff --git a/example/satosa/templates/qrcode.svg b/example/satosa/templates/qrcode.svg new file mode 100644 index 00000000..4d9be062 --- /dev/null +++ b/example/satosa/templates/qrcode.svg @@ -0,0 +1,873 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/example/satosa/wallet-it/smartphone.svg b/example/satosa/wallet-it/smartphone.svg deleted file mode 100644 index 9e96f90c..00000000 --- a/example/satosa/wallet-it/smartphone.svg +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/example/satosa/wallet-it/smartphone2.svg b/example/satosa/wallet-it/smartphone2.svg deleted file mode 100644 index 4d39910d..00000000 --- a/example/satosa/wallet-it/smartphone2.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/example/satosa/wallet-it/wallet_12.svg b/example/satosa/wallet-it/wallet_12.svg deleted file mode 100644 index e003bd87..00000000 --- a/example/satosa/wallet-it/wallet_12.svg +++ /dev/null @@ -1,11 +0,0 @@ - \ No newline at end of file diff --git a/linting.sh b/linting.sh index 2d8dfce0..ff4fc5d4 100755 --- a/linting.sh +++ b/linting.sh @@ -9,3 +9,24 @@ flake8 $SRC --count --select=E9,F63,F7,F82 --show-source --statistics flake8 $SRC --max-line-length 120 --count --statistics bandit -r -x $SRC/test* $SRC/* + +echo -e '\nHTML:' +readarray -d '' array < <(find $SRC example -name "*.html" -print0) +echo "Running linter on (${#array[@]}): " +printf '\t- %s\n' "${array[@]}" +echo "Linter output:" + +for file in "${array[@]}" +do + echo -e "\n$file:" + html_lint.py "$file" | awk -v path="file://$PWD/$file:" '$0=path$0' | sed -e 's/: /:\n\t/'; +done + +errors=0 +for file in "${array[@]}" +do + errors=$((errors + $(html_lint.py "$file" | grep -c 'Error'))) +done + +echo -e "\nHTML errors: $errors" +if [ "$errors" -gt 0 ]; then exit 1; fi; diff --git a/pyeudiw/__init__.py b/pyeudiw/__init__.py index d3ec452c..493f7415 100644 --- a/pyeudiw/__init__.py +++ b/pyeudiw/__init__.py @@ -1 +1 @@ -__version__ = "0.2.0" +__version__ = "0.3.0" diff --git a/pyeudiw/federation/__init__.py b/pyeudiw/federation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pyeudiw/federation/exceptions.py b/pyeudiw/federation/exceptions.py new file mode 100644 index 00000000..9c521737 --- /dev/null +++ b/pyeudiw/federation/exceptions.py @@ -0,0 +1,70 @@ +class HttpError(Exception): + pass + + +class TrustChainHttpError(HttpError): + pass + + +class UnknownKid(Exception): + pass + + +class MissingJwksClaim(Exception): + pass + + +class MissingAuthorityHintsClaim(Exception): + pass + + +class NotDescendant(Exception): + pass + + +class TrustAnchorNeeded(Exception): + pass + + +class MissingTrustAnchorPublicKey(Exception): + pass + + +class MetadataDiscoveryException(Exception): + pass + + +class MissingTrustMark(Exception): + pass + + +class InvalidRequiredTrustMark(Exception): + pass + + +class InvalidTrustchain(Exception): + pass + + +class TrustchainMissingMetadata(Exception): + pass + + +class InvalidEntityConfiguration(Exception): + pass + + +class InvalidEntityStatement(Exception): + pass + + +class TimeValidationError(Exception): + pass + + +class KeyValidationError(Exception): + pass + + +class InvalidChainError(Exception): + pass diff --git a/pyeudiw/federation/http_client.py b/pyeudiw/federation/http_client.py new file mode 100644 index 00000000..ebdce54c --- /dev/null +++ b/pyeudiw/federation/http_client.py @@ -0,0 +1,49 @@ +import aiohttp +import asyncio +import requests + + +async def fetch(session, url, httpc_params: dict = {}): + async with session.get(url, **httpc_params.get("connection", {})) as response: + if response.status != 200: # pragma: no cover + # response.raise_for_status() + return "" + return await response.text() + + +async def fetch_all(session, urls, httpc_params): + tasks = [] + for url in urls: + task = asyncio.create_task(fetch(session, url, httpc_params)) + tasks.append(task) + results = await asyncio.gather(*tasks) + return results + + +async def http_get(urls, httpc_params: dict = {}, sync=True): + + if sync: + res = [ + requests.get(url, **httpc_params).content # nosec - B113 + for url in urls + ] + return res + + async with aiohttp.ClientSession(**httpc_params.get("session", {})) as session: + text = await fetch_all(session, urls, httpc_params) + return text + + +if __name__ == "__main__": # pragma: no cover + httpc_params = { + "connection": {"ssl": True}, + "session": {"timeout": aiohttp.ClientTimeout(total=4)}, + } + urls = [ + "http://127.0.0.1:8001/.well-known/openid-federation", + "http://127.0.0.1:8000/.well-known/openid-federation", + "http://asdasd.it", + "http://127.0.0.1:8001/.well-known/openid-federation", + "http://google.it", + ] + asyncio.run(http_get(urls, httpc_params=httpc_params)) diff --git a/pyeudiw/federation/policy.py b/pyeudiw/federation/policy.py new file mode 100644 index 00000000..231d40c7 --- /dev/null +++ b/pyeudiw/federation/policy.py @@ -0,0 +1,357 @@ +__author__ = "Roland Hedberg" +__license__ = "Apache 2.0" +__version__ = "" + +import logging + +logger = logging.getLogger("pyeudiw.federation.policy") + + +class PolicyError(Exception): + pass + + +def combine_subset_of(s1, s2): # pragma: no cover + return list(set(s1).intersection(set(s2))) + + +def combine_superset_of(s1, s2): # pragma: no cover + return list(set(s1).intersection(set(s2))) + + +def combine_one_of(s1, s2): # pragma: no cover + return list(set(s1).intersection(set(s2))) + + +def combine_add(s1, s2): # pragma: no cover + if isinstance(s1, list): + set1 = set(s1) + else: + set1 = {s1} + if isinstance(s2, list): + set2 = set(s2) + else: + set2 = {s2} + return list(set1.union(set2)) + + +POLICY_FUNCTIONS = { + "subset_of", + "superset_of", + "one_of", + "add", + "value", + "default", + "essential", +} + +OP2FUNC = { + "subset_of": combine_subset_of, + "superset_of": combine_superset_of, + "one_of": combine_one_of, + "add": combine_add, +} + + +def do_sub_one_super_add(superior, child, policy): # pragma: no cover + if policy in superior and policy in child: + comb = OP2FUNC[policy](superior[policy], child[policy]) + if comb: + return comb + else: + raise PolicyError("Value sets doesn't overlap") + elif policy in superior: + return superior[policy] + elif policy in child: + return child[policy] + + +def do_value(superior, child, policy): # pragma: no cover + if policy in superior and policy in child: + if superior[policy] == child[policy]: + return superior[policy] + else: + raise PolicyError("Not allowed to combine values") + elif policy in superior: + return superior[policy] + elif policy in child: + return child[policy] + + +def do_default(superior, child, policy): # pragma: no cover + # A child's default can not override a superiors + if policy in superior and policy in child: + if superior["default"] == child["default"]: + return superior["default"] + else: + raise PolicyError("Not allowed to change default") + elif policy in superior: + return superior[policy] + elif policy in child: + return child[policy] + + +def do_essential(superior, child, policy): # pragma: no cover + # essential: an child can make it True if a superior has states False + # but not the other way around + + if policy in superior and policy in child: + if not superior[policy] and child["essential"]: + return True + else: + return superior[policy] + elif policy in superior: + return superior[policy] + elif policy in child: # Not in superior is the same as essential=True + return True + + +DO_POLICY = { + "superset_of": do_sub_one_super_add, + "subset_of": do_sub_one_super_add, + "one_of": do_sub_one_super_add, + "add": do_sub_one_super_add, + "value": do_value, + "default": do_default, + "essential": do_essential, +} + + +def combine_claim_policy(superior, child): # pragma: no cover + """ + Combine policy rules. + Applying the child policy can only make the combined policy more restrictive. + :param superior: Superior policy + :param child: Intermediates policy + """ + # weed out everything I don't recognize + + superior_set = set(superior).intersection(POLICY_FUNCTIONS) + child_set = set(child).intersection(POLICY_FUNCTIONS) + if "value" in superior_set: # An exact value can not be restricted. + if child_set: + if "essential" in child_set: + if len(child_set) == 1: + return {"value": superior["value"], "essential": child["essential"]} + else: + raise PolicyError( + "value can only be combined with essential, not {}".format( + child_set + ) + ) + elif "value" in child_set: + if child["value"] != superior["value"]: # Not OK + raise PolicyError( + "Child can not set another value then superior") + else: + return superior + else: + raise PolicyError( + "Not allowed combination of policies: {} + {}".format( + superior, child + ) + ) + return superior + else: + if "essential" in superior_set and "essential" in child_set: + # can only go from False to True + if ( + superior["essential"] != child["essential"] + and child["essential"] is False + ): + raise PolicyError("Essential can not go from True to False") + + comb_policy = superior_set.union(child_set) + if "one_of" in comb_policy: + if "subset_of" in comb_policy or "superset_of" in comb_policy: + raise PolicyError( + "one_of can not be combined with subset_of/superset_of" + ) + + rule = {} + for policy in comb_policy: + rule[policy] = DO_POLICY[policy](superior, child, policy) + + if comb_policy == {"superset_of", "subset_of"}: + # make sure the subset_of is a superset of superset_of. + if set(rule["superset_of"]).difference(set(rule["subset_of"])): + raise PolicyError("superset_of not a super set of subset_of") + elif comb_policy == {"superset_of", "subset_of", "default"}: + # make sure the subset_of is a superset of superset_of. + if set(rule["superset_of"]).difference(set(rule["subset_of"])): + raise PolicyError("superset_of not a super set of subset_of") + if set(rule["default"]).difference(set(rule["subset_of"])): + raise PolicyError("default not a sub set of subset_of") + if set(rule["superset_of"]).difference(set(rule["default"])): + raise PolicyError("default not a super set of subset_of") + elif comb_policy == {"subset_of", "default"}: + if set(rule["default"]).difference(set(rule["subset_of"])): + raise PolicyError("default not a sub set of subset_of") + elif comb_policy == {"superset_of", "default"}: + if set(rule["superset_of"]).difference(set(rule["default"])): + raise PolicyError("default not a super set of subset_of") + elif comb_policy == {"one_of", "default"}: + if isinstance(rule["default"], list): + if set(rule["default"]).difference(set(rule["one_of"])): + raise PolicyError("default not a super set of one_of") + else: + if {rule["default"]}.difference(set(rule["one_of"])): + raise PolicyError("default not a super set of one_of") + return rule + + +def combine_policy(superior, child): + res = {} + sup_set = set(superior.keys()) + chi_set = set(child.keys()) + + for claim in set(sup_set).intersection(chi_set): + res[claim] = combine_claim_policy(superior[claim], child[claim]) + + for claim in sup_set.difference(chi_set): + res[claim] = superior[claim] + + for claim in chi_set.difference(sup_set): + res[claim] = child[claim] + + return res + + +def gather_policies(chain, entity_type): + """ + Gather and combine all the metadata policies that are defined in the trust chain + :param chain: A list of Entity Statements + :return: The combined metadata policy + """ + + try: + combined_policy = chain[0]["metadata_policy"][entity_type] + except KeyError: + combined_policy = {} + + for es in chain[1:]: + try: + child = es["metadata_policy"][entity_type] + except KeyError: + pass + else: + combined_policy = combine_policy(combined_policy, child) + + return combined_policy + + +def union(val1, val2): + if isinstance(val1, list): + base = set(val1) + else: + base = {val1} + + if isinstance(val2, list): + ext = set(val2) + else: + ext = {val2} + return base.union(ext) + + +def apply_policy(metadata, policy): + """ + Apply a metadata policy to a metadata statement. + The order is value, add, default and then the checks subset_of/superset_of and one_of + :param metadata: A metadata statement + :param policy: A metadata policy + :return: A metadata statement that adheres to a metadata policy + """ + metadata_set = set(metadata.keys()) + policy_set = set(policy.keys()) + + # Metadata claims that there exists a policy for + for claim in metadata_set.intersection(policy_set): + if "value" in policy[claim]: # value overrides everything + metadata[claim] = policy[claim]["value"] + else: + if "one_of" in policy[claim]: + # The is for claims that can have only one value + if isinstance(metadata[claim], list): # Should not be but ... + _claim = [ + c for c in metadata[claim] if c in policy[claim]["one_of"] + ] + if _claim: + metadata[claim] = _claim[0] + else: + raise PolicyError( + "{}: None of {} among {}".format( + claim, metadata[claim], policy[claim]["one_of"] + ) + ) + else: + if metadata[claim] in policy[claim]["one_of"]: + pass + else: + raise PolicyError( + "{} not among {}".format( + metadata[claim], policy[claim]["one_of"] + ) + ) + else: + # The following is for claims that can have lists of values + if "add" in policy[claim]: + metadata[claim] = list( + union(metadata[claim], policy[claim]["add"])) + + if "subset_of" in policy[claim]: + _val = set(policy[claim]["subset_of"]).intersection( + set(metadata[claim]) + ) + if _val: + metadata[claim] = list(_val) + else: + raise PolicyError( + "{} not subset of {}".format( + metadata[claim], policy[claim]["subset_of"] + ) + ) + if "superset_of" in policy[claim]: + if set(policy[claim]["superset_of"]).difference( + set(metadata[claim]) + ): + raise PolicyError( + "{} not superset of {}".format( + metadata[claim], policy[claim]["superset_of"] + ) + ) + else: + pass + + # In policy but not in metadata + for claim in policy_set.difference(metadata_set): + if "value" in policy[claim]: + metadata[claim] = policy[claim]["value"] + elif "add" in policy[claim]: + metadata[claim] = policy[claim]["add"] + elif "default" in policy[claim]: + metadata[claim] = policy[claim]["default"] + + if claim not in metadata: + if "essential" in policy[claim] and policy[claim]["essential"]: + raise PolicyError("Essential claim '{}' missing".format(claim)) + + # All that are in metadata but not in policy should just remain + + return metadata + + +def diff2policy(new, old): + res = {} + for claim in set(new).intersection(set(old)): + if new[claim] == old[claim]: + continue + else: + res[claim] = {"value": new[claim]} + + for claim in set(new).difference(set(old)): + if claim in ["contacts"]: + res[claim] = {"add": new[claim]} + else: + res[claim] = {"value": new[claim]} + + return res diff --git a/pyeudiw/federation/schema.py b/pyeudiw/federation/schema.py new file mode 100644 index 00000000..3a584c93 --- /dev/null +++ b/pyeudiw/federation/schema.py @@ -0,0 +1,29 @@ +from typing import Optional + +from pydantic import BaseModel + + +class ESSchema(BaseModel, extra='forbid'): + exp: int + iat: int + iss: str + sub: str + jwks: dict + source_endpoint: Optional[str] = None + + +def is_es(payload: dict) -> bool: + try: + ESSchema(**payload) + if payload["iss"] != payload["sub"]: + return True + except Exception: + return False + + +def is_ec(payload: dict) -> bool: + try: + ESSchema(**payload) + return False + except Exception: + return True diff --git a/pyeudiw/federation/schemas/__init__.py b/pyeudiw/federation/schemas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pyeudiw/federation/schemas/entity_configuration.py b/pyeudiw/federation/schemas/entity_configuration.py new file mode 100644 index 00000000..4dcdf1cd --- /dev/null +++ b/pyeudiw/federation/schemas/entity_configuration.py @@ -0,0 +1,35 @@ +from typing import List, Literal + +from pydantic import BaseModel, HttpUrl, field_validator +from pydantic_core.core_schema import FieldValidationInfo + +from pyeudiw.federation.schemas.federation_entity import FederationEntity +from pyeudiw.federation.schemas.wallet_relying_party import WalletRelyingParty +from pyeudiw.jwk.schema import JwksSchema +from pyeudiw.tools.schema_utils import check_algorithm + + +class EntityConfigurationHeader(BaseModel): + alg: str + kid: str + typ: Literal["entity-statement+jwt"] + + @field_validator("alg") + @classmethod + def _check_alg(cls, alg, info: FieldValidationInfo): + return check_algorithm(alg, info) + + +class EntityConfigurationMetadataSchema(BaseModel): + wallet_relying_party: WalletRelyingParty + federation_entity: FederationEntity + + +class EntityConfigurationPayload(BaseModel): + iat: int + exp: int + iss: HttpUrl + sub: HttpUrl + jwks: JwksSchema + metadata: EntityConfigurationMetadataSchema + authority_hints: List[HttpUrl] diff --git a/pyeudiw/federation/schemas/federation_entity.py b/pyeudiw/federation/schemas/federation_entity.py new file mode 100644 index 00000000..b37985e0 --- /dev/null +++ b/pyeudiw/federation/schemas/federation_entity.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel, HttpUrl + + +class FederationEntity(BaseModel): + organization_name: str + homepage_uri: HttpUrl + policy_uri: HttpUrl + logo_uri: HttpUrl + contacts: list[str] diff --git a/pyeudiw/federation/schemas/wallet_relying_party.py b/pyeudiw/federation/schemas/wallet_relying_party.py new file mode 100644 index 00000000..b84578c5 --- /dev/null +++ b/pyeudiw/federation/schemas/wallet_relying_party.py @@ -0,0 +1,97 @@ +from typing import Any, Dict, List + +from pydantic import BaseModel, HttpUrl, field_validator +from pydantic_core.core_schema import FieldValidationInfo + +from pyeudiw.jwk.schema import JwksSchema + +_default_algorithms = { + "authorization_signed_response_alg": [ + "RS256", + "ES256" + ], + "authorization_encrypted_response_alg": [ + "RSA-OAEP", + "RSA-OAEP-256", + ], + "authorization_encrypted_response_enc": [ + "A128CBC-HS256", + "A192CBC-HS384", + "A256CBC-HS512", + "A128GCM", + "A192GCM", + "A256GCM", + ], + "id_token_signed_response_alg": [ + "RS256", + "ES256" + ], + "id_token_encrypted_response_alg": [ + "RSA-OAEP", + "RSA-OAEP-256", + ], + "id_token_encrypted_response_enc": [ + "A128CBC-HS256", + "A192CBC-HS384", + "A256CBC-HS512", + "A128GCM", + "A192GCM", + "A256GCM", + ] +} + + +class WalletRelyingParty(BaseModel): + application_type: str + client_id: HttpUrl + client_name: str + jwks: JwksSchema + contacts: List[str] + request_uris: List[HttpUrl] + redirect_uris: List[HttpUrl] + default_acr_values: List[HttpUrl] + vp_formats: Dict[str, Dict[str, List[str]]] + presentation_definitions: List[Any] + default_max_age: int + authorization_signed_response_alg: List[str] + authorization_encrypted_response_alg: List[str] + authorization_encrypted_response_enc: List[str] + subject_type: str + require_auth_time: bool + id_token_signed_response_alg: List[str] + id_token_encrypted_response_alg: List[str] + id_token_encrypted_response_enc: List[str] + + @classmethod + def _get_algorithms_supported(cls, name: str, info: FieldValidationInfo) -> list[str]: + if not info.context: + return _default_algorithms[name] + return info.context.get(name, _default_algorithms[name]) + + @classmethod + def _check_algorithms(cls, algorithms: list[str], name: str, info: FieldValidationInfo): + supported_algorithms = WalletRelyingParty._get_algorithms_supported( + name, info) + for alg in algorithms: + if alg not in supported_algorithms: + raise ValueError( + f"Unsupported algorithm: {alg} for {name}. " + f"Supported algorithms: {supported_algorithms}." + ) + return algorithms + + @field_validator( + "authorization_signed_response_alg", + "authorization_encrypted_response_alg", + "authorization_encrypted_response_enc", + "id_token_signed_response_alg", + "id_token_encrypted_response_alg", + "id_token_encrypted_response_enc" + ) + @classmethod + def check_alg(cls, value, info: FieldValidationInfo): + return WalletRelyingParty._check_algorithms( + value, + info.field_name, + info + ) diff --git a/pyeudiw/federation/statements.py b/pyeudiw/federation/statements.py new file mode 100644 index 00000000..e61f712e --- /dev/null +++ b/pyeudiw/federation/statements.py @@ -0,0 +1,476 @@ +from copy import deepcopy +from pyeudiw.federation.exceptions import ( + UnknownKid, + MissingJwksClaim, + MissingTrustMark, + TrustAnchorNeeded, +) +from pyeudiw.federation.http_client import http_get +from pyeudiw.jwt.utils import unpad_jwt_payload, unpad_jwt_header +from pyeudiw.jwt import JWSHelper + +import asyncio +import json +import logging +import requests + + +try: + pass +except ImportError: # pragma: no cover + pass + + +OIDCFED_FEDERATION_WELLKNOWN_URL = ".well-known/openid-federation" +logger = logging.getLogger("pyeudiw.federation") + + +def jwks_from_jwks_uri(jwks_uri: str, httpc_params: dict = {}) -> list: + return [json.loads(asyncio.run(http_get([jwks_uri], httpc_params)))] # pragma: no cover + + +def get_federation_jwks(jwt_payload: dict, httpc_params: dict = {}): + return ( + jwt_payload.get("jwks", {}).get("keys", []) + ) + + +def get_http_url(urls: list, httpc_params: dict = {}, http_async: bool = True) -> list: + if http_async: + responses = asyncio.run( + http_get(urls, httpc_params)) # pragma: no cover + else: + responses = [] + for i in urls: + res = requests.get(i, **httpc_params) # nosec - B113 + responses.append(res.content.decode()) + return responses + + +def get_entity_statements(urls: list, httpc_params: dict = {}) -> list: + """ + Fetches an entity statement/configuration + """ + if isinstance(urls, str): + urls = [urls] # pragma: no cover + for url in urls: + logger.debug(f"Starting Entity Statement Request to {url}") + return get_http_url(urls, httpc_params) + + +def get_entity_configurations(subjects: list, httpc_params: dict = {}): + if isinstance(subjects, str): + subjects = [subjects] + urls = [] + for subject in subjects: + if subject[-1] != "/": + subject = f"{subject}/" + url = f"{subject}{OIDCFED_FEDERATION_WELLKNOWN_URL}" + urls.append(url) + logger.info(f"Starting Entity Configuration Request for {url}") + return get_http_url(urls, httpc_params) + + +class TrustMark: + def __init__(self, jwt: str, httpc_params: dict = {}): + self.jwt = jwt + self.header = unpad_jwt_header(jwt) + self.payload = unpad_jwt_payload(jwt) + + self.id = self.payload["id"] + self.sub = self.payload["sub"] + self.iss = self.payload["iss"] + + self.is_valid = False + + self.issuer_entity_configuration = None + self.httpc_params = httpc_params + + def validate_by(self, ec) -> bool: + # TODO: pydantic entity configuration validation here + + if self.header.get("kid") not in ec.kids: + raise UnknownKid( # pragma: no cover + f"Trust Mark validation failed: " + f"{self.header.get('kid')} not found in {ec.jwks}" + ) + # verify signature + + jwsh = JWSHelper(ec.jwks[ec.kids.index(self.header["kid"])]) + payload = jwsh.verify(self.jwt) + self.is_valid = True + return payload + + def validate_by_its_issuer(self) -> bool: + if not self.issuer_entity_configuration: + self.issuer_entity_configuration = get_entity_configurations( + self.iss, self.httpc_params + ) + try: + ec = EntityStatement(self.issuer_entity_configuration[0]) + ec.validate_by_itself() + except UnknownKid: + logger.warning( + f"Trust Mark validation failed by its Issuer: " + f"{self.header.get('kid')} not found in " + f"{self.issuer_entity_configuration.jwks}") + return False + except Exception: + logger.warning( + f"Issuer {self.iss} of trust mark {self.id} is not valid.") + self.is_valid = False + return False + + # verify signature + jwsh = JWSHelper(ec.jwks[ec.kids.index(self.header["kid"])]) + payload = jwsh.verify(self.jwt) + self.is_valid = True + return payload + + def __repr__(self) -> str: + return f"{self.id} to {self.sub} issued by {self.iss}" + + +class EntityStatement: + """ + The self issued/signed statement of a federation entity + """ + + def __init__( + self, + jwt: str, + httpc_params: dict = {}, + filter_by_allowed_trust_marks: list = [], + trust_anchor_entity_conf=None, + trust_mark_issuers_entity_confs: dict = [], + ): + self.jwt = jwt + self.header = unpad_jwt_header(jwt) + self.payload = unpad_jwt_payload(jwt) + self.sub = self.payload["sub"] + self.iss = self.payload["iss"] + self.jwks = get_federation_jwks(self.payload, httpc_params) + if not self.jwks[0]: + _msg = f"Missing jwks in the statement for {self.sub}" + logger.error(_msg) + raise MissingJwksClaim(_msg) + + self.kids = [i.get("kid") for i in self.jwks] + self.httpc_params = httpc_params + + self.filter_by_allowed_trust_marks = filter_by_allowed_trust_marks + self.trust_anchor_entity_conf = trust_anchor_entity_conf + self.trust_mark_issuers_entity_confs = trust_mark_issuers_entity_confs + + # a dict with sup_sub : superior entity configuration + self.verified_superiors = {} + # as previous but with superiors with invalid entity configurations + self.failed_superiors = {} + + # a dict with sup_sub : entity statement issued for self + self.verified_by_superiors = {} + self.failed_by_superiors = {} + + # a dict with the paylaod of valid entity statements for each descendant subject + self.verified_descendant_statements = {} + self.failed_descendant_statements = {} + + # a dict with the RAW JWT of valid entity statements for each descendant subject + self.verified_descendant_statements_as_jwt = {} + + self.verified_trust_marks = [] + self.is_valid = False + + def validate_by_itself(self) -> bool: + """ + validates the entity configuration by it self + """ + # TODO: pydantic entity configuration validation here + if self.header.get("kid") not in self.kids: + raise UnknownKid( + f"{self.header.get('kid')} not found in {self.jwks}") # pragma: no cover + # verify signature + jwsh = JWSHelper(self.jwks[self.kids.index(self.header["kid"])]) + jwsh.verify(self.jwt) + self.is_valid = True + return True + + def validate_by_allowed_trust_marks(self) -> bool: + """ + validate the entity configuration ony if marked by a well known + trust mark, issued by a trusted issuer + """ + + if not self.trust_anchor_entity_conf: + raise TrustAnchorNeeded( + "To validate the trust marks the " + "Trust Anchor Entity Configuration " + "is needed." + ) + + if not self.filter_by_allowed_trust_marks: + return True + + if not self.payload.get("trust_marks"): + logger.warning( + f"{self.sub} doesn't have the trust marks claim " + "in its Entity Configuration" + ) + return False + + trust_marks = [] + is_valid = False + for tm in self.payload["trust_marks"]: + + if tm.get("id", None) not in self.filter_by_allowed_trust_marks: + continue + + try: + trust_mark = TrustMark(tm["trust_mark"]) + except KeyError: + logger.warning( + f"Trust Mark decoding failed on [{tm}]. " + "Missing 'trust_mark' claim in it" + ) + except Exception: + logger.warning(f"Trust Mark decoding failed on [{tm}]") + continue + else: + trust_marks.append(trust_mark) + + if not trust_marks: + raise MissingTrustMark( + "Required Trust marks are missing.") # pragma: no cover + + trust_mark_issuers_by_id = self.trust_anchor_entity_conf.payload.get( + "trust_marks_issuers", {} + ) + + # TODO : cache of issuers -> it would be better to have a proxy function + # + # required_issuer_ecs = [] + # for trust_mark in trust_marks: + # if trust_mark.iss not in [ + # i.payload.get('iss', None) + # for i in self.trust_mark_issuers_entity_confs + # ]: + # required_issuer_ecs.append(trust_mark.iss) + # TODO: snippet for CACHE + # if required_issuer_ec: + # ## fetch the issuer entity configuration and validate it + # iecs = get_entity_configurations( + # [required_issuer_ecs], self.httpc_params + # ) + # for jwt in iecs: + # try: + # ec = self.__class__(jwt, httpc_params=self.httpc_params) + # ec.validate_by_itself() + # except Exception as e: + # logger.warning( + # "Trust Marks issuer Entity Configuration " + # f"failed for {jwt}: {e}" + # ) + # continue + # self.trust_mark_issuers_entity_confs.append(ec) + + for trust_mark in trust_marks: + id_issuers = trust_mark_issuers_by_id.get(trust_mark.id, None) + if id_issuers and trust_mark.iss not in id_issuers: + is_valid = False + elif id_issuers and trust_mark.iss in id_issuers: + is_valid = trust_mark.validate_by_its_issuer() + elif not id_issuers: + is_valid = trust_mark.validate_by( + self.trust_anchor_entity_conf) + + if not trust_mark.is_valid: + is_valid = False + + if is_valid: + logger.info(f"Trust Mark {trust_mark} is valid") + self.verified_trust_marks.append(trust_mark) + else: + logger.warning(f"Trust Mark {trust_mark} is not valid") + + return is_valid + + def get_superiors( + self, + authority_hints: list = [], + max_authority_hints: int = 0, + superiors_hints: list = [], + ) -> dict: + """ + get superiors entity configurations + """ + # apply limits if defined + authority_hints = authority_hints or deepcopy( + self.payload.get("authority_hints", [])) + if ( + max_authority_hints + and authority_hints != authority_hints[:max_authority_hints] + ): + logger.warning( + f"Found {len(authority_hints)} but " + f"authority maximum hints is set to {max_authority_hints}. " + "the following authorities will be ignored: " + f"{', '.join(authority_hints[max_authority_hints:])}" + ) + authority_hints = authority_hints[:max_authority_hints] + + for sup in superiors_hints: + if sup.sub in authority_hints: + logger.info( + "Getting Cached Entity Configurations for " + f"{[i.sub for i in superiors_hints]}" + ) + authority_hints.pop(authority_hints.index(sup.sub)) + self.verified_superiors[sup.sub] = sup + + logger.debug(f"Getting Entity Configurations for {authority_hints}") + + jwts = [] + + if self.trust_anchor_entity_conf: + ta_id = self.trust_anchor_entity_conf.payload.get("sub", {}) + if ta_id in authority_hints: + jwts = [self.trust_anchor_configuration] + + if not jwts: + jwts = get_entity_configurations( + authority_hints, self.httpc_params) + + for jwt in jwts: + try: + ec = self.__class__( + jwt, + httpc_params=self.httpc_params, + trust_anchor_entity_conf=self.trust_anchor_entity_conf + ) + except Exception as e: + logger.warning(f"Get Entity Configuration for {jwt}: {e}") + continue + + if ec.validate_by_itself(): + target = self.verified_superiors + else: + target = self.failed_superiors + + target[ec.payload["sub"]] = ec + + for ahints in authority_hints: + if not self.verified_superiors.get(ahints, None): + logger.warning( + f"{ahints} is not available, missing or not valid authority hint" + ) + continue + + return self.verified_superiors + + def validate_descendant_statement(self, jwt: str) -> bool: + """ + jwt is a descendant entity statement issued by self + """ + # TODO: pydantic entity configuration validation here + header = unpad_jwt_header(jwt) + payload = unpad_jwt_payload(jwt) + + if header.get("kid") not in self.kids: + raise UnknownKid( + f"{self.header.get('kid')} not found in {self.jwks}") + # verify signature + jwsh = JWSHelper(self.jwks[self.kids.index(header["kid"])]) + payload = jwsh.verify(jwt) + + self.verified_descendant_statements[payload["sub"]] = payload + self.verified_descendant_statements_as_jwt[payload["sub"]] = jwt + return self.verified_descendant_statements + + def validate_by_superior_statement(self, jwt: str, ec): + """ + jwt is a statement issued by a superior + ec is a superior entity configuration + + this method validates self with the jwks contained in statement + of the superior + """ + is_valid = None + payload = {} + try: + payload = unpad_jwt_payload(jwt) + ec.validate_by_itself() + ec.validate_descendant_statement(jwt) + _jwks = get_federation_jwks(payload, self.httpc_params) + _kids = [i.get("kid") for i in _jwks] + + jwsh = JWSHelper(_jwks[_kids.index(self.header["kid"])]) + payload = jwsh.verify(self.jwt) + + is_valid = True + except Exception as e: + logger.warning( + f"{self.sub} failed validation with " + f"{ec.sub}'s superior statement '{payload or jwt}'. " + f"Exception: {e}" + ) + is_valid = False + + if is_valid: + target = self.verified_by_superiors + ec.verified_descendant_statements[self.sub] = payload + ec.verified_descendant_statements_as_jwt[self.sub] = jwt + target[payload["iss"]] = ec + self.is_valid = True + return self.verified_by_superiors.get(ec.sub) + else: + target = self.failed_superiors + ec.failed_descendant_statements[self.sub] = payload + self.is_valid = False + + def validate_by_superiors( + self, + superiors_entity_configurations: dict = {}, + ): # -> dict[str, EntityConfiguration]: + """ + validates the entity configuration with the entity statements + issued by its superiors + + this methods create self.verified_superiors and failed ones + and self.verified_by_superiors and failed ones + """ + for ec in superiors_entity_configurations: + if ec.sub in ec.verified_by_superiors: + # already fetched and cached + continue + + try: + # get superior fetch url + fetch_api_url = ec.payload["metadata"]["federation_entity"][ + "federation_fetch_endpoint" + ] + except KeyError: + logger.warning( + "Missing federation_fetch_endpoint in " + f"federation_entity metadata for {self.sub} by {ec.sub}." + ) + self.failed_superiors[ec.sub] = None + continue + + else: + _url = f"{fetch_api_url}?sub={self.sub}" + logger.info(f"Getting entity statements from {_url}") + jwts = get_entity_statements([_url], self.httpc_params) + # TODO - this could be different from ZERO + # to be tested and fixed when the tests will be available + jwt = jwts[0] + if jwt: + self.validate_by_superior_statement(jwt, ec) + else: + logger.error( + f"Empty response for {_url}" + ) + + return self.verified_by_superiors + + def __repr__(self) -> str: + return f"{self.sub} valid {self.is_valid}" diff --git a/pyeudiw/federation/trust_chain_builder.py b/pyeudiw/federation/trust_chain_builder.py new file mode 100644 index 00000000..1b5d0f45 --- /dev/null +++ b/pyeudiw/federation/trust_chain_builder.py @@ -0,0 +1,297 @@ +import datetime +import logging + +from collections import OrderedDict +from typing import Union + +from pyeudiw.federation.policy import apply_policy + +from .exceptions import ( + InvalidEntityStatement, + InvalidRequiredTrustMark, + MetadataDiscoveryException +) + +from .statements import ( + get_entity_configurations, + EntityStatement, +) +from pyeudiw.tools.utils import datetime_from_timestamp + + +logger = logging.getLogger("pyeudiw.federation") + + +class TrustChainBuilder: + """ + A trust walker that fetches statements and evaluate the evaluables + + max_intermediaries means how many hops are allowed to the trust anchor + max_authority_hints means how much authority_hints to follow on each hop + + required_trust_marks means all the trsut marks needed to start a metadata discovery + at least one of the required trust marks is needed to start a metadata discovery + if this param if absent the filter won't be considered. + """ + + def __init__( + self, + subject: str, + trust_anchor: str, + trust_anchor_configuration: Union[EntityStatement, None] = None, + httpc_params: dict = {}, + max_authority_hints: int = 10, + subject_configuration: EntityStatement = None, + required_trust_marks: list = [], + # TODO - prefetch cache? + # pre_fetched_entity_configurations = {}, + # pre_fetched_statements = {}, + # + **kwargs, + ) -> None: + + self.subject = subject + self.subject_configuration = subject_configuration + self.httpc_params = httpc_params + + self.trust_anchor = trust_anchor + self.trust_anchor_configuration = trust_anchor_configuration + + self.required_trust_marks = required_trust_marks + self.is_valid = False + + self.tree_of_trust = OrderedDict() + self.trust_path = [] # list of valid subjects up to trust anchor + + self.max_authority_hints = max_authority_hints + # dynamically valued + self.max_path_len = 0 + self.final_metadata: dict = {} + + self.verified_trust_marks = [] + self.exp = 0 + self._set_max_path_len() + + def apply_metadata_policy(self) -> dict: + """ + filters the trust path from subject to trust anchor + apply the metadata policies along the path and + returns the final metadata + """ + # find the path of trust + if not self.trust_path: + self.trust_path = [self.subject_configuration] + elif self.trust_path[-1].sub == self.trust_anchor_configuration.sub: + # ok trust path completed, I just have to return over all the parent calls + return + + logger.info( + f"Applying metadata policy for {self.subject} over " + f"{self.trust_anchor_configuration.sub} starting from " + f"{self.trust_path[-1]}" + ) + last_path = self.tree_of_trust[len(self.trust_path) - 1] + + path_found = False + for ec in last_path: + for sup_ec in ec.verified_by_superiors.values(): + while len(self.trust_path) - 2 < self.max_path_len: + if sup_ec.sub == self.trust_anchor_configuration.sub: + self.trust_path.append(sup_ec) + path_found = True + break + if sup_ec.verified_by_superiors: + self.trust_path.append(sup_ec) + self.apply_metadata_policy() + else: + logger.info( + f"'Cul de sac' in {sup_ec.sub} for {self.subject} " + f"to {self.trust_anchor_configuration.sub}" + ) + self.trust_path = [self.subject_configuration] + break + + # once I filtered a concrete and unique trust path I can apply the metadata policy + if path_found: + logger.info(f"Found a trust path: {self.trust_path}") + self.final_metadata = self.subject_configuration.payload.get( + "metadata", {}) + if not self.final_metadata: + logger.error( + f"Missing metadata in {self.subject_configuration.payload['metadata']}" + ) + return + + for i in range(len(self.trust_path))[::-1]: + self.trust_path[i - 1].sub + _pol = ( + self.trust_path[i] + .verified_descendant_statements.get("metadata_policy", {}) + ) + for md_type, md in _pol.items(): + if not self.final_metadata.get(md_type): + continue + self.final_metadata[md_type] = apply_policy( + self.final_metadata[md_type], _pol[md_type] + ) + + # set exp + self.set_exp() + return self.final_metadata + + @property + def exp_datetime(self) -> datetime.datetime: + if self.exp: # pragma: no cover + return datetime_from_timestamp(self.exp) + + def set_exp(self) -> int: + exps = [i.payload["exp"] for i in self.trust_path] + if exps: + self.exp = min(exps) + + def discovery(self) -> bool: + """ + return a chain of verified statements + from the lower up to the trust anchor + """ + logger.info( + f"Starting a Walk into Metadata Discovery for {self.subject}") + self.tree_of_trust[0] = [self.subject_configuration] + + ecs_history = [] + while (len(self.tree_of_trust) - 2) < self.max_path_len: + last_path_n = list(self.tree_of_trust.keys())[-1] + last_ecs = self.tree_of_trust[last_path_n] + + sup_ecs = [] + for last_ec in last_ecs: + # Metadata discovery loop prevention + if last_ec.sub in ecs_history: + logger.warning( + f"Metadata discovery loop detection for {last_ec.sub}. " + f"Already present in {ecs_history}. " + "Discovery blocked for this path." + ) + continue + + try: + superiors = last_ec.get_superiors( + max_authority_hints=self.max_authority_hints, + superiors_hints=[self.trust_anchor_configuration], + ) + validated_by = last_ec.validate_by_superiors( + superiors_entity_configurations=superiors.values() + ) + vbv = list(validated_by.values()) + sup_ecs.extend(vbv) + ecs_history.append(last_ec) + except MetadataDiscoveryException as e: + logger.exception( + f"Metadata discovery exception for {last_ec.sub}: {e}" + ) + + if sup_ecs: + self.tree_of_trust[last_path_n + 1] = sup_ecs + else: + break + + last_path = list(self.tree_of_trust.keys())[-1] + if ( + self.tree_of_trust[0][0].is_valid + and self.tree_of_trust[last_path][0].is_valid + ): + self.is_valid = True + self.apply_metadata_policy() + + return self.is_valid + + def get_trust_anchor_configuration(self) -> None: + if not isinstance(self.trust_anchor, EntityStatement): + logger.info(f"Starting Metadata Discovery for {self.subject}") + ta_jwt = get_entity_configurations( + self.trust_anchor, httpc_params=self.httpc_params + )[0] + self.trust_anchor_configuration = EntityStatement(ta_jwt) + + try: + self.trust_anchor_configuration.validate_by_itself() + except Exception as e: # pragma: no cover + _msg = ( + f"Trust Anchor Entity Configuration failed for " + f"{self.trust_anchor}: '{e}'" + ) + logger.error(_msg) + raise Exception(_msg) + + self._set_max_path_len() + + def _set_max_path_len(self): + if self.trust_anchor_configuration.payload.get("constraints", {}).get( + "max_path_length" + ): + self.max_path_len = int( + self.trust_anchor_configuration.payload["constraints"][ + "max_path_length" + ] + ) + + def get_subject_configuration(self) -> None: + if not self.subject_configuration: + try: + jwts = get_entity_configurations( + self.subject, httpc_params=self.httpc_params + ) + self.subject_configuration = EntityStatement( + jwts[0], trust_anchor_entity_conf=self.trust_anchor_configuration + ) + self.subject_configuration.validate_by_itself() + except Exception as e: + _msg = f"Entity Configuration for {self.subject} failed: {e}" + logger.error(_msg) + raise InvalidEntityStatement(_msg) + + # Trust Mark filter + if self.required_trust_marks: + sc = self.subject_configuration + sc.filter_by_allowed_trust_marks = self.required_trust_marks + + # TODO: create a proxy function that gets tm issuers ec from + # a previously populated cache + # sc.trust_mark_issuers_entity_confs = [ + # trust_mark_issuers_entity_confs + # ] + if not sc.validate_by_allowed_trust_marks(): + raise InvalidRequiredTrustMark( + "The required Trust Marks are not valid" + ) + else: + self.verified_trust_marks.extend(sc.verified_trust_marks) + + def serialize(self): + res = [] + # we keep just the leaf's and TA's EC, all the intermediates EC will be dropped + ta_ec: str = "" + for stat in self.trust_path: + if (self.subject == stat.sub == stat.iss): + res.append(stat.jwt) + elif (self.trust_anchor_configuration.sub == stat.sub == stat.iss): + ta_ec = stat.jwt + + if stat.verified_descendant_statements: + res.append( + # [dict(i) for i in stat.verified_descendant_statements.values()] + [i for i in stat.verified_descendant_statements_as_jwt.values()] + ) + if ta_ec: + res.append(ta_ec) + return res + + def start(self): + try: + # self.get_trust_anchor_configuration() + self.get_subject_configuration() + self.discovery() + except Exception as e: + self.is_valid = False + logger.error(f"{e}") + raise e diff --git a/pyeudiw/federation/trust_chain_validator.py b/pyeudiw/federation/trust_chain_validator.py new file mode 100644 index 00000000..5ef373e4 --- /dev/null +++ b/pyeudiw/federation/trust_chain_validator.py @@ -0,0 +1,204 @@ +import logging +from pyeudiw.tools.utils import iat_now +from pyeudiw.jwt import JWSHelper +from pyeudiw.jwt.utils import unpad_jwt_payload, unpad_jwt_header +from pyeudiw.federation.schema import is_es +from pyeudiw.federation.statements import ( + get_entity_configurations, + get_entity_statements +) +from pyeudiw.federation.exceptions import ( + HttpError, + MissingTrustAnchorPublicKey, + TimeValidationError, + KeyValidationError +) + +logger = logging.getLogger("pyeudiw.federation") + + +def find_jwk(kid: str, jwks: list) -> dict: + if not kid: + return {} + for jwk in jwks: + valid_jwk = jwk.get("kid", None) + if valid_jwk and kid == valid_jwk: + return jwk + + +class StaticTrustChainValidator: + def __init__( + self, + static_trust_chain: list, + trust_anchor_jwks: list, + **kwargs, + ) -> None: + + self.static_trust_chain = static_trust_chain + self.updated_trust_chain = [] + self.exp = 0 + + if not trust_anchor_jwks: + raise MissingTrustAnchorPublicKey( + f"{self.__class__.__name__} cannot " + "created without the TA public jwks" + ) + + self.trust_anchor_jwks = trust_anchor_jwks + for k, v in kwargs.items(): + setattr(self, k, v) + + def _check_expired(self, exp: int) -> bool: + return exp < iat_now() + + def _validate_keys(self, fed_jwks: list[str], st_header: dict) -> None: + current_kid = st_header["kid"] + + validation_kid = None + + for key in fed_jwks: + if key["kid"] == current_kid: + validation_kid = key + + if not validation_kid: + raise KeyValidationError(f"Kid {current_kid} not found") + + def _validate_single(self, fed_jwks: list[str], header: dict, payload: dict) -> bool: + try: + self._validate_keys(fed_jwks, header) + self._validate_exp(payload["exp"]) + except Exception as e: + logger.warning(f"Warning: {e}") + return False + + return True + + @property + def is_valid(self) -> bool: + # start from the last entity statement + rev_tc = [ + i for i in reversed(self.get_chain()) + ] + + # inspect the entity statement kid header to know which + # TA's public key to use for the validation + + last_element = rev_tc[0] + es_header = unpad_jwt_header(last_element) + es_payload = unpad_jwt_payload(last_element) + ta_jwk = find_jwk( + es_header.get("kid", None), self.trust_anchor_jwks + ) + + # Validate the last statement with ta_jwk + jwsh = JWSHelper(ta_jwk) + + if not jwsh.verify(last_element): + return False + + # then go ahead with other checks + es_exp = es_payload["exp"] + + if self._check_expired(es_exp): + raise TimeValidationError() + + fed_jwks = es_payload["jwks"]["keys"] + + # for st in rev_tc[1:]: + # validate the entire chain taking in cascade using fed_jwks + # if valid -> update fed_jwks with $st + for st in rev_tc[1:]: + st_header = unpad_jwt_header(st) + st_payload = unpad_jwt_payload(st) + jwk = find_jwk( + st_header.get("kid", None), fed_jwks + ) + + if not jwk: + return False + + jwsh = JWSHelper(jwk) + if not jwsh.verify(st): + return False + else: + fed_jwks = st_payload["jwks"]["keys"] + + return True + + def _retrieve_ec(self, iss: str, httpc_params: dict = {}) -> str: + jwt = get_entity_configurations(iss, httpc_params) + if not jwt: + raise HttpError( + f"Cannot get the Entity Configuration from {iss}") + + # is something weird these will raise their Exceptions + return jwt[0] + + def _retrieve_es(self, download_url: str, iss: str, httpc_params: dict = {}) -> str: + jwt = get_entity_statements(download_url, httpc_params) + if not jwt: + logger.warning( + f"Cannot fast refresh Entity Statement {iss}" + ) + return jwt + + def _update_st(self, st: str, httpc_params: dict = {}) -> str: + payload = unpad_jwt_payload(st) + iss = payload['iss'] + + if not is_es(payload): + # It's an entity configuration + return self._retrieve_ec(iss, httpc_params) + + # if it has the source_endpoint let's try a fast renewal + download_url: str = payload.get("source_endpoint", "") + if download_url: + jwt = self._retrieve_es(download_url, iss, httpc_params) + else: + ec = self._retrieve_ec(iss, httpc_params) + ec_data = unpad_jwt_payload(ec) + fetch_api_url = None + + try: + # get superior fetch url + fetch_api_url = ec_data["metadata"]["federation_entity"][ + "federation_fetch_endpoint" + ] + except KeyError: + logger.warning( + "Missing federation_fetch_endpoint in " + f"federation_entity metadata for {ec_data['sub']}" + ) + + jwt = self._retrieve_es(fetch_api_url, iss, httpc_params) + + return jwt + + def update(self, httpc_params: dict = {}) -> bool: + self.exp = 0 + for st in self.static_trust_chain: + jwt = self._update_st(st, httpc_params) + + exp = unpad_jwt_payload(jwt)["exp"] + + if not self.exp or self.exp > exp: + self.exp = exp + + self.updated_trust_chain.append(jwt) + + return self.is_valid + + def get_chain(self) -> list[str]: + return self.updated_trust_chain or self.static_trust_chain + + def get_exp(self) -> int: + return self.exp + + @property + def is_expired(self) -> int: + return self._check_expired(self.exp) + + def get_entityID(self) -> str: + chain = self.get_chain() + payload = unpad_jwt_payload(chain[0]) + return payload["iss"] diff --git a/pyeudiw/jwk/__init__.py b/pyeudiw/jwk/__init__.py index 9dedaeea..8f8d95dc 100644 --- a/pyeudiw/jwk/__init__.py +++ b/pyeudiw/jwk/__init__.py @@ -1,11 +1,10 @@ import json - from typing import Union -from cryptojwt.jwk.jwk import key_from_jwk_dict + +from cryptography.hazmat.primitives import serialization from cryptojwt.jwk.ec import new_ec_key +from cryptojwt.jwk.jwk import key_from_jwk_dict from cryptojwt.jwk.rsa import new_rsa_key -from cryptography.hazmat.primitives import serialization - KEY_TYPES_FUNC = dict( EC=new_ec_key, @@ -23,6 +22,7 @@ def __init__( ) -> None: kwargs = {} + self.kid = "" if key_type and not KEY_TYPES_FUNC.get(key_type, None): raise NotImplementedError(f"JWK key type {key_type} not found.") @@ -30,6 +30,8 @@ def __init__( if key: if isinstance(key, dict): self.key = key_from_jwk_dict(key) + key_type = key.get('kty', key_type) + self.kid = key.get('kid', "") else: self.key = key else: @@ -39,7 +41,7 @@ def __init__( self.thumbprint = self.key.thumbprint(hash_function=hash_func) self.jwk = self.key.to_dict() - self.jwk["kid"] = self.thumbprint.decode() + self.jwk["kid"] = self.kid or self.thumbprint.decode() self.public_key = self.key.serialize() self.public_key['kid'] = self.jwk["kid"] diff --git a/pyeudiw/jwk/schema.py b/pyeudiw/jwk/schema.py index dede8c9c..3e2acf4e 100644 --- a/pyeudiw/jwk/schema.py +++ b/pyeudiw/jwk/schema.py @@ -19,10 +19,10 @@ class JwkSchema(BaseModel): "PS384", "PS512", ] - ] - use: Optional[Literal["sig", "enc"]] - n: Optional[str] # Base64urlUInt-encoded - e: Optional[str] # Base64urlUInt-encoded + ] = None + use: Optional[Literal["sig", "enc"]] = None + n: Optional[str] = None # Base64urlUInt-encoded + e: Optional[str] = None # Base64urlUInt-encoded def check_value_for_rsa(value, name, values): if "EC" == values.get("kty") and value: @@ -34,11 +34,11 @@ def check_value_for_ec(value, name, values): @field_validator("n") def validate_n(cls, n_value, values): - cls.check_value_for_rsa(n_value, "n", values) + cls.check_value_for_rsa(n_value, "n", values.data) @field_validator("e") def validate_e(cls, e_value, values): - cls.check_value_for_rsa(e_value, "e", values) + cls.check_value_for_rsa(e_value, "e", values.data) class JwkSchemaEC(JwkSchema): @@ -48,15 +48,15 @@ class JwkSchemaEC(JwkSchema): @field_validator("x") def validate_x(cls, x_value, values): - cls.check_value_for_ec(x_value, "x", values) + cls.check_value_for_ec(x_value, "x", values.data) @field_validator("y") def validate_y(cls, y_value, values): - cls.check_value_for_ec(y_value, "y", values) + cls.check_value_for_ec(y_value, "y", values.data) @field_validator("crv") def validate_crv(cls, crv_value, values): - cls.check_value_for_ec(crv_value, "crv", values) + cls.check_value_for_ec(crv_value, "crv", values.data) class JwksSchemaEC(BaseModel): diff --git a/pyeudiw/jwt/__init__.py b/pyeudiw/jwt/__init__.py index 203ca80b..a58478b3 100644 --- a/pyeudiw/jwt/__init__.py +++ b/pyeudiw/jwt/__init__.py @@ -1,18 +1,18 @@ import binascii import json +from typing import Union import cryptojwt from cryptojwt.exception import VerificationError +from cryptojwt.jwe.jwe import factory from cryptojwt.jwe.jwe_ec import JWE_EC from cryptojwt.jwe.jwe_rsa import JWE_RSA from cryptojwt.jwk.jwk import key_from_jwk_dict -from cryptojwt.jwe.jwe import factory from cryptojwt.jws.jws import JWS as JWSec -from typing import Union from pyeudiw.jwk import JWK -from pyeudiw.jwt.utils import unpad_jwt_header from pyeudiw.jwk.exceptions import KidError +from pyeudiw.jwt.utils import unpad_jwt_header DEFAULT_HASH_FUNC = "SHA-256" @@ -21,9 +21,20 @@ "EC": "ES256" } -DEFAULT_JWS_ALG = "ES256" -DEFAULT_JWE_ALG = "RSA-OAEP" -DEFAULT_JWE_ENC = "A256CBC-HS512" +DEFAUL_SIG_ALG_MAP = { + "RSA": "RS256", + "EC": "ES256" +} + +DEFAUL_ENC_ALG_MAP = { + "RSA": "RSA-OAEP", + "EC": "ECDH-ES+A256KW" +} + +DEFAUL_ENC_ENC_MAP = { + "RSA": "A256CBC-HS512", + "EC": "A256GCM" +} class JWEHelper(): @@ -51,13 +62,17 @@ def encrypt(self, plain_dict: Union[dict, str, int, None], **kwargs) -> str: _keyobj = JWE_CLASS( _payload, - alg=DEFAULT_JWE_ALG, - enc=DEFAULT_JWE_ENC, + alg=DEFAUL_ENC_ALG_MAP[_key.kty], + enc=DEFAUL_ENC_ENC_MAP[_key.kty], kid=_key.kid, **kwargs ) - return _keyobj.encrypt(_key.public_key()) + if _key.kty == 'EC': + # TODO - TypeError: key must be bytes-like + return _keyobj.encrypt(cek=_key.public_key()) + else: + return _keyobj.encrypt(key=_key.public_key()) def decrypt(self, jwe: str) -> dict: try: @@ -65,8 +80,8 @@ def decrypt(self, jwe: str) -> dict: except (binascii.Error, Exception) as e: raise VerificationError("The JWT is not valid") - _alg = jwe_header.get("alg", DEFAULT_JWE_ALG) - _enc = jwe_header.get("enc", DEFAULT_JWE_ENC) + _alg = jwe_header.get("alg") + _enc = jwe_header.get("enc") jwe_header.get("kid") _decryptor = factory(jwe, alg=_alg, enc=_enc) diff --git a/pyeudiw/jwt/utils.py b/pyeudiw/jwt/utils.py index 245c5cd2..311a260a 100644 --- a/pyeudiw/jwt/utils.py +++ b/pyeudiw/jwt/utils.py @@ -1,5 +1,8 @@ import base64 import json +import re + +JWT_REGEXP = r"^(([-A-Za-z0-9\=_])*\.([-A-Za-z0-9\=_])*\.([-A-Za-z0-9\=_])*)$" def unpad_jwt_element(jwt: str, position: int) -> dict: @@ -29,3 +32,8 @@ def get_jwk_from_jwt(jwt: str, provider_jwks: dict) -> dict: if jwk["kid"] == kid: return jwk return {} + + +def is_jwt_format(jwt: str) -> bool: + res = re.match(JWT_REGEXP, jwt) + return bool(res) diff --git a/pyeudiw/oauth2/dpop/__init__.py b/pyeudiw/oauth2/dpop/__init__.py index b7c8b3fe..60b2b866 100644 --- a/pyeudiw/oauth2/dpop/__init__.py +++ b/pyeudiw/oauth2/dpop/__init__.py @@ -2,12 +2,12 @@ import logging import uuid -from pyeudiw.jwt import JWSHelper -from pyeudiw.jwt.utils import unpad_jwt_payload, unpad_jwt_header from pyeudiw.jwk.exceptions import KidError +from pyeudiw.jwt import JWSHelper +from pyeudiw.jwt.utils import unpad_jwt_header, unpad_jwt_payload +from pyeudiw.oauth2.dpop.schema import (DPoPTokenHeaderSchema, + DPoPTokenPayloadSchema) from pyeudiw.tools.utils import iat_now -from pyeudiw.oauth2.dpop.schema import ( - DPoPTokenHeaderSchema, DPoPTokenPayloadSchema) logger = logging.getLogger("pyeudiw.oauth2.dpop") diff --git a/pyeudiw/oauth2/dpop/schema.py b/pyeudiw/oauth2/dpop/schema.py index f0cfc185..2674fbfb 100644 --- a/pyeudiw/oauth2/dpop/schema.py +++ b/pyeudiw/oauth2/dpop/schema.py @@ -1,7 +1,7 @@ -from pydantic import BaseModel, HttpUrl - from typing import Literal +from pydantic import BaseModel, HttpUrl + class DPoPTokenHeaderSchema(BaseModel): # header @@ -17,7 +17,7 @@ class DPoPTokenHeaderSchema(BaseModel): "PS384", "PS512", ] - # TODO - dynamic schema loader if EC or RSA + # TODO - dynamic schemas loader if EC or RSA # jwk: JwkSchema diff --git a/pyeudiw/openid4vp/__init__.py b/pyeudiw/openid4vp/__init__.py index f9244884..35edf980 100644 --- a/pyeudiw/openid4vp/__init__.py +++ b/pyeudiw/openid4vp/__init__.py @@ -1,18 +1,35 @@ from typing import Tuple from pyeudiw.jwk import JWK +from pyeudiw.jwt.utils import unpad_jwt_payload, unpad_jwt_header from pyeudiw.sd_jwt import verify_sd_jwt -from pyeudiw.jwt.utils import unpad_jwt_payload +from pyeudiw.openid4vp.exceptions import KIDNotFound +from pyeudiw.openid4vp.schemas.vp_token import VPTokenPayload, VPTokenHeader -def check_vp_token(vp_token: str, config: dict, sd_specification: dict, sd_jwt: dict) -> Tuple[str | None, dict]: +def check_vp_token(vp_token: str, sd_specification: dict, jwks: list[dict], config: dict = {"no_randomness": True}) -> Tuple[str | None, dict]: payload = unpad_jwt_payload(vp_token) - holder_jwk = JWK(payload["cnf"]["jwk"]) - issuer_jwk = JWK(config["federation"]["federation_jwks"][1]) + VPTokenPayload(**payload) - result, binding = verify_sd_jwt( - vp_token, sd_specification, sd_jwt, issuer_jwk, holder_jwk) - nonce = binding.get("nonce", None) + headers = unpad_jwt_header(vp_token) + VPTokenHeader(**headers) + + kid = headers["kid"] + + vp = unpad_jwt_payload(payload["vp"]) + + issuer_jwk = jwks.get(kid, None) + + if not issuer_jwk: + raise KIDNotFound(f"kid {kid} not present") + + issuer_jwk = JWK(issuer_jwk) + holder_jwk = JWK(vp["cnf"]["jwk"]) + + result = verify_sd_jwt( + payload["vp"], sd_specification, config, issuer_jwk, holder_jwk) + + nonce = payload.get("nonce", None) claims = result["holder_disclosed_claims"] try: diff --git a/pyeudiw/openid4vp/exceptions.py b/pyeudiw/openid4vp/exceptions.py new file mode 100644 index 00000000..e1c98e62 --- /dev/null +++ b/pyeudiw/openid4vp/exceptions.py @@ -0,0 +1,4 @@ +class KIDNotFound(Exception): + """ + Raised when kid is not present in the public key dict + """ diff --git a/pyeudiw/openid4vp/schema.py b/pyeudiw/openid4vp/schema.py deleted file mode 100644 index b261f210..00000000 --- a/pyeudiw/openid4vp/schema.py +++ /dev/null @@ -1,43 +0,0 @@ -import re -from pydantic import BaseModel, ValidationError -from typing_extensions import Annotated -from pydantic.functional_validators import AfterValidator - -JWT_REGEX = r"(^[\w-]*.[\w-]*.[\w-]*~([\w-]*.[\w-]*.[\w-]*){1})" - - -def checkJWT(jwt: str) -> str: - res = re.match(JWT_REGEX, jwt) - if not res: - raise ValidationError(f"Vp_token is not a jwt {jwt}") - - return jwt - - -def checkJWTList(jwt_list: list[str]) -> list[str]: - if len(jwt_list) == 0: - raise ValidationError("vp_token is empty") - - for jwt in jwt_list: - checkJWT(jwt) - - return jwt_list - - -class DescriptorSchema(BaseModel): - id: str - path: str - format: str - - -class PresentationSubmissionSchema(BaseModel): - definition_id: str - id: str - descriptor_map: list[DescriptorSchema] - - -class ResponseSchema(BaseModel): - state: str - vp_token: Annotated[str, AfterValidator( - checkJWT)] | Annotated[list[str], AfterValidator(checkJWTList)] - presentation_submission: PresentationSubmissionSchema diff --git a/pyeudiw/openid4vp/schemas/__init__.py b/pyeudiw/openid4vp/schemas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pyeudiw/openid4vp/schemas/cnf_schema.py b/pyeudiw/openid4vp/schemas/cnf_schema.py new file mode 100644 index 00000000..2ed0f8f4 --- /dev/null +++ b/pyeudiw/openid4vp/schemas/cnf_schema.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + +from pyeudiw.jwk.schema import JwkSchema + + +class CNFSchema(BaseModel): + jwk: JwkSchema diff --git a/pyeudiw/openid4vp/schemas/response_schema.py b/pyeudiw/openid4vp/schemas/response_schema.py new file mode 100644 index 00000000..60d29e4f --- /dev/null +++ b/pyeudiw/openid4vp/schemas/response_schema.py @@ -0,0 +1,31 @@ +from typing import Optional + +from pydantic import BaseModel, field_validator + +from pyeudiw.jwt.utils import is_jwt_format + + +class DescriptorSchema(BaseModel): + id: str + path: str + format: str + + +class PresentationSubmissionSchema(BaseModel): + definition_id: str + id: str + descriptor_map: list[DescriptorSchema] + + +class ResponseSchema(BaseModel): + state: Optional[str] + vp_token: str + presentation_submission: PresentationSubmissionSchema + + @field_validator("vp_token") + @classmethod + def _check_vp_token(cls, vp_token): + if is_jwt_format(vp_token): + return vp_token + else: + raise ValueError("vp_token is not in a JWT format.") diff --git a/pyeudiw/openid4vp/schemas/vp_token.py b/pyeudiw/openid4vp/schemas/vp_token.py new file mode 100644 index 00000000..704cddf6 --- /dev/null +++ b/pyeudiw/openid4vp/schemas/vp_token.py @@ -0,0 +1,36 @@ +from typing import Literal + +from pydantic import BaseModel, HttpUrl, field_validator +from pydantic_core.core_schema import FieldValidationInfo + +from pyeudiw.sd_jwt.schema import is_sd_jwt_format +from pyeudiw.tools.schema_utils import check_algorithm + + +class VPTokenHeader(BaseModel): + alg: str + kid: str + typ: Literal["JWT"] + + @field_validator("alg") + @classmethod + def _check_alg(cls, alg, info: FieldValidationInfo): + return check_algorithm(alg, info) + + +class VPTokenPayload(BaseModel): + iss: HttpUrl + jti: str + aud: HttpUrl + iat: int + exp: int + nonce: str + vp: str + + @field_validator("vp") + @classmethod + def _check_vp(cls, vp): + if is_sd_jwt_format(vp): + return vp + else: + raise ValueError("vp is not in a SDJWT format.") diff --git a/pyeudiw/openid4vp/schemas/wallet_instance_attestation.py b/pyeudiw/openid4vp/schemas/wallet_instance_attestation.py new file mode 100644 index 00000000..e2e3bf46 --- /dev/null +++ b/pyeudiw/openid4vp/schemas/wallet_instance_attestation.py @@ -0,0 +1,55 @@ +from typing import Dict, List, Literal, Optional + +from pydantic import BaseModel, HttpUrl, field_validator +from pydantic_core.core_schema import FieldValidationInfo + +from pyeudiw.openid4vp.schemas.cnf_schema import CNFSchema +from pyeudiw.tools.schema_utils import check_algorithm + +_default_supported_algorithms = [ + "RS256", + "RS384", + "RS512", + "ES256", + "ES384", + "ES512", + "PS256", + "PS384", + "PS512", +] + + +class VPFormatSchema(BaseModel): + jwt_vp_json: Dict[Literal["alg_values_supported"], List[str]] + jwt_vc_json: Dict[Literal["alg_values_supported"], List[str]] + + +class WalletInstanceAttestationHeader(BaseModel): + alg: str + typ: Literal["wallet-attestation+jwt"] + kid: str + x5c: Optional[List[str]] = None + trust_chain: Optional[List[str]] = None + + @field_validator("alg") + @classmethod + def _check_alg(cls, alg, info: FieldValidationInfo): + return check_algorithm(alg, info) + + +class WalletInstanceAttestationPayload(BaseModel): + iss: HttpUrl + sub: str + iat: int + exp: int + type: Literal["WalletInstanceAttestation"] + policy_uri: HttpUrl + tos_uri: HttpUrl + logo_uri: HttpUrl + attested_security_context: HttpUrl + cnf: CNFSchema + authorization_endpoint: str + response_types_supported: List[str] + vp_formats_supported: VPFormatSchema + request_object_signing_alg_values_supported: List[str] + presentation_definition_uri_supported: bool diff --git a/pyeudiw/openid4vp/schemas/wallet_instance_attestation_request.py b/pyeudiw/openid4vp/schemas/wallet_instance_attestation_request.py new file mode 100644 index 00000000..052b8bf0 --- /dev/null +++ b/pyeudiw/openid4vp/schemas/wallet_instance_attestation_request.py @@ -0,0 +1,31 @@ +from typing import Literal + +from pydantic import BaseModel, HttpUrl, field_validator +from pydantic_core.core_schema import FieldValidationInfo + +from pyeudiw.openid4vp.schemas.cnf_schema import CNFSchema +from pyeudiw.tools.schema_utils import check_algorithm + + +class WalletInstanceAttestationRequestHeader(BaseModel): + alg: str + typ: Literal["var+jwt"] + kid: str + + @field_validator("alg") + @classmethod + def _check_alg(cls, alg, info: FieldValidationInfo): + return check_algorithm(alg, info) + + +class WalletInstanceAttestationRequestPayload(BaseModel): + iss: str + aud: HttpUrl + jti: str + type: Literal["WalletInstanceAttestationRequest"] + nonce: str + cnf: CNFSchema + # TODO: check if `iat` and `exp` are required. They are not listed in the table but are in the example. + # https://github.com/italia/eudi-wallet-it-docs/blob/versione-corrente/docs/en/wallet-instance-attestation.rst#format-of-the-wallet-instance-attestation-request + iat: int + exp: int diff --git a/pyeudiw/presentation_exchange/__init__.py b/pyeudiw/presentation_exchange/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pyeudiw/presentation_exchange/schemas/__init__.py b/pyeudiw/presentation_exchange/schemas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pyeudiw/presentation_exchange/schemas/presentation_definition.py b/pyeudiw/presentation_exchange/schemas/presentation_definition.py new file mode 100644 index 00000000..aa064554 --- /dev/null +++ b/pyeudiw/presentation_exchange/schemas/presentation_definition.py @@ -0,0 +1,29 @@ +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel + + +class InputDescriptorJwt(BaseModel): + alg: List[str] + + +class MsoMdoc(BaseModel): + alg: List[str] + + +class FormatSchema(BaseModel): + jwt: Optional[InputDescriptorJwt] = None + mso_mdoc: Optional[MsoMdoc] = None + constraints: Optional[Dict[str, Any]] = None + + +class InputDescriptor(BaseModel): + id: str + name: Optional[str] = None + purpose: Optional[str] = None + format: Optional[str | FormatSchema] = None + + +class PresentationDefinition(BaseModel): + id: str + input_descriptors: List[InputDescriptor] diff --git a/pyeudiw/satosa/backend.py b/pyeudiw/satosa/backend.py index 78403856..df10b1be 100644 --- a/pyeudiw/satosa/backend.py +++ b/pyeudiw/satosa/backend.py @@ -1,34 +1,33 @@ -import base64 import json import logging import uuid - from datetime import datetime, timedelta from typing import Union -from urllib.parse import urlencode, quote_plus +from urllib.parse import quote_plus, urlencode -from satosa.context import Context import satosa.logging_util as lu from satosa.backends.base import BackendModule -from pyeudiw.satosa.exceptions import ( - BadRequestError, - NoBoundEndpointError -) +from satosa.context import Context from satosa.internal import InternalData from satosa.response import Redirect, Response -from pyeudiw.oauth2.dpop import DPoPVerifier from pyeudiw.jwk import JWK -from pyeudiw.jwt import JWSHelper, JWEHelper -from pyeudiw.jwt.utils import unpad_jwt_payload +from pyeudiw.jwt import JWEHelper, JWSHelper +from pyeudiw.jwt.utils import unpad_jwt_header, unpad_jwt_payload +from pyeudiw.oauth2.dpop import DPoPVerifier +from pyeudiw.openid4vp.schemas.response_schema import ResponseSchema as ResponseValidator +from pyeudiw.satosa.exceptions import BadRequestError, NoBoundEndpointError, NoNonceInVPToken, InvalidVPToken, NotTrustedFederationError from pyeudiw.satosa.html_template import Jinja2TemplateHandler from pyeudiw.satosa.response import JsonResponse -from pyeudiw.tools.qr_code import QRCode from pyeudiw.tools.mobile import is_smartphone -from pyeudiw.tools.utils import iat_now -from pyeudiw.openid4vp.schema import ResponseSchema as ResponseValidator -from pyeudiw.sd_jwt import load_specification_from_yaml_string +from pyeudiw.tools.qr_code import QRCode +from pyeudiw.tools.utils import iat_now, exp_from_now from pyeudiw.openid4vp import check_vp_token +from pyeudiw.openid4vp.exceptions import KIDNotFound +from pyeudiw.storage.db_engine import DBEngine +from pyeudiw.trust import TrustEvaluationHelper + +from pydantic import ValidationError logger = logging.getLogger("openid4vp_backend") @@ -64,29 +63,35 @@ def __init__(self, auth_callback_func, internal_attributes, config, base_url, na self.absolute_redirect_url = config['metadata']['redirect_uris'][0] self.absolute_request_url = config['metadata']['request_uris'][0] - self.qrcode_settings = config['qrcode_settings'] + self.qrcode_settings = config['qrcode'] self.config = config - self.default_exp = int(self.config['jwt_settings']['default_exp']) + self.default_exp = int(self.config['jwt']['default_exp']) # dumps public jwks in the metadata self.config['metadata']['jwks'] = config["metadata_jwks"] self.federation_jwk = JWK( - self.config['federation']['federation_jwks'][0]) + self.config['federation']['federation_jwks'][0] + ) self.metadata_jwk = JWK(self.config["metadata_jwks"][0]) + self.federations_jwks_by_kids = { + i['kid']: i for i in self.config['federation']['federation_jwks'] + } + self.metadata_jwks_by_kids = { + i['kid']: i for i in self.config['metadata_jwks'] + } + # HTML template loader self.template = Jinja2TemplateHandler(config) - self.sd_jwt = self.config["sd_jwt"] - self.sd_specification = load_specification_from_yaml_string( - self.sd_jwt["sd_specification"]) + self.db_engine = DBEngine(self.config["storage"]) logger.debug( lu.LOG_FMT.format( id="OpenID4VP init", - message=f"Loaded configuration:\n{json.dumps(config)}" + message=f"Loaded configuration: {json.dumps(config)}" ) ) @@ -171,29 +176,44 @@ def entity_configuration_endpoint(self, context, *args): def pre_request_endpoint(self, context, internal_request, **kwargs): + session_id = str(context.state["SESSION_ID"]) + state = str(uuid.uuid4()) + + # Init session + try: + self.db_engine.init_session( + state=state, + session_id=session_id + ) + except Exception as e: + _msg = ( + f"Error while initializing session with state {state} and {session_id}. " + f"{e.__class__.__name__}: {e}" + ) + return self.handle_error(context, message=_msg, err_code="500") + # PAR payload = { 'client_id': self.client_id, - 'request_uri': self.absolute_request_url + 'request_uri': f"{self.absolute_request_url}?id={state}", } url_params = urlencode(payload, quote_via=quote_plus) if is_smartphone(context.http_headers.get('HTTP_USER_AGENT')): # Same Device flow - res_url = f'{self.config["authorization"]["url_scheme"]}://authorize?{url_params}' + res_url = \ + f'{self.config["authorization"]["url_scheme"]}://authorize?{url_params}' return Redirect(res_url) # Cross Device flow res_url = f'{self.client_id}?{url_params}' # response = base64.b64encode(res_url.encode()) - qrcode = QRCode(res_url, **self.config['qrcode_settings']) - stream = qrcode.for_html() + qrcode = QRCode(res_url, **self.config['qrcode']) result = self.template.qrcode_page.render( - {"title": "frame the qrcode", 'qrcode_base64': base64.b64encode( - stream.encode()).decode()} + {"title": "Frame the qrcode", 'qrcode_base64': qrcode.to_base64()} ) return Response(result, content="text/html; charset=utf8", status="200") @@ -209,15 +229,15 @@ def _translate_response(self, response, issuer): :param subject_type: public or pairwise according to oidc standard. :return: A SATOSA internal response. """ - timestamp = response.get( - "auth_time", - response.get('iat', iat_now()) - ) - # it may depends by credential type and attested security context evaluated # if WIA was previously submitted by the Wallet + # TODO - Internal Response # auth_class_ref = response.get("acr", response.get("amr", UNSPECIFIED)) + # timestamp = response.get( + # "auth_time", + # response.get('iat', iat_now()) + # ) # auth_info = AuthenticationInformation(auth_class_ref, timestamp, issuer) # internal_resp = InternalData(auth_info=auth_info) internal_resp = InternalData() @@ -229,21 +249,25 @@ def _translate_response(self, response, issuer): internal_resp.subject_id = str(uuid.uuid4()) return internal_resp - def _handle_vp(self, vp_token: str, context: Context) -> dict: + def _validate_trust(self, jws: str) -> None: + headers = unpad_jwt_header(jws) + trust_eval = TrustEvaluationHelper(self.db_engine, **headers) + is_trusted = trust_eval.inspect_evaluation_method() + if not is_trusted(): + raise NotTrustedFederationError( + f"{trust_eval.entity_id} is not trusted" + ) + + def _handle_vp(self, vp_token: str) -> dict: + self._validate_trust(vp_token) valid, value = check_vp_token( - vp_token, self.config, self.sd_specification, self.sd_jwt) + vp_token, None, self.metadata_jwks_by_kids + ) + if not valid: - raise value + raise InvalidVPToken("Invalid vp_token") elif not value.get("nonce", None): - _msg = "vp_token's nonce not present" - self._log(context, level='error', message=_msg) - return JsonResponse( - { - "error": "parameter_absent", - "error_description": _msg - }, - status="400" - ) + raise NoNonceInVPToken("vp_token's nonce not present") return value @@ -258,8 +282,15 @@ def redirect_endpoint(self, context, *args): # take the encrypted jwt, decrypt with my public key (one of the metadata) -> if not -> exception jwt = context.request["response"] - jwk = JWK(self.config["federation"] - ["federation_jwks"][0], key_type="RSA") + + # get the decryption jwks by its kid + jwt_header = unpad_jwt_header(jwt) + + jwk = JWK( + self.metadata_jwks_by_kids[ + jwt_header.get('kid', self.metadata_jwk) + ] + ) jweHelper = JWEHelper(jwk) try: @@ -270,14 +301,13 @@ def redirect_endpoint(self, context, *args): raise BadRequestError(_msg) # TODO: get state, do lookup on the db -> if not -> exception - # TODO Fix this field handling state = decrypted_data.get("state", None) if not state: _msg = f"Response state missing" # state is OPTIONAL in openid4vp ... self._log(context, level='warning', message=_msg) - # check with pydantic on the JWT schema + # check with pydantic on the JWT schemas try: ResponseValidator(**decrypted_data) except Exception as e: @@ -296,32 +326,31 @@ def redirect_endpoint(self, context, *args): nonce = None claims = [] for vp in vp_token: + result = None try: - result = self._handle_vp(vp, context) + result = self._handle_vp(vp) + except InvalidVPToken: + return self.handle_error(context=context, message=f"Cannot validate VP: {vp}", err_code="400") + except NoNonceInVPToken: + return self.handle_error(context=context, message=f"Nonce is missing in vp", err_code="400") + except ValidationError as e: + return self.handle_error(context=context, message=f"Error validating schemas: {e}", err_code="400") + except KIDNotFound as e: + return self.handle_error(context=context, message=f"Kid error: {e}", err_code="400") + except NotTrustedFederationError as e: + return self.handle_error(context=context, message=f"Not trusted federation error: {e}", err_code="400") except Exception as e: - _msg = f"VP parsing error: {e}" - self._log(context, level='error', message=_msg) - return JsonResponse( - { - "error": "unsupported_response_type", - "error_description": _msg - }, - status="400" - ) + return self.handle_error(context=context, message=f"VP parsing error: {e}", err_code="400") # TODO: this is not clear ... since the nonce must be taken from the originatin authz request, taken from the storage (mongodb) if not nonce: nonce = result["nonce"] elif nonce != result["nonce"]: - _msg = "Presentation has divergent nonces" - self._log(context, level='error', message=_msg) - return JsonResponse( - { - "error": "invalid_token", - "error_description": _msg - }, - status="401" + return self.handle_error( + context=self, + message=f"Presentation has divergent nonces: {nonce} != {result['nonce']}", + err_code="401" ) else: claims.append(result["claims"]) @@ -338,8 +367,10 @@ def redirect_endpoint(self, context, *args): for claim in claims: all_user_claims.update(claim) - self._log(context, level='debug', - message=f"Wallet disclosure: {all_user_claims}") + self._log( + context, level='debug', + message=f"Wallet disclosure: {all_user_claims}" + ) # TODO: define "issuer" ... it MUST be not an empty dictionary _info = {"issuer": {}} @@ -347,6 +378,12 @@ def redirect_endpoint(self, context, *args): internal_resp = self._translate_response( all_user_claims, _info["issuer"] ) + + try: + self.db_engine.update_response_object(nonce, state, internal_resp) + except Exception as e: + return self.handle_error(context=context, message=f"Cannot update response object: {e}", err_code="500") + return self.auth_callback_func(context, internal_resp) def _request_endpoint_dpop(self, context, *args) -> Union[JsonResponse, None]: @@ -359,10 +396,12 @@ def _request_endpoint_dpop(self, context, *args) -> Union[JsonResponse, None]: # using the TA public key validate trust_chain and or x5c # take WIA - wia = unpad_jwt_payload(context.http_headers['HTTP_AUTHORIZATION']) + http_authz = context.http_headers['HTTP_AUTHORIZATION'] + wia = unpad_jwt_payload(http_authz) + dpop_jws = http_authz.split()[1] + self._validate_trust(dpop_jws) # TODO: validate wia scheme using pydantic - try: dpop = DPoPVerifier( public_jwk=wia['cnf']['jwk'], @@ -392,10 +431,9 @@ def _request_endpoint_dpop(self, context, *args) -> Union[JsonResponse, None]: ) # TODO: assert and configure the wallet capabilities - # TODO: assert and configure the wallet Attested Security Context + # TODO: assert and configure the wallet Attested Security Context else: - # TODO - check that this logging system works ... _msg = ( "The Wallet Instance didn't provide its Wallet Instance Attestation " "a default set of capabilities and a low security level are accorded." @@ -403,19 +441,28 @@ def _request_endpoint_dpop(self, context, *args) -> Union[JsonResponse, None]: self._log(context, level='warning', message=_msg) def request_endpoint(self, context, *args): - jwk = self.metadata_jwk # check DPOP for WIA if any dpop_validation_error = self._request_endpoint_dpop(context) if dpop_validation_error: return dpop_validation_error - # TODO: do customization if the WIA is available + try: + state = context.qs_params["id"] + except Exception as e: + _msg = ( + "Error while retrieving id from qs_params: " + f"{e.__class__.__name__}: {e}" + ) + return self.handle_error(context, message=_msg, err_code="403") - # TODO: take the response and extract from jwt the public key of holder + try: + dpop_proof = context.http_headers['HTTP_DPOP'] + attestation = context.http_headers['HTTP_AUTHORIZATION'] + except KeyError as e: + _msg = f"Error while accessing http headers: {e}" + return self.handle_error(context, message=_msg, err_code="403") - # verify the jwt - helper = JWSHelper(jwk) data = { "scope": ' '.join(self.config['authorization']['scopes']), "client_id_scheme": "entity_id", # that's federation. @@ -424,14 +471,32 @@ def request_endpoint(self, context, *args): "response_type": "vp_token", "response_uri": self.config["metadata"]["redirect_uris"][0], "nonce": str(uuid.uuid4()), - "state": str(uuid.uuid4()), + "state": state, "iss": self.client_id, "iat": iat_now(), - "exp": iat_now() + (self.default_exp * 60) # in seconds + "exp": exp_from_now(minutes=5) } + + try: + document = self.db_engine.get_by_state(state) + document_id = document["document_id"] + self.db_engine.add_dpop_proof_and_attestation( + document_id, dpop_proof, attestation) + self.db_engine.update_request_object(document_id, data) + self.db_engine.set_finalized(document_id) + except ValueError as e: + _msg = ( + "Error while retrieving request object from database: " + f"{e.__class__.__name__}: {e}" + ) + return self.handle_error(context, message=_msg, err_code="403") + except Exception as e: + _msg = f"Error while updating request object: {e}" + return self.handle_error(context, message=_msg, err_code="500") + + helper = JWSHelper(self.metadata_jwk) jwt = helper.sign(data) response = {"response": jwt} - return JsonResponse( response, status="200" @@ -460,3 +525,35 @@ def handle_error( }, status=err_code ) + + def state_endpoint(self, context): + session_id = context.state["SESSION_ID"] + try: + state = context.qs_params["id"] + except TypeError as e: + _msg = f"No query params found! {e}" + return self.handle_error(context, message=_msg, err_code="403") + except KeyError as e: + _msg = f"No id found in qs_params! {e}" + return self.handle_error(context, message=_msg, err_code="403") + + try: + session = self.db_engine.get_by_state_and_session_id( + state=state, session_id=session_id) + except ValueError as e: + _msg = f"Error while retrieving session by state {state} and session_id {session_id}: {e}" + return self.handle_error(context, message=_msg, err_code="403") + + if session["finalized"]: + return JsonResponse({ + "response": "Authentication successful" + }, + status="302" + ) + else: + return JsonResponse( + { + "response": "Request object issued" + }, + status="204" + ) diff --git a/pyeudiw/satosa/exceptions.py b/pyeudiw/satosa/exceptions.py index d13b9847..6fda1b68 100644 --- a/pyeudiw/satosa/exceptions.py +++ b/pyeudiw/satosa/exceptions.py @@ -10,3 +10,19 @@ class NoBoundEndpointError(Exception): """ Raised when a given url path is not bound to any endpoint function """ + + +class NoNonceInVPToken(Exception): + """ + Raised when a given VP has no nonce + """ + + +class InvalidVPToken(Exception): + """ + Raised when a given VP is invalid + """ + + +class NotTrustedFederationError(Exception): + pass diff --git a/pyeudiw/sd_jwt/__init__.py b/pyeudiw/sd_jwt/__init__.py index 76170023..904e0702 100644 --- a/pyeudiw/sd_jwt/__init__.py +++ b/pyeudiw/sd_jwt/__init__.py @@ -1,22 +1,22 @@ from io import StringIO -from sd_jwt.issuer import SDJWTIssuer -from sd_jwt.verifier import SDJWTVerifier +from sd_jwt.issuer import SDJWTIssuer from sd_jwt.utils.demo_utils import get_jwk from sd_jwt.utils.formatting import textwrap_json from sd_jwt.utils.yaml_specification import _yaml_load_specification +from sd_jwt.verifier import SDJWTVerifier -from pyeudiw.tools.utils import iat_now, gen_exp_time from pyeudiw.jwk import JWK from pyeudiw.jwt.utils import unpad_jwt_payload +from pyeudiw.tools.utils import gen_exp_time, iat_now def _adapt_keys(settings: dict, issuer_key: JWK, holder_key: JWK, kty: str = "EC", key_size: int = 256): keys = { "key_size": key_size, "kty": kty, - "issuer_key": issuer_key.as_dict(), - "holder_key": holder_key.as_dict() + "issuer_key": issuer_key.as_dict() if issuer_key else {}, + "holder_key": holder_key.as_dict() if holder_key else {} } return get_jwk(keys, settings["no_randomness"], None) @@ -56,12 +56,10 @@ def _cb_get_issuer_key(issuer: str, settings: dict, adapted_keys: dict): def verify_sd_jwt(sd_jwt_presentation: str, specification: dict, settings: dict, issuer_key: JWK, holder_key: JWK) -> dict: - + settings.update({"issuer": unpad_jwt_payload(sd_jwt_presentation)["iss"]}) adapted_keys = _adapt_keys(settings, issuer_key, holder_key) - specification.get("add_decoy_claims", False) - serialization_format = specification.get("serialization_format", "compact") - + serialization_format = "compact" sdjwt_at_verifier = SDJWTVerifier( sd_jwt_presentation, (lambda x: _cb_get_issuer_key(x, settings, adapted_keys)), @@ -70,7 +68,4 @@ def verify_sd_jwt(sd_jwt_presentation: str, specification: dict, settings: dict, serialization_format=serialization_format, ) - key_binding = unpad_jwt_payload( - sdjwt_at_verifier._unverified_input_key_binding_jwt) - - return sdjwt_at_verifier.get_verified_payload(), key_binding + return sdjwt_at_verifier.get_verified_payload() diff --git a/pyeudiw/sd_jwt/schema.py b/pyeudiw/sd_jwt/schema.py new file mode 100644 index 00000000..4d86d8f1 --- /dev/null +++ b/pyeudiw/sd_jwt/schema.py @@ -0,0 +1,33 @@ +import re +from typing import Dict, Literal + +from pydantic import BaseModel, HttpUrl + +from pyeudiw.jwk.schema import JwkSchema + +SD_JWT_REGEXP = r"^(([-A-Za-z0-9\=_])*\.([-A-Za-z0-9\=_])*\.([-A-Za-z0-9\=_])*)(~([-A-Za-z0-9\=_\.])*)*$" + + +def is_sd_jwt_format(sd_jwt: str) -> bool: + res = re.match(SD_JWT_REGEXP, sd_jwt) + return bool(res) + + +def is_sd_jwt_list_format(sd_jwt_list: list[str]) -> bool: + if len(sd_jwt_list) == 0: + return False + + for sd_jwt in sd_jwt_list: + if not is_sd_jwt_format(sd_jwt): + return False + + return True + + +class SDJWTSchema(BaseModel): + iss: HttpUrl + iat: int + exp: int + sub: str + _sd_alg: str + cnf: Dict[Literal["jwk"], JwkSchema] diff --git a/pyeudiw/storage/base_cache.py b/pyeudiw/storage/base_cache.py index 7e6ac00d..a29afeed 100644 --- a/pyeudiw/storage/base_cache.py +++ b/pyeudiw/storage/base_cache.py @@ -1,9 +1,18 @@ +from enum import Enum from typing import Callable +class RetrieveStatus(Enum): + RETRIEVED = 0 + ADDED = 1 + + class BaseCache(): - def try_retrieve(self, object_name: str, on_not_found: Callable[[], str]) -> dict: + def try_retrieve(self, object_name: str, on_not_found: Callable[[], str]) -> tuple[dict, RetrieveStatus]: raise NotImplementedError() def overwrite(self, object_name: str, value_gen_fn: Callable[[], str]) -> dict: raise NotImplementedError() + + def set(self, data: dict) -> dict: + raise NotImplementedError() diff --git a/pyeudiw/storage/base_storage.py b/pyeudiw/storage/base_storage.py index 3b782452..5bf3e228 100644 --- a/pyeudiw/storage/base_storage.py +++ b/pyeudiw/storage/base_storage.py @@ -1,9 +1,24 @@ +import datetime + + class BaseStorage(object): - def init_session(self, dpop_proof: dict, attestation: dict): - NotImplementedError() + def init_session(self, document_id: str, dpop_proof: dict, attestation: dict): + raise NotImplementedError() + + def update_request_object(self, document_id: str, nonce: str, state: str | None, request_object: dict): + raise NotImplementedError() + + def update_response_object(self, nonce: str, state: str | None, response_object: dict): + raise NotImplementedError() + + def get_trust_attestation(self, entity_id: str): + raise NotImplementedError() + + def has_trust_attestation(self, entity_id: str): + raise NotImplementedError() - def update_request_object(self, document_id: str, request_object: dict): - NotImplementedError() + def add_trust_attestation(self, entity_id: str, trust_chain: list[str], exp: datetime) -> str: + raise NotImplementedError() - def update_response_object(self, nonce: str, state: str, response_object: dict): - NotImplementedError() + def update_trust_attestation(self, entity_id: str, trust_chain: list[str], exp: datetime) -> str: + raise NotImplementedError() diff --git a/pyeudiw/storage/db_engine.py b/pyeudiw/storage/db_engine.py new file mode 100644 index 00000000..bd949f8c --- /dev/null +++ b/pyeudiw/storage/db_engine.py @@ -0,0 +1,252 @@ +import uuid +import logging +import importlib +from datetime import datetime +from typing import Callable, Union +from pyeudiw.storage.base_cache import BaseCache, RetrieveStatus +from pyeudiw.storage.base_storage import BaseStorage +from pyeudiw.storage.exceptions import ReplicaError + +logger = logging.getLogger("openid4vp.storage.db") + + +class DBEngine(): + def __init__(self, config: dict): + self.caches = [] + self.storages = [] + + for db_name, db_conf in config.items(): + storage_instance, cache_instance = self._handle_instance(db_conf) + + if storage_instance: + self.storages.append((db_name, storage_instance)) + + if cache_instance: + self.caches.append((db_name, cache_instance)) + + def _handle_instance(self, instance: dict) -> dict[BaseStorage | None, BaseCache | None]: + cache_conf = instance.get("cache", None) + storage_conf = instance.get("storage", None) + + storage_instance = None + if storage_conf: + module = importlib.import_module(storage_conf["module"]) + instance_class = getattr(module, storage_conf["class"]) + + storage_instance = instance_class(**storage_conf["init_params"]) + + cache_instance = None + if cache_conf: + module = importlib.import_module(cache_conf["module"]) + instance_class = getattr(module, cache_conf["class"]) + + cache_instance = instance_class(**cache_conf["init_params"]) + + return storage_instance, cache_instance + + def init_session(self, session_id: str, state: str) -> str: + document_id = str(uuid.uuid4()) + for db_name, storage in self.storages: + try: + storage.init_session( + document_id, session_id=session_id, state=state) + except Exception as e: + logger.critical( + f"Error while initializing session with document_id {document_id}. " + f"Cannot write document with id {document_id} on {db_name}: " + f"{e.__class__.__name__}: {e}") + + return document_id + + def add_dpop_proof_and_attestation(self, document_id, dpop_proof: dict, attestation: dict): + replica_count = 0 + for db_name, storage in self.storages: + try: + storage.add_dpop_proof_and_attestation( + document_id, dpop_proof=dpop_proof, attestation=attestation) + replica_count += 1 + except Exception as e: + logger.critical(f"Error {str(e)}") + logger.critical( + f"Cannot update document with id {document_id} on {db_name}") + + if replica_count == 0: + raise ReplicaError( + f"Cannot update document {document_id} on any instance") + + return replica_count + + def set_finalized(self, document_id: str): + replica_count = 0 + for db_name, storage in self.storages: + try: + storage.set_finalized(document_id) + replica_count += 1 + except Exception as e: + logger.critical(f"Error {str(e)}") + logger.critical( + f"Cannot update document with id {document_id} on {db_name}") + + if replica_count == 0: + raise Exception( + f"Cannot update document {document_id} on any instance") + + return replica_count + + def update_request_object(self, document_id: str, request_object: dict) -> int: + replica_count = 0 + for db_name, storage in self.storages: + try: + storage.update_request_object( + document_id, request_object) + replica_count += 1 + except Exception as e: + logger.critical(f"Error {str(e)}") + logger.critical( + f"Cannot update document with id {document_id} on {db_name}") + + if replica_count == 0: + raise Exception( + f"Cannot update document {document_id} on any instance") + + return replica_count + + def update_response_object(self, nonce: str, state: str, response_object: dict) -> int: + replica_count = 0 + for db_name, storage in self.storages: + try: + storage.update_response_object(nonce, state, response_object) + replica_count += 1 + except Exception as e: + logger.critical(f"Error {str(e)}") + logger.critical( + f"Cannot update document with nonce {nonce} and state {state} on {db_name}") + + if replica_count == 0: + raise ReplicaError( + f"Cannot update document with state {state} and nonce {nonce} on any instance") + + return replica_count + + def get_trust_attestation(self, entity_id: str) -> Union[dict, None]: + for db_name, storage in self.storages: + try: + chain = storage.get_trust_attestation(entity_id) + + if chain: + return chain + + except Exception as e: + logger.critical(f"Error {str(e)}") + logger.critical( + f"Cannot find chain {entity_id} on {db_name}") + + raise Exception(f"Cannot find chain {entity_id} on any instance") + + def has_trust_attestation(self, entity_id: str): + if self.get_trust_attestation(entity_id) is not None: + return True + return False + + def add_trust_attestation(self, entity_id: str, trust_chain: list[str], exp: datetime) -> str: + replica_count = 0 + for db_name, storage in self.storages: + try: + storage.add_trust_attestation(entity_id, trust_chain, exp) + replica_count += 1 + except Exception: + logger.critical( + "Cannot add chain with entity_id {entity_id} on database {db_name}") + + if replica_count == 0: + raise ReplicaError( + f"Cannot add chain {entity_id} on any instance") + + def update_trust_attestation(self, entity_id: str, trust_chain: list[str], exp: datetime) -> str: + replica_count = 0 + for db_name, storage in self.storages: + try: + storage.update_trust_attestation(entity_id, trust_chain, exp) + replica_count += 1 + except Exception: + logger.critical( + "Cannot add chain with entity_id {entity_id} on database {db_name}") + + if replica_count == 0: + raise ReplicaError( + f"Cannot update chain {entity_id} on any instance") + + def _cache_try_retrieve(self, object_name: str, on_not_found: Callable[[], str]) -> tuple[dict, RetrieveStatus, int]: + for i, cache in enumerate(self.caches): + try: + cache_object, status = cache.try_retrieve( + object_name, on_not_found) + return cache_object, status, i + except Exception: + logger.critical( + "Cannot retrieve or write cache object with identifier {object_name} on database {db_name}") + raise ConnectionRefusedError( + "Cannot write cache object on any instance") + + def try_retrieve(self, object_name: str, on_not_found: Callable[[], str]) -> dict: + istances_len = len(self.caches) + + # if no cache instance exist return the object + if istances_len == 0: + return on_not_found() + + # if almost one cache instance exist try to retrieve + cache_object, status, idx = self._cache_try_retrieve( + object_name, on_not_found) + + # if the status is retrieved return the object + if status == RetrieveStatus.RETRIEVED: + return cache_object + + # else try replicate the data on all the other istances + replica_instances = self.caches[:idx] + self.caches[idx + 1:] + + for cache_name, cache in replica_instances: + try: + cache.set(cache_object) + except Exception: + logger.critical( + "Cannot replicate cache object with identifier {object_name} on cache {cache_name}") + + return cache_object + + def overwrite(self, object_name: str, value_gen_fn: Callable[[], str]) -> dict: + for cache_name, cache in self.caches: + cache_object = None + try: + cache_object = cache.overwrite(object_name, value_gen_fn) + except Exception: + logger.critical( + "Cannot overwrite cache object with identifier {object_name} on cache {cache_name}") + + return cache_object + + def exists_by_state_and_session_id(self, state: str, session_id: str = "") -> bool: + for db_name, storage in self.storages: + found = storage.exists_by_state_and_session_id( + state=state, session_id=session_id) + if found: + return True + return False + + def get_by_state(self, state: str): + return self.get_by_state_and_session_id(state=state) + + def get_by_state_and_session_id(self, state: str, session_id: str = ""): + for db_name, storage in self.storages: + try: + document = storage._retrieve_document_by_state_and_session_id( + state, session_id) + return document + except ValueError: + logger.debug( + f"Document object with state {state} and session_id {session_id} not found in db {db_name}") + + _msg = f"Document object with state {state} and session_id {session_id} not found" + logger.error(_msg) + raise ValueError(_msg) diff --git a/pyeudiw/storage/exceptions.py b/pyeudiw/storage/exceptions.py new file mode 100644 index 00000000..c929de0b --- /dev/null +++ b/pyeudiw/storage/exceptions.py @@ -0,0 +1,10 @@ +class ChainAlreadyExist(BaseException): + pass + + +class ChainNotExist(BaseException): + pass + + +class ReplicaError(BaseException): + pass diff --git a/pyeudiw/storage/mongo_cache.py b/pyeudiw/storage/mongo_cache.py index 86cd3909..0d159e09 100644 --- a/pyeudiw/storage/mongo_cache.py +++ b/pyeudiw/storage/mongo_cache.py @@ -1,15 +1,16 @@ -import pymongo from datetime import datetime from typing import Callable -from .base_cache import BaseCache +import pymongo + +from pyeudiw.storage.base_cache import BaseCache, RetrieveStatus class MongoCache(BaseCache): - def __init__(self, storage_conf: dict, url: str, connection_params: dict = None) -> None: + def __init__(self, conf: dict, url: str, connection_params: dict = None) -> None: super().__init__() - self.storage_conf = storage_conf + self.storage_conf = conf self.url = url self.connection_params = connection_params @@ -23,7 +24,15 @@ def _connect(self): self.db = getattr(self.client, self.storage_conf["db_name"]) self.collection = getattr(self.db, "cache_storage") - def try_retrieve(self, object_name: str, on_not_found: Callable[[], str]) -> dict: + def _gen_cache_object(self, object_name: str, data: str): + creation_date = datetime.timestamp(datetime.now()) + return { + "object_name": object_name, + "data": data, + "creation_date": creation_date + } + + def try_retrieve(self, object_name: str, on_not_found: Callable[[], str]) -> tuple[dict, RetrieveStatus]: self._connect() query = {"object_name": object_name} @@ -31,16 +40,11 @@ def try_retrieve(self, object_name: str, on_not_found: Callable[[], str]) -> dic cache_object = self.collection.find_one(query) if cache_object is None: - creation_date = datetime.timestamp(datetime.now()) - cache_object = { - "object_name": object_name, - "data": on_not_found(), - "creation_date": creation_date - } - + cache_object = self._gen_cache_object(object_name, on_not_found()) self.collection.insert_one(cache_object) + return cache_object, RetrieveStatus.ADDED - return cache_object + return cache_object, RetrieveStatus.RETRIEVED def overwrite(self, object_name: str, value_gen_fn: Callable[[], str]) -> dict: self._connect() @@ -64,3 +68,8 @@ def overwrite(self, object_name: str, value_gen_fn: Callable[[], str]) -> dict: }) return cache_object + + def set(self, data: dict) -> dict: + self._connect() + + return self.collection.insert_one(data) diff --git a/pyeudiw/storage/mongo_storage.py b/pyeudiw/storage/mongo_storage.py index f79888a7..59a5510b 100644 --- a/pyeudiw/storage/mongo_storage.py +++ b/pyeudiw/storage/mongo_storage.py @@ -1,14 +1,17 @@ import pymongo from datetime import datetime -from .base_storage import BaseStorage +from pymongo.results import UpdateResult + +from pyeudiw.storage.base_storage import BaseStorage +from pyeudiw.storage.exceptions import ChainAlreadyExist, ChainNotExist class MongoStorage(BaseStorage): - def __init__(self, storage_conf: dict, url: str, connection_params: dict = None) -> None: + def __init__(self, conf: dict, url: str, connection_params: dict = None) -> None: super().__init__() - self.storage_conf = storage_conf + self.storage_conf = conf self.url = url self.connection_params = connection_params @@ -18,27 +21,32 @@ def __init__(self, storage_conf: dict, url: str, connection_params: dict = None) def _connect(self): if not self.client or not self.client.server_info(): self.client = pymongo.MongoClient( - self.url, **self.connection_params) + self.url, **self.connection_params + ) self.db = getattr(self.client, self.storage_conf["db_name"]) - self.collection = getattr( - self.db, self.storage_conf["db_collection"]) + self.sessions = getattr( + self.db, self.storage_conf["db_sessions_collection"] + ) + self.attestations = getattr( + self.db, self.storage_conf["db_attestations_collection"] + ) def _retrieve_document_by_id(self, document_id: str) -> dict: self._connect() - document = self.collection.find_one({"_id": document_id}) + document = self.sessions.find_one({"document_id": document_id}) if document is None: raise ValueError(f'Document with id {document_id} not found') return document - def _retrieve_document_by_nonce_state(self, nonce: str, state: str) -> dict: + def _retrieve_document_by_nonce_state(self, nonce: str, state: str | None) -> dict: self._connect() query = {"state": state, "nonce": nonce} - document = self.collection.find_one(query) + document = self.sessions.find_one(query) if document is None: raise ValueError( @@ -46,46 +54,94 @@ def _retrieve_document_by_nonce_state(self, nonce: str, state: str) -> dict: return document - def init_session(self, dpop_proof: dict, attestation: dict): + def _retrieve_document_by_state_and_session_id(self, state: str, session_id: str = ""): + self._connect() + + query = {"state": state} + if session_id: + query["session_id"] = session_id + document = self.sessions.find_one(query) + + if document is None: + raise ValueError( + f'Document with state {state} not found') + + return document + + def init_session(self, document_id: str, session_id: str, state: str) -> str: creation_date = datetime.timestamp(datetime.now()) entity = { + "document_id": document_id, "creation_date": creation_date, - "dpop_proof": dpop_proof, - "attestation": attestation, + "state": state, + "session_id": session_id, + "finalized": False, "request_object": None, "response": None } self._connect() - document_id = self.collection.insert_one(entity) + self.sessions.insert_one(entity) - return document_id.inserted_id - - def update_request_object(self, document_id: str, request_object: dict): - nonce = request_object["nonce"] - state = request_object["state"] + return document_id + def add_dpop_proof_and_attestation(self, document_id: str, dpop_proof: dict, attestation: dict): self._connect() - documentStatus = self.collection.update_one( - {"_id": document_id}, + update_result: UpdateResult = self.sessions.update_one( + {"document_id": document_id}, { "$set": { - "nonce": nonce, - "state": state, - "request_object": request_object + "dpop_proof": dpop_proof, + "attestation": attestation, + } + }) + if update_result.matched_count != 1 or update_result.modified_count != 1: + raise ValueError( + f"Cannot update document {document_id}')" + ) + + return update_result + + def update_request_object(self, document_id: str, request_object: dict): + self._retrieve_document_by_id(document_id) + documentStatus = self.sessions.update_one( + {"document_id": document_id}, + { + "$set": { + "request_object": request_object, + "nonce": request_object["nonce"], + "state": request_object["state"], } } ) + if documentStatus.matched_count != 1 or documentStatus.modified_count != 1: + raise ValueError( + f"Cannot update document {document_id}')" + ) + return documentStatus - return nonce, state, documentStatus + def set_finalized(self, document_id: str): + self._retrieve_document_by_id(document_id) + + update_result: UpdateResult = self.collection.update_one( + {"document_id": document_id}, + { + "$set": { + "finalized": True + }, + } + ) + if update_result.matched_count != 1 or update_result.modified_count != 1: + raise ValueError( + f"Cannot update document {document_id}')" + ) + return update_result def update_response_object(self, nonce: str, state: str, response_object: dict): document = self._retrieve_document_by_nonce_state(nonce, state) - document_id = document["_id"] - - documentStatus = self.collection.update_one( + documentStatus = self.sessions.update_one( {"_id": document_id}, {"$set": { @@ -94,3 +150,47 @@ def update_response_object(self, nonce: str, state: str, response_object: dict): }) return nonce, state, documentStatus + + def get_trust_attestation(self, entity_id: str): + self._connect() + return self.attestations.find_one({"entity_id": entity_id}) + + def has_trust_attestation(self, entity_id: str): + if self.get_trust_attestation({"entity_id": entity_id}): + return True + return False + + def add_trust_attestation(self, entity_id: str, trust_chain: list[str], exp: datetime) -> str: + if self.has_trust_attestation(entity_id): + raise ChainAlreadyExist( + f"Chain with entity id {entity_id} already exist") + + entity = { + "entity_id": entity_id, + "federation": { + "chain": trust_chain, + "exp": exp + }, + "x509": {} + } + + self.attestations.insert_one(entity) + + return entity_id + + def update_trust_attestation(self, entity_id: str, trust_chain: list[str], exp: datetime) -> str: + if not self.has_trust_attestation(entity_id): + raise ChainNotExist(f"Chain with entity id {entity_id} not exist") + + documentStatus = self.attestations.update_one( + {"entity_id": entity_id}, + {"$set": + { + "federation": { + "chain": trust_chain, + "exp": exp + } + } + } + ) + return documentStatus diff --git a/pyeudiw/tests/__init__.py b/pyeudiw/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pyeudiw/tests/federation/__init__.py b/pyeudiw/tests/federation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pyeudiw/tests/federation/base.py b/pyeudiw/tests/federation/base.py new file mode 100644 index 00000000..61879e11 --- /dev/null +++ b/pyeudiw/tests/federation/base.py @@ -0,0 +1,285 @@ +# pip install cryptojwt +from cryptojwt.jws.jws import JWS +from cryptojwt.jwk.rsa import new_rsa_key +from pyeudiw.tools.utils import iat_now, exp_from_now + +# Create private keys +leaf_jwk = new_rsa_key() +intermediate_jwk = new_rsa_key() +ta_jwk = new_rsa_key() + +NOW = iat_now() +EXP = exp_from_now(5) + +# Define Entity Configurations +leaf_ec = { + "exp": EXP, + "iat": NOW, + "iss": "https://rp.example.it", + "sub": "https://rp.example.it", + 'jwks': {"keys": []}, + "metadata": { + "wallet_relying_party": { + "application_type": "web", + "client_id": "https://rp.example.it", + "client_name": "Name of an example organization", + 'jwks': {"keys": []}, + "contacts": [ + "ops@verifier.example.org" + ], + + "request_uris": [ + "https://verifier.example.org/request_uri" + ], + "redirect_uris": [ + "https://verifier.example.org/callback" + ], + + "default_acr_values": [ + "https://www.spid.gov.it/SpidL2", + "https://www.spid.gov.it/SpidL3" + ], + + "vp_formats": { + "jwt_vp_json": { + "alg": [ + "EdDSA", + "ES256K" + ] + } + }, + "presentation_definitions": [ + { + "id": "pid-sd-jwt:unique_id+given_name+family_name", + "input_descriptors": [ + { + "id": "sd-jwt", + "format": { + "jwt": { + "alg": [ + "EdDSA", + "ES256" + ] + }, + "constraints": { + "limit_disclosure": "required", + "fields": [ + { + "path": [ + "$.sd-jwt.type" + ], + "filter": { + "type": "string", + "const": "PersonIdentificationData" + } + }, + { + "path": [ + "$.sd-jwt.cnf" + ], + "filter": { + "type": "object", + } + }, + { + "path": [ + "$.sd-jwt.family_name" + ], + "intent_to_retain": "true" + }, + { + "path": [ + "$.sd-jwt.given_name" + ], + "intent_to_retain": "true" + }, + { + "path": [ + "$.sd-jwt.unique_id" + ], + "intent_to_retain": "true" + } + ] + } + } + } + ] + }, + ], + + "default_max_age": 1111, + + # JARM related + "authorization_signed_response_alg": [ + "RS256", + "ES256" + ], + "authorization_encrypted_response_alg": [ + "RSA-OAEP", + "RSA-OAEP-256" + ], + "authorization_encrypted_response_enc": [ + "A128CBC-HS256", + "A192CBC-HS384", + "A256CBC-HS512", + "A128GCM", + "A192GCM", + "A256GCM" + ], + + # SIOPv2 related + "subject_type": "pairwise", + "require_auth_time": True, + "id_token_signed_response_alg": [ + "RS256", + "ES256" + ], + "id_token_encrypted_response_alg": [ + "RSA-OAEP", + "RSA-OAEP-256" + ], + "id_token_encrypted_response_enc": [ + "A128CBC-HS256", + "A192CBC-HS384", + "A256CBC-HS512", + "A128GCM", + "A192GCM", + "A256GCM" + ], + }, + "federation_entity": { + "organization_name": "OpenID Wallet Verifier example", + "homepage_uri": "https://verifier.example.org/home", + "policy_uri": "https://verifier.example.org/policy", + "logo_uri": "https://verifier.example.org/static/logo.svg", + "contacts": [ + "tech@verifier.example.org" + ] + } + }, + "authority_hints": [ + "https://intermediate.eidas.example.org" + ] +} + + +intermediate_ec = { + "exp": EXP, + "iat": NOW, + 'iss': 'https://intermediate.eidas.example.org', + 'sub': 'https://intermediate.eidas.example.org', + 'jwks': {"keys": []}, + 'metadata': { + 'federation_entity': { + 'contacts': ['soggetto@intermediate.eidas.example.it'], + 'federation_fetch_endpoint': 'https://intermediate.eidas.example.org/fetch/', + 'federation_resolve_endpoint': 'https://intermediate.eidas.example.org/resolve/', + 'federation_list_endpoint': 'https://intermediate.eidas.example.org/list/', + 'homepage_uri': 'https://soggetto.intermediate.eidas.example.it', + 'name': 'Example Intermediate intermediate.eidas.example' + } + }, + 'authority_hints': ['https://registry.eidas.trust-anchor.example.eu']} + + +ta_ec = { + "exp": EXP, + "iat": NOW, + 'iss': 'https://registry.eidas.trust-anchor.example.eu/', + 'sub': 'https://registry.eidas.trust-anchor.example.eu/', + 'jwks': {"keys": []}, + 'metadata': { + 'federation_entity': { + 'organization_name': 'example TA', + 'contacts': ['tech@eidas.trust-anchor.example.eu'], + 'homepage_uri': 'https://registry.eidas.trust-anchor.example.eu/', + 'logo_uri': 'https://registry.eidas.trust-anchor.example.eu/static/svg/logo.svg', + 'federation_fetch_endpoint': 'https://registry.eidas.trust-anchor.example.eu/fetch/', + 'federation_resolve_endpoint': 'https://registry.eidas.trust-anchor.example.eu/resolve/', + 'federation_list_endpoint': 'https://registry.eidas.trust-anchor.example.eu/list/', + 'federation_trust_mark_status_endpoint': 'https://registry.eidas.trust-anchor.example.eu/trust_mark_status/' + } + }, + 'constraints': {'max_path_length': 1} +} + +# place example keys +leaf_ec["jwks"]['keys'] = [leaf_jwk.serialize()] +leaf_ec['metadata']['wallet_relying_party']["jwks"]['keys'] = [ + leaf_jwk.serialize()] + +intermediate_ec["jwks"]['keys'] = [intermediate_jwk.serialize()] +ta_ec["jwks"]['keys'] = [ta_jwk.serialize()] + +# pubblica: dict = privata.serialize() +# privata_dict: dict = privata.to_dict() + +# Define Entity Statements +intermediate_es = { + "exp": EXP, + "iat": NOW, + "iss": "https://intermediate.eidas.example.org", + "sub": "https://rp.example.org", + 'jwks': {"keys": []}, + "metadata_policy": { + "openid_relying_party": { + "scopes": { + "subset_of": [ + "eu.europa.ec.eudiw.pid.1, eu.europa.ec.eudiw.pid.it.1" + ] + }, + "request_authentication_methods_supported": { + "one_of": ["request_object"] + }, + "request_authentication_signing_alg_values_supported": { + "subset_of": ["RS256", "RS512", "ES256", "ES512", "PS256", "PS512"] + } + } + } +} + +# the leaf publishes the leaf public key +intermediate_es["jwks"]['keys'] = [leaf_jwk.serialize()] + + +ta_es = { + "exp": EXP, + "iat": NOW, + "iss": "https://trust-anchor.example.eu", + "sub": "https://intermediate.eidas.example.org", + 'jwks': {"keys": []}, + "trust_marks": [ + { + "id": "https://trust-anchor.example.eu/federation_entity/that-profile", + "trust_mark": "eyJhb …" + } + ] +} + +# the ta publishes the intermediate public key +ta_es["jwks"]['keys'] = [intermediate_jwk.serialize()] + + +leaf_signer = JWS(leaf_ec, alg="RS256", typ="application/entity-statement+jwt") +leaf_ec_signed = leaf_signer.sign_compact([leaf_jwk]) + +intermediate_signer = JWS(intermediate_es, alg="RS256", + typ="application/entity-statement+jwt") +intermediate_es_signed = intermediate_signer.sign_compact([intermediate_jwk]) + +intermediate_signer_ec = JWS(intermediate_ec, alg="RS256", + typ="application/entity-statement+jwt") +intermediate_ec_signed = intermediate_signer_ec.sign_compact([ + intermediate_jwk]) + +ta_signer = JWS(ta_es, alg="RS256", typ="application/entity-statement+jwt") +ta_es_signed = ta_signer.sign_compact([ta_jwk]) + +ta_signer_ec = JWS(ta_ec, alg="RS256", typ="application/entity-statement+jwt") +ta_ec_signed = ta_signer_ec.sign_compact([ta_jwk]) + +trust_chain = [ + leaf_ec_signed, + intermediate_es_signed, + ta_es_signed +] diff --git a/pyeudiw/tests/federation/mocked_response.py b/pyeudiw/tests/federation/mocked_response.py new file mode 100644 index 00000000..c57fe927 --- /dev/null +++ b/pyeudiw/tests/federation/mocked_response.py @@ -0,0 +1,152 @@ + +from . base import intermediate_ec_signed, intermediate_es_signed, leaf_ec_signed, ta_ec_signed, ta_es_signed + +import logging + +logger = logging.getLogger(__name__) + + +class DummyContent: + def __init__(self, content): + self.content = content.encode() + self.status_code = 200 + + +class EntityResponse: + def __init__(self): + self.status_code = 200 + self.req_counter = 0 + self.result = None + + +class EntityResponseNoIntermediate(EntityResponse): + @property + def content(self): + + resp_seq = { + 0: ta_ec_signed, + 1: leaf_ec_signed, + 2: intermediate_ec_signed, + 3: intermediate_es_signed, + 4: ta_ec_signed, + 5: ta_es_signed + } + + self.result = resp_seq.get(self.req_counter, None) + self.req_counter += 1 + if self.result: + return self.result + else: + raise NotImplementedError( + "The mocked resposes seems to be not aligned with the correct flow" + ) + +# class EntityResponseNoIntermediateSignedJwksUri(EntityResponse): + # @property + # def content(self): + # if self.req_counter == 0: + # self.result = self.trust_anchor_ec() + # elif self.req_counter == 1: + # self.result = self.rp_ec() + # elif self.req_counter == 2: + # self.result = self.fetch_rp_from_ta() + # elif self.req_counter == 3: + # metadata = copy.deepcopy( + # rp_conf['metadata']['openid_relying_party']) + # _jwks = metadata.pop('jwks') + # fed_jwks = rp_conf['jwks_fed'][0] + # self.result = create_jws(_jwks, fed_jwks) + # return self.result.encode() + # elif self.req_counter > 3: + # raise NotImplementedError( + # "The mocked resposes seems to be not aligned with the correct flow" + # ) + + # return self.result_as_jws() + + +# class EntityResponseWithIntermediate(EntityResponse): + # @property + # def content(self): + # if self.req_counter == 0: + # self.result = self.trust_anchor_ec() + # elif self.req_counter == 1: + # self.result = self.rp_ec() + # elif self.req_counter == 2: + # sa = FederationEntityConfiguration.objects.get( + # sub=intermediary_conf["sub"]) + # self.result = DummyContent(sa.entity_configuration_as_jws) + # elif self.req_counter == 3: + # url = reverse("oidcfed_fetch") + # self.result = self.client.get( + # url, + # data={ + # "sub": rp_onboarding_data["sub"], + # "iss": intermediary_conf["sub"], + # }, + # ) + # elif self.req_counter == 4: + # url = reverse("oidcfed_fetch") + # self.result = self.client.get( + # url, data={"sub": intermediary_conf["sub"]}) + # elif self.req_counter == 5: + # url = reverse("entity_configuration") + # self.result = self.client.get( + # url, data={"sub": ta_conf_data["sub"]}) + # elif self.req_counter > 5: + # raise NotImplementedError( + # "The mocked resposes seems to be not aligned with the correct flow" + # ) + + # if self.result.status_code != 200: + # raise HttpError( + # f"Something went wrong with Http Request: {self.result.__dict__}") + + # logger.info("-------------------------------------------------") + # logger.info("") + # return self.result_as_jws() + + +# class EntityResponseWithIntermediateManyHints(EntityResponse): + # @property + # def content(self): + # if self.req_counter == 0: + # self.result = self.trust_anchor_ec() + # elif self.req_counter == 1: + # self.result = self.rp_ec() + # elif self.req_counter == 2: + # sa = FederationEntityConfiguration.objects.get( + # sub=intermediary_conf["sub"]) + # self.result = DummyContent(sa.entity_configuration_as_jws) + # elif self.req_counter == 3: + # self.result = DummyContent("crap") + + # elif self.req_counter == 4: + # url = reverse("oidcfed_fetch") + # self.result = self.client.get( + # url, + # data={ + # "sub": rp_onboarding_data["sub"], + # "iss": intermediary_conf["sub"], + # }, + # ) + # elif self.req_counter == 5: + # url = reverse("oidcfed_fetch") + # self.result = self.client.get( + # url, data={"sub": intermediary_conf["sub"]}) + # elif self.req_counter == 6: + # url = reverse("entity_configuration") + # self.result = self.client.get( + # url, data={"sub": ta_conf_data["sub"]}) + # elif self.req_counter > 6: + # raise NotImplementedError( + # "The mocked resposes seems to be not aligned with the correct flow" + # ) + # if self.result.status_code != 200: + # raise HttpError( + # f"Something went wrong with Http Request: {self.result.__dict__}") + + # try: + # return self.result_as_jws() + # except Exception: + # return self.result_as_it_is() diff --git a/pyeudiw/tests/federation/schemas/__init__.py b/pyeudiw/tests/federation/schemas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pyeudiw/tests/federation/schemas/test_entity_configuration.py b/pyeudiw/tests/federation/schemas/test_entity_configuration.py new file mode 100644 index 00000000..5592d26d --- /dev/null +++ b/pyeudiw/tests/federation/schemas/test_entity_configuration.py @@ -0,0 +1,266 @@ +import pytest +from pydantic import ValidationError + +from pyeudiw.federation.schemas.entity_configuration import EntityConfigurationHeader, EntityConfigurationPayload + +ENTITY_CONFIGURATION = { + "header": { + "alg": "RS256", + "kid": "2HnoFS3YnC9tjiCaivhWLVUJ3AxwGGz_98uRFaqMEEs", + "typ": "entity-statement+jwt" + }, + "payload": { + "exp": 1649590602, + "iat": 1649417862, + "iss": "https://rp.example.it", + "sub": "https://rp.example.it", + "jwks": { + "keys": [ + { + "kty": "RSA", + "n": "5s4qi …", + "e": "AQAB", + "kid": "2HnoFS3YnC9tjiCaivhWLVUJ3AxwGGz_98uRFaqMEEs" + } + ] + }, + "metadata": { + "wallet_relying_party": { + "application_type": "web", + "client_id": "https://rp.example.it", + "client_name": "Name of an example organization", + "jwks": { + "keys": [ + { + "kty": "RSA", + "use": "sig", + "n": "1Ta-sE …", + "e": "AQAB", + "kid": "YhNFS3YnC9tjiCaivhWLVUJ3AxwGGz_98uRFaqMEEs", + "x5c": ["..."] + } + ] + }, + + "contacts": [ + "ops@verifier.example.org" + ], + + "request_uris": [ + "https://verifier.example.org/request_uri" + ], + "redirect_uris": [ + "https://verifier.example.org/callback" + ], + + "default_acr_values": [ + "https://www.spid.gov.it/SpidL2", + "https://www.spid.gov.it/SpidL3" + ], + + "vp_formats": { + "jwt_vp_json": { + "alg": [ + "EdDSA", + "ES256K" + ] + } + }, + "presentation_definitions": [ + { + "id": "pid-sd-jwt:unique_id+given_name+family_name", + "input_descriptors": [ + { + "id": "sd-jwt", + "format": { + "jwt": { + "alg": [ + "EdDSA", + "ES256" + ] + }, + "constraints": { + "limit_disclosure": "required", + "fields": [ + { + "path": [ + "$.sd-jwt.type" + ], + "filter": { + "type": "string", + "const": "PersonIdentificationData" + } + }, + { + "path": [ + "$.sd-jwt.cnf" + ], + "filter": { + "type": "object", + } + }, + { + "path": [ + "$.sd-jwt.family_name" + ], + "intent_to_retain": "true" + }, + { + "path": [ + "$.sd-jwt.given_name" + ], + "intent_to_retain": "true" + }, + { + "path": [ + "$.sd-jwt.unique_id" + ], + "intent_to_retain": "true" + } + ] + } + } + } + ] + }, + { + "id": "mDL-sample-req", + "input_descriptors": [ + { + "id": "mDL", + "format": { + "mso_mdoc": { + "alg": [ + "EdDSA", + "ES256" + ] + }, + "constraints": { + "limit_disclosure": "required", + "fields": [ + { + "path": [ + "$.mdoc.doctype" + ], + "filter": { + "type": "string", + "const": "org.iso.18013.5.1.mDL" + } + }, + { + "path": [ + "$.mdoc.namespace" + ], + "filter": { + "type": "string", + "const": "org.iso.18013.5.1" + } + }, + { + "path": [ + "$.mdoc.family_name" + ], + "intent_to_retain": "false" + }, + { + "path": [ + "$.mdoc.portrait" + ], + "intent_to_retain": "false" + }, + { + "path": [ + "$.mdoc.driving_privileges" + ], + "intent_to_retain": "false" + } + ] + } + } + } + ] + } + ], + + "default_max_age": 1111, + + "authorization_signed_response_alg": [ + "RS256", + "ES256" + ], + "authorization_encrypted_response_alg": [ + "RSA-OAEP", + "RSA-OAEP-256" + ], + "authorization_encrypted_response_enc": [ + "A128CBC-HS256", + "A192CBC-HS384", + "A256CBC-HS512", + "A128GCM", + "A192GCM", + "A256GCM" + ], + + "subject_type": "pairwise", + "require_auth_time": True, + "id_token_signed_response_alg": [ + "RS256", + "ES256" + ], + "id_token_encrypted_response_alg": [ + "RSA-OAEP", + "RSA-OAEP-256" + ], + "id_token_encrypted_response_enc": [ + "A128CBC-HS256", + "A192CBC-HS384", + "A256CBC-HS512", + "A128GCM", + "A192GCM", + "A256GCM" + ], + }, + "federation_entity": { + "organization_name": "OpenID Wallet Verifier example", + "homepage_uri": "https://verifier.example.org/home", + "policy_uri": "https://verifier.example.org/policy", + "logo_uri": "https://verifier.example.org/static/logo.svg", + "contacts": [ + "tech@verifier.example.org" + ] + } + }, + "authority_hints": [ + "https://registry.eudi-wallet.example.it" + ] + } +} + + +def test_entity_configuration_header(): + EntityConfigurationHeader(**ENTITY_CONFIGURATION["header"]) + + with pytest.raises(ValidationError): + EntityConfigurationHeader.model_validate( + ENTITY_CONFIGURATION["header"], context={"supported_algorithms": ["ES256"]}) + + ENTITY_CONFIGURATION["header"]["typ"] = "NOT-entity-statement+jwt" + with pytest.raises(ValidationError): + EntityConfigurationHeader(**ENTITY_CONFIGURATION["header"]) + + +def test_entity_configuration_payload(): + EntityConfigurationPayload(**ENTITY_CONFIGURATION["payload"]) + + ENTITY_CONFIGURATION["payload"]["jwks"]["keys"] = [] + EntityConfigurationPayload(**ENTITY_CONFIGURATION["payload"]) + del ENTITY_CONFIGURATION["payload"]["jwks"]["keys"] + with pytest.raises(ValidationError): + EntityConfigurationPayload(**ENTITY_CONFIGURATION["payload"]) + + ENTITY_CONFIGURATION["payload"]["jwks"]["keys"] = [{ + "kty": "RSA", + "n": "5s4qi …", + "e": "AQAB", + "kid": "2HnoFS3YnC9tjiCaivhWLVUJ3AxwGGz_98uRFaqMEEs" + }] diff --git a/pyeudiw/tests/federation/test_policy.py b/pyeudiw/tests/federation/test_policy.py new file mode 100644 index 00000000..6a235081 --- /dev/null +++ b/pyeudiw/tests/federation/test_policy.py @@ -0,0 +1,88 @@ + +from pyeudiw.federation.policy import ( + do_sub_one_super_add, PolicyError, do_value +) + + +def test_do_sub_one_super_add_subset_of(): + SUPERIOR = { + "subset_of": set(["test_a", "test_b"]) + } + + CHILD = { + "subset_of": set(["test_a", "test_d"]) + } + + policy = do_sub_one_super_add(SUPERIOR, CHILD, "subset_of") + assert policy == ['test_a'] + + +def test_do_sub_one_super_add_subset_of_fail(): + SUPERIOR = { + "subset_of": set(["test_a", "test_b"]) + } + + CHILD = { + "subset_of": set(["test_q", "test_d"]) + } + + try: + do_sub_one_super_add(SUPERIOR, CHILD, "subset_of") + except PolicyError: + return + + +def test_do_sub_one_super_add_combine_superset_of(): + SUPERIOR = { + "superset_of": set(["test_a", "test_b"]) + } + + CHILD = { + "superset_of": set(["test_a", "test_d"]) + } + + policy = do_sub_one_super_add(SUPERIOR, CHILD, "superset_of") + assert policy == ['test_a'] + + +def test_do_superset_of_fail(): + SUPERIOR = { + "superset_of": set(["test_a", "test_b"]) + } + + CHILD = { + "superset_of": set(["test_q", "test_d"]) + } + + try: + do_sub_one_super_add(SUPERIOR, CHILD, "superset_of") + except PolicyError: + return + + +def test_do_value_superset_of(): + SUPERIOR = { + "superset_of": set(["test_a", "test_b"]) + } + + CHILD = { + "superset_of": set(["test_a", "test_b"]) + } + + policy = do_value(SUPERIOR, CHILD, "superset_of") + assert policy == set(["test_a", "test_b"]) + + +def test_do_value_superset_of_fail(): + SUPERIOR = { + "superset_of": set(["test_a", "test_b"]) + } + + CHILD = { + "superset_of": set(["test_q", "test_d"]) + } + + try: + do_value(SUPERIOR, CHILD, "superset_of") + except PolicyError: + return diff --git a/pyeudiw/tests/federation/test_schema.py b/pyeudiw/tests/federation/test_schema.py new file mode 100644 index 00000000..e0e2e39d --- /dev/null +++ b/pyeudiw/tests/federation/test_schema.py @@ -0,0 +1,51 @@ + +from pyeudiw.tools.utils import iat_now, exp_from_now +from pyeudiw.federation.schema import is_es, is_ec + +NOW = iat_now() +EXP = exp_from_now(5) + +ta_es = { + "exp": EXP, + "iat": NOW, + "iss": "https://trust-anchor.example.eu", + "sub": "https://intermediate.eidas.example.org", + 'jwks': {"keys": []}, + "source_endpoint": "https://rp.example.it" +} + +ta_ec = { + "exp": EXP, + "iat": NOW, + 'iss': 'https://registry.eidas.trust-anchor.example.eu/', + 'sub': 'https://registry.eidas.trust-anchor.example.eu/', + 'jwks': {"keys": []}, + 'metadata': {'federation_entity': {'organization_name': 'example TA', + 'contacts': ['tech@eidas.trust-anchor.example.eu'], + 'homepage_uri': 'https://registry.eidas.trust-anchor.example.eu/', + 'logo_uri': 'https://registry.eidas.trust-anchor.example.eu/static/svg/logo.svg', + 'federation_fetch_endpoint': 'https://registry.eidas.trust-anchor.example.eu/fetch/', + 'federation_resolve_endpoint': 'https://registry.eidas.trust-anchor.example.eu/resolve/', + 'federation_list_endpoint': 'https://registry.eidas.trust-anchor.example.eu/list/', + 'federation_trust_mark_status_endpoint': 'https://registry.eidas.trust-anchor.example.eu/trust_mark_status/'}}, + 'trust_marks_issuers': {'https://registry.eidas.trust-anchor.example.eu/openid_relying_party/public/': ['https://registry.spid.eidas.trust-anchor.example.eu/', + 'https://public.intermediary.spid.org/'], + 'https://registry.eidas.trust-anchor.example.eu/openid_relying_party/private/': ['https://registry.spid.eidas.trust-anchor.example.eu/', + 'https://private.other.intermediary.org/']}, + 'constraints': {'max_path_length': 1}} + + +def test_is_es(): + assert is_es(ta_es) + + +def test_is_es_false(): + assert not is_es(ta_ec) + + +def test_is_ec(): + assert is_ec(ta_ec) + + +def test_is_ec_false(): + assert not is_ec(ta_es) diff --git a/pyeudiw/tests/federation/test_static_trust_chain_validator.py b/pyeudiw/tests/federation/test_static_trust_chain_validator.py new file mode 100644 index 00000000..e59cdcfa --- /dev/null +++ b/pyeudiw/tests/federation/test_static_trust_chain_validator.py @@ -0,0 +1,139 @@ +import copy +import uuid +import unittest.mock as mock +from unittest.mock import Mock +from pyeudiw.federation.trust_chain_validator import StaticTrustChainValidator +import pyeudiw.federation.trust_chain_validator as tcv_test + + +from . base import EXP, JWS, NOW, intermediate_ec_signed, intermediate_es, intermediate_jwk, leaf_ec_signed, leaf_jwk, ta_es, ta_es_signed, ta_jwk, trust_chain + + +def test_is_valid(): + assert StaticTrustChainValidator( + trust_chain, [ta_jwk.serialize()]).is_valid + + +invalid_intermediate = copy.deepcopy(intermediate_es) +invalid_leaf_jwk = copy.deepcopy(leaf_jwk.serialize()) +invalid_leaf_jwk["kid"] = str(uuid.uuid4()) + +invalid_intermediate["jwks"]['keys'] = [invalid_leaf_jwk] + +intermediate_signer = JWS( + invalid_intermediate, alg="RS256", + typ="application/entity-statement+jwt" +) +invalid_intermediate_es_signed = intermediate_signer.sign_compact( + [intermediate_jwk] +) + +invalid_trust_chain = [ + leaf_ec_signed, + invalid_intermediate_es_signed, + ta_es_signed +] + + +def test_is_valid_equals_false(): + assert not StaticTrustChainValidator( + invalid_trust_chain, [ta_jwk.serialize()] + ).is_valid + + +def test_retrieve_ec(): + tcv_test.get_entity_configurations = Mock(return_value=[leaf_ec_signed]) + + assert tcv_test.StaticTrustChainValidator( + invalid_trust_chain, [ta_jwk.serialize()])._retrieve_ec("https://trust-anchor.example.eu") == leaf_ec_signed + + +def test_retrieve_ec_fails(): + tcv_test.get_entity_configurations = Mock(return_value=[]) + + try: + StaticTrustChainValidator( + invalid_trust_chain, [ta_jwk.serialize()])._retrieve_ec("https://trust-anchor.example.eu") + except tcv_test.HttpError as e: + return + + +def test_retrieve_es(): + tcv_test.get_entity_statements = Mock(return_value=ta_es) + + assert tcv_test.StaticTrustChainValidator( + invalid_trust_chain, [ta_jwk.serialize()])._retrieve_es("https://trust-anchor.example.eu", "https://trust-anchor.example.eu") == ta_es + + +def test_retrieve_es_output_is_none(): + tcv_test.get_entity_statements = Mock(return_value=None) + + assert tcv_test.StaticTrustChainValidator( + invalid_trust_chain, [ta_jwk.serialize()])._retrieve_es("https://trust-anchor.example.eu", "https://trust-anchor.example.eu") == None + + +def test_update_st_ec_case(): + def mock_method(*args, **kwargs): + if args[0] == "https://rp.example.it": + return [leaf_ec_signed] + + raise Exception("Wrong issuer") + + with mock.patch.object(tcv_test, "get_entity_configurations", mock_method): + assert tcv_test.StaticTrustChainValidator( + invalid_trust_chain, [ta_jwk.serialize()])._update_st(leaf_ec_signed) == leaf_ec_signed + + +def test_update_st_es_case_source_endpoint(): + ta_es = { + "exp": EXP, + "iat": NOW, + "iss": "https://trust-anchor.example.eu", + "sub": "https://intermediate.eidas.example.org", + 'jwks': {"keys": []}, + "source_endpoint": "https://rp.example.it" + } + + ta_signer = JWS(ta_es, alg="RS256", typ="application/entity-statement+jwt") + ta_es_signed = ta_signer.sign_compact([ta_jwk]) + + def mock_method(*args, **kwargs): + if args[0] == "https://rp.example.it": + return leaf_ec_signed + + raise Exception("Wrong issuer") + + with mock.patch.object(tcv_test, "get_entity_statements", mock_method): + assert tcv_test.StaticTrustChainValidator( + invalid_trust_chain, [ta_jwk.serialize()])._update_st(ta_es_signed) == leaf_ec_signed + + +def test_update_st_es_case_no_source_endpoint(): + + ta_es = { + "exp": EXP, + "iat": NOW, + "iss": "https://trust-anchor.example.eu", + "sub": "https://intermediate.eidas.example.org", + 'jwks': {"keys": []}, + } + + ta_signer = JWS(ta_es, alg="RS256", typ="application/entity-statement+jwt") + ta_es_signed = ta_signer.sign_compact([ta_jwk]) + + def mock_method_ec(*args, **kwargs): + if args[0] == "https://trust-anchor.example.eu": + return [intermediate_ec_signed] + raise Exception("Wrong issuer") + + def mock_method_es(*args, **kwargs): + if args[0] == "https://intermediate.eidas.example.org/fetch/": + return leaf_ec_signed + raise Exception("Wrong issuer") + + with mock.patch.object(tcv_test, "get_entity_statements", mock_method_es): + with mock.patch.object(tcv_test, "get_entity_configurations", mock_method_ec): + + assert tcv_test.StaticTrustChainValidator( + invalid_trust_chain, [ta_jwk.serialize()] + )._update_st(ta_es_signed) == leaf_ec_signed diff --git a/pyeudiw/tests/federation/test_trust_chain_builder.py b/pyeudiw/tests/federation/test_trust_chain_builder.py new file mode 100644 index 00000000..28ddc8b7 --- /dev/null +++ b/pyeudiw/tests/federation/test_trust_chain_builder.py @@ -0,0 +1,28 @@ +from pyeudiw.federation.trust_chain_builder import TrustChainBuilder +from pyeudiw.federation.statements import get_entity_configurations, EntityStatement + +from . base import * +from . mocked_response import * + +from unittest.mock import patch + + +@patch("requests.get", return_value=EntityResponseNoIntermediate()) +def test_trust_chain_valid_no_intermediaries(self, mocker): + + jwt = get_entity_configurations([ta_ec["sub"]])[0] + trust_anchor_ec = EntityStatement(jwt) + trust_anchor_ec.validate_by_itself() + + trust_chain = TrustChainBuilder( + subject=leaf_ec["sub"], + trust_anchor=trust_anchor_ec.sub, + trust_anchor_configuration=trust_anchor_ec + ) + + trust_chain.start() + trust_chain.apply_metadata_policy() + + assert trust_chain.is_valid + assert trust_chain.final_metadata + assert len(trust_chain.trust_path) == 3 diff --git a/pyeudiw/tests/oauth2/test_dpop.py b/pyeudiw/tests/oauth2/test_dpop.py index 79b463e2..b5cd78a3 100644 --- a/pyeudiw/tests/oauth2/test_dpop.py +++ b/pyeudiw/tests/oauth2/test_dpop.py @@ -2,13 +2,12 @@ import pytest -from pyeudiw.oauth2.dpop import DPoPIssuer, DPoPVerifier from pyeudiw.jwk import JWK from pyeudiw.jwt import JWSHelper -from pyeudiw.jwt.utils import unpad_jwt_payload, unpad_jwt_header +from pyeudiw.jwt.utils import unpad_jwt_header, unpad_jwt_payload +from pyeudiw.oauth2.dpop import DPoPIssuer, DPoPVerifier from pyeudiw.tools.utils import iat_now - PRIVATE_JWK = JWK() PUBLIC_JWK = PRIVATE_JWK.public_key diff --git a/pyeudiw/tests/tools/test_sd_jwt.py b/pyeudiw/tests/oauth2/test_sd_jwt.py similarity index 93% rename from pyeudiw/tests/tools/test_sd_jwt.py rename to pyeudiw/tests/oauth2/test_sd_jwt.py index 0a12754b..fde210a7 100644 --- a/pyeudiw/tests/tools/test_sd_jwt.py +++ b/pyeudiw/tests/oauth2/test_sd_jwt.py @@ -1,11 +1,11 @@ import uuid -from pyeudiw.jwk import JWK -from pyeudiw.sd_jwt import ( - issue_sd_jwt, verify_sd_jwt, _adapt_keys, load_specification_from_yaml_string) - from sd_jwt.holder import SDJWTHolder +from pyeudiw.jwk import JWK +from pyeudiw.sd_jwt import (_adapt_keys, issue_sd_jwt, + load_specification_from_yaml_string, verify_sd_jwt) + settings = { "issuer": "http://test.com", "default_exp": 60, diff --git a/pyeudiw/tests/openid4vp/__init__.py b/pyeudiw/tests/openid4vp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pyeudiw/tests/openid4vp/schemas/__init__.py b/pyeudiw/tests/openid4vp/schemas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pyeudiw/tests/openid4vp/schemas/test_schema.py b/pyeudiw/tests/openid4vp/schemas/test_schema.py new file mode 100644 index 00000000..9a3007a3 --- /dev/null +++ b/pyeudiw/tests/openid4vp/schemas/test_schema.py @@ -0,0 +1,326 @@ +### DO NOT TRACK ON GIT ### +import pytest +from pydantic import ValidationError + +from pyeudiw.federation.schemas.entity_configuration import ( + EntityConfigurationHeader, EntityConfigurationPayload) +from pyeudiw.openid4vp.schemas.wallet_instance_attestation_request import ( + WalletInstanceAttestationRequestHeader, + WalletInstanceAttestationRequestPayload) + + +def test_wir(): + wir_dict = { + "header": { + "alg": "RS256", + "kid": "NjVBRjY5MDlCMUIwNzU4RTA2QzZFMDQ4QzQ2MDAyQjVDNjk1RTM2Qg", + "typ": "var+jwt" + }, + "payload": { + "iss": "vbeXJksM45xphtANnCiG6mCyuU4jfGNzopGuKvogg9c", + "aud": "https://wallet-provider.example.org", + "jti": "6ec69324-60a8-4e5b-a697-a766d85790ea", + "type": "WalletInstanceAttestationRequest", + "nonce": ".....", + "cnf": { + "jwk": { + "alg": "RS256", + "kty": "RSA", + "use": "sig", + "x5c": [ + "MIIC+DCCAeCgAwIBAgIJBIGjYW6hFpn2MA0GCSqGSIb3DQEBBQUAMCMxITAfBgNVBAMTGGN1c3RvbWVyLWRlbW9zLmF1dGgwLmNvbTAeFw0xNjExMjIyMjIyMDVaFw0zMDA4MDEyMjIyMDVaMCMxITAfBgNVBAMTGGN1c3RvbWVyLWRlbW9zLmF1dGgwLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMnjZc5bm/eGIHq09N9HKHahM7Y31P0ul+A2wwP4lSpIwFrWHzxw88/7Dwk9QMc+orGXX95R6av4GF+Es/nG3uK45ooMVMa/hYCh0Mtx3gnSuoTavQEkLzCvSwTqVwzZ+5noukWVqJuMKNwjL77GNcPLY7Xy2/skMCT5bR8UoWaufooQvYq6SyPcRAU4BtdquZRiBT4U5f+4pwNTxSvey7ki50yc1tG49Per/0zA4O6Tlpv8x7Red6m1bCNHt7+Z5nSl3RX/QYyAEUX1a28VcYmR41Osy+o2OUCXYdUAphDaHo4/8rbKTJhlu8jEcc1KoMXAKjgaVZtG/v5ltx6AXY0CAwEAAaMvMC0wDAYDVR0TBAUwAwEB/zAdBgNVHQ4EFgQUQxFG602h1cG+pnyvJoy9pGJJoCswDQYJKoZIhvcNAQEFBQADggEBAGvtCbzGNBUJPLICth3mLsX0Z4z8T8iu4tyoiuAshP/Ry/ZBnFnXmhD8vwgMZ2lTgUWwlrvlgN+fAtYKnwFO2G3BOCFw96Nm8So9sjTda9CCZ3dhoH57F/hVMBB0K6xhklAc0b5ZxUpCIN92v/w+xZoz1XQBHe8ZbRHaP1HpRM4M7DJk2G5cgUCyu3UBvYS41sHvzrxQ3z7vIePRA4WF4bEkfX12gvny0RsPkrbVMXX1Rj9t6V7QXrbPYBAO+43JvDGYawxYVvLhz+BJ45x50GFQmHszfY3BR9TPK8xmMmQwtIvLu1PMttNCs7niCYkSiUv2sc2mlq1i3IashGkkgmo=" + ], + "n": "yeNlzlub94YgerT030codqEztjfU_S6X4DbDA_iVKkjAWtYfPHDzz_sPCT1Axz6isZdf3lHpq_gYX4Sz-cbe4rjmigxUxr-FgKHQy3HeCdK6hNq9ASQvMK9LBOpXDNn7mei6RZWom4wo3CMvvsY1w8tjtfLb-yQwJPltHxShZq5-ihC9irpLI9xEBTgG12q5lGIFPhTl_7inA1PFK97LuSLnTJzW0bj096v_TMDg7pOWm_zHtF53qbVsI0e3v5nmdKXdFf9BjIARRfVrbxVxiZHjU6zL6jY5QJdh1QCmENoejj_ytspMmGW7yMRxzUqgxcAqOBpVm0b-_mW3HoBdjQ", + "e": "AQAB", + "kid": "NjVBRjY5MDlCMUIwNzU4RTA2QzZFMDQ4QzQ2MDAyQjVDNjk1RTM2Qg", + "x5t": "NjVBRjY5MDlCMUIwNzU4RTA2QzZFMDQ4QzQ2MDAyQjVDNjk1RTM2Qg" + } + }, + "iat": 1686645115, + "exp": 1686652315 + }} + + wir_header = WalletInstanceAttestationRequestHeader(**wir_dict["header"]) + wir_payload = WalletInstanceAttestationRequestPayload( + **wir_dict["payload"]) + + wir_header = WalletInstanceAttestationRequestHeader.model_validate( + wir_dict["header"], context={"supported_algorithms": ["RS256"]}) + with pytest.raises(ValidationError): + wir_header = WalletInstanceAttestationRequestHeader.model_validate( + wir_dict["header"], context={"supported_algorithms": []}) + with pytest.raises(ValidationError): + wir_header = WalletInstanceAttestationRequestHeader.model_validate( + wir_dict["header"], context={"supported_algorithms": None}) + with pytest.raises(ValidationError): + wir_header = WalletInstanceAttestationRequestHeader.model_validate( + wir_dict["header"], context={"supported_algorithms": ["RS384"]}) + + wir_dict["payload"]["type"] = "NOT_WalletInstanceAttestationRequest" + with pytest.raises(ValidationError): + wir_payload = WalletInstanceAttestationRequestPayload.model_validate( + wir_dict["payload"], context={"supported_algorithms": ["RS256"]}) + wir_dict["payload"]["type"] = "WalletInstanceAttestationRequest" + + wir_dict["payload"]["cnf"] = { + "wrong_name_jwk": wir_dict["payload"]["cnf"]["jwk"]} + with pytest.raises(ValidationError): + wir_payload = WalletInstanceAttestationRequestPayload.model_validate( + wir_dict["payload"], context={"supported_algorithms": ["RS256"]}) + wir_dict["payload"]["cnf"] = { + "jwk": wir_dict["payload"]["cnf"]["wrong_name_jwk"]} + + +def test_entity_config_header(): + header = { + "alg": "RS256", + "kid": "2HnoFS3YnC9tjiCaivhWLVUJ3AxwGGz_98uRFaqMEEs", + "typ": "entity-statement+jwt" + } + EntityConfigurationHeader(**header) + + header['typ'] = "entity-config+jwt" + with pytest.raises(ValidationError): + EntityConfigurationHeader(**header) + header['typ'] = "entity-statement+jwt" + + with pytest.raises(ValidationError): + EntityConfigurationHeader.model_validate( + header, context={"supported_algorithms": []}) + + with pytest.raises(ValidationError): + EntityConfigurationHeader.model_validate( + header, context={"supported_algorithms": ["asd"]}) + + EntityConfigurationHeader.model_validate( + header, context={"supported_algorithms": ["RS256"]}) + + +def test_entity_config_payload(): + payload = { + "exp": 1649590602, + "iat": 1649417862, + "iss": "https://rp.example.it", + "sub": "https://rp.example.it", + "jwks": { + "keys": [ + { + "kty": "RSA", + "n": "5s4qi …", + "e": "AQAB", + "kid": "2HnoFS3YnC9tjiCaivhWLVUJ3AxwGGz_98uRFaqMEEs" + } + ] + }, + "metadata": { + "wallet_relying_party": { + "application_type": "web", + "client_id": "https://rp.example.it", + "client_name": "Name of an example organization", + "jwks": { + "keys": [ + { + "kty": "RSA", + "use": "sig", + "n": "1Ta-sE …", + "e": "AQAB", + "kid": "YhNFS3YnC9tjiCaivhWLVUJ3AxwGGz_98uRFaqMEEs", + "x5c": [ + "..." + ] + } + ] + }, + "contacts": [ + "ops@verifier.example.org" + ], + "request_uris": [ + "https://verifier.example.org/request_uri" + ], + "redirect_uris": [ + "https://verifier.example.org/callback" + ], + "default_acr_values": [ + "https://www.spid.gov.it/SpidL2", + "https://www.spid.gov.it/SpidL3" + ], + "vp_formats": { + "jwt_vp_json": { + "alg": [ + "EdDSA", + "ES256K" + ] + } + }, + "presentation_definitions": [ + { + "id": "pid-sd-jwt:unique_id+given_name+family_name", + "input_descriptors": [ + { + "id": "sd-jwt", + "format": { + "jwt": { + "alg": [ + "EdDSA", + "ES256" + ] + }, + "constraints": { + "limit_disclosure": "required", + "fields": [ + { + "path": [ + "$.sd-jwt.type" + ], + "filter": { + "type": "string", + "const": "PersonIdentificationData" + } + }, + { + "path": [ + "$.sd-jwt.cnf" + ], + "filter": { + "type": "object" + } + }, + { + "path": [ + "$.sd-jwt.family_name" + ], + "intent_to_retain": "true" + }, + { + "path": [ + "$.sd-jwt.given_name" + ], + "intent_to_retain": "true" + }, + { + "path": [ + "$.sd-jwt.unique_id" + ], + "intent_to_retain": "true" + } + ] + } + } + } + ] + }, + { + "id": "mDL-sample-req", + "input_descriptors": [ + { + "id": "mDL", + "format": { + "mso_mdoc": { + "alg": [ + "EdDSA", + "ES256" + ] + }, + "constraints": { + "limit_disclosure": "required", + "fields": [ + { + "path": [ + "$.mdoc.doctype" + ], + "filter": { + "type": "string", + "const": "org.iso.18013.5.1.mDL" + } + }, + { + "path": [ + "$.mdoc.namespace" + ], + "filter": { + "type": "string", + "const": "org.iso.18013.5.1" + } + }, + { + "path": [ + "$.mdoc.family_name" + ], + "intent_to_retain": "false" + }, + { + "path": [ + "$.mdoc.portrait" + ], + "intent_to_retain": "false" + }, + { + "path": [ + "$.mdoc.driving_privileges" + ], + "intent_to_retain": "false" + } + ] + } + } + } + ] + } + ], + "default_max_age": 1111, + "authorization_signed_response_alg": [ + "RS256", + "ES256" + ], + "authorization_encrypted_response_alg": [ + "RSA-OAEP", + "RSA-OAEP-256" + ], + "authorization_encrypted_response_enc": [ + "A128CBC-HS256", + "A192CBC-HS384", + "A256CBC-HS512", + "A128GCM", + "A192GCM", + "A256GCM" + ], + "subject_type": "pairwise", + "require_auth_time": True, + "id_token_signed_response_alg": [ + "RS256", + "ES256" + ], + "id_token_encrypted_response_alg": [ + "RSA-OAEP", + "RSA-OAEP-256" + ], + "id_token_encrypted_response_enc": [ + "A128CBC-HS256", + "A192CBC-HS384", + "A256CBC-HS512", + "A128GCM", + "A192GCM", + "A256GCM" + ] + }, + "federation_entity": { + "organization_name": "OpenID Wallet Verifier example", + "homepage_uri": "https://verifier.example.org/home", + "policy_uri": "https://verifier.example.org/policy", + "logo_uri": "https://verifier.example.org/static/logo.svg", + "contacts": [ + "tech@verifier.example.org" + ] + } + }, + "authority_hints": [ + "https://registry.eudi-wallet.example.it" + ] + } + EntityConfigurationPayload(**payload) + with pytest.raises(ValidationError): + EntityConfigurationPayload.model_validate( + payload, context={"authorization_encrypted_response_alg": ["ASD"]}) + with pytest.raises(ValidationError): + EntityConfigurationPayload.model_validate( + payload, context={"authorization_encrypted_response_alg": []}) diff --git a/pyeudiw/tests/openid4vp/schemas/test_vp_token.py b/pyeudiw/tests/openid4vp/schemas/test_vp_token.py new file mode 100644 index 00000000..b1007b52 --- /dev/null +++ b/pyeudiw/tests/openid4vp/schemas/test_vp_token.py @@ -0,0 +1,52 @@ +import pytest +from pydantic import ValidationError + +from pyeudiw.openid4vp.schemas.vp_token import VPTokenHeader, VPTokenPayload + +VP_TOKEN = { + "header": { + "alg": "ES256", + "typ": "JWT", + "kid": "e0bbf2f1-8c3a-4eab-a8ac-2e8f34db8a47" + }, + "payload": { + "iss": "https://wallet-provider.example.org/instance/vbeXJksM45xphtANnCiG6mCyuU4jfGNzopGuKvogg9c", + "jti": "3978344f-8596-4c3a-a978-8fcaba3903c5", + "aud": "https://verifier.example.org/callback", + "iat": 1541493724, + "exp": 1573029723, + "nonce": "2c128e4d-fc91-4cd3-86b8-18bdea0988cb", + "vp": "eyJhbGciOiAiRVMyNTYifQ.eyJfc2QiOiBbIkc0UlAyQnhBNW50VUtYNGVhclR0cEo3TUJ1RWwyRzBueFRPU0h4X05POVUiLCAiZTJIa20tLUM3c1ZYTnV2UG5tVFptRnFKNDIxUDR0eWRBTUhfTDRvaWNjVSIsICJ2VWNWSFp5Q0thdXN2X003TGdXa1NqRzRXSUJQaWV1S09HOUE3TjJ2ZUpjIl0sICJpc3MiOiAiaHR0cHM6Ly9leGFtcGxlLmNvbS9pc3N1ZXIiLCAiaWF0IjogMTY5MDk1NTUzNSwgImV4cCI6IDE2OTA5NTY0MzUsICJzdWIiOiAiNmM1YzBhNDktYjU4OS00MzFkLWJhZTctMjE5MTIyYTllYzJjIiwgIl9zZF9hbGciOiAic2hhLTI1NiIsICJjbmYiOiB7Imp3ayI6IHsia3R5IjogIkVDIiwgImNydiI6ICJQLTI1NiIsICJ4IjogIlRDQUVSMTladnUzT0hGNGo0VzR2ZlNWb0hJUDFJTGlsRGxzN3ZDZUdlbWMiLCAieSI6ICJaeGppV1diWk1RR0hWV0tWUTRoYlNJaXJzVmZ1ZWNDRTZ0NGpUOUYySFpRIn19fQ.YvGqqjp3NjFlOIz6furIKHDYzZibhZPj36vtwgH7fTbgSshCvvvzfTOcwtNA0K3M9wZw7v0BQWdlkLx3SkUJfg~WyI2NFE3OXJKdWkyOVJxWWdHdGpTQ0dBIiwgImFkZHJlc3MiLCB7InN0cmVldF9hZGRyZXNzIjogIlNjaHVsc3RyLiAxMiIsICJsb2NhbGl0eSI6ICJTY2h1bHBmb3J0YSIsICJyZWdpb24iOiAiU2FjaHNlbi1BbmhhbHQiLCAiY291bnRyeSI6ICJERSJ9XQ~WyJjR1ctZl9NVmlJUnp6M0Q1QVNxOUt3IiwgImVtYWlsIiwgIm1heEBob21lLmNvbSJd~eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImtiK2p3dCJ9.eyJub25jZSI6ICIyZjlkZWE4YTBkYmY1ZGRiN2NlOWQyZmRlOWZiOGJkNiIsICJhdWQiOiAiaHR0cHM6Ly9leGFtcGxlLmNvbS92ZXJpZmllciIsICJpYXQiOiAxNjkwOTYyNzM1fQ.ScCgejwnR7fdF2trKDSJooNKWiz6-dLQGlQzRK-NVMSayKWXxj6Ebxwleb2MS_SbSHYHN2GygLw5NNyXV_3TlA" + # "vp": "~~~...~" + } +} + + +def test_vp_token_header(): + VPTokenHeader(**VP_TOKEN['header']) + # alg is ES256 + # it should fail if alg is not in supported_algorithms + with pytest.raises(ValidationError): + VPTokenHeader.model_validate( + VP_TOKEN['header'], context={"supported_algorithms": None}) + with pytest.raises(ValidationError): + VPTokenHeader.model_validate( + VP_TOKEN['header'], context={"supported_algorithms": []}) + with pytest.raises(ValidationError): + VPTokenHeader.model_validate( + VP_TOKEN['header'], context={"supported_algorithms": ["asd"]}) + + VPTokenHeader.model_validate( + VP_TOKEN['header'], context={"supported_algorithms": ["ES256"]}) + + +def test_vp_token_payload(): + VPTokenPayload(**VP_TOKEN['payload']) + # it should fail on SD-JWT format or missing vp + VP_TOKEN["payload"]["vp"] = VP_TOKEN["payload"]["vp"].replace("~", ".") + with pytest.raises(ValidationError): + VPTokenPayload(**VP_TOKEN['payload']) + VP_TOKEN["payload"]["vp"] = VP_TOKEN["payload"]["vp"].replace(".", "~") + del VP_TOKEN["payload"]["vp"] + with pytest.raises(ValidationError): + VPTokenPayload(**VP_TOKEN['payload']) diff --git a/pyeudiw/tests/openid4vp/schemas/test_wallet_instance_attestation.py b/pyeudiw/tests/openid4vp/schemas/test_wallet_instance_attestation.py new file mode 100644 index 00000000..a75727fd --- /dev/null +++ b/pyeudiw/tests/openid4vp/schemas/test_wallet_instance_attestation.py @@ -0,0 +1,136 @@ +import pytest +from pydantic import ValidationError + +from pyeudiw.openid4vp.schemas.wallet_instance_attestation import WalletInstanceAttestationHeader, \ + WalletInstanceAttestationPayload + +WALLET_INSTANCE_ATTESTATION = { + "header": { + "alg": "RS256", + "kid": "NjVBRjY5MDlCMUIwNzU4RTA2QzZFMDQ4QzQ2MDAyQjVDNjk1RTM2Qg", + "trust_chain": [ + "eyJhbGciOiJFUz...6S0A", + "eyJhbGciOiJFUz...jJLA", + "eyJhbGciOiJFUz...H9gw", + ], + "typ": "wallet-attestation+jwt", + "x5c": ["MIIBjDCC ... XFehgKQA=="] + }, + "payload": { + "iss": "https://wallet-provider.example.org", + "sub": "vbeXJksM45xphtANnCiG6mCyuU4jfGNzopGuKvogg9c", + "type": "WalletInstanceAttestation", + "policy_uri": "https://wallet-provider.example.org/privacy_policy", + "tos_uri": "https://wallet-provider.example.org/info_policy", + "logo_uri": "https://wallet-provider.example.org/logo.svg", + "attested_security_context": "https://wallet-provider.example.org/LoA/basic", + "cnf": { + "jwk": { + "alg": "RS256", + "kty": "RSA", + "use": "sig", + "x5c": [ + "MIIC+DCCAeCgAwIBAgIJBIGjYW6hFpn2MA0GCSqGSIb3DQEBBQUAMCMxITAfBgNVBAMTGGN1c3RvbWVyLWRlbW9zLmF1dGgwLmNvbTAeFw0xNjExMjIyMjIyMDVaFw0zMDA4MDEyMjIyMDVaMCMxITAfBgNVBAMTGGN1c3RvbWVyLWRlbW9zLmF1dGgwLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMnjZc5bm/eGIHq09N9HKHahM7Y31P0ul+A2wwP4lSpIwFrWHzxw88/7Dwk9QMc+orGXX95R6av4GF+Es/nG3uK45ooMVMa/hYCh0Mtx3gnSuoTavQEkLzCvSwTqVwzZ+5noukWVqJuMKNwjL77GNcPLY7Xy2/skMCT5bR8UoWaufooQvYq6SyPcRAU4BtdquZRiBT4U5f+4pwNTxSvey7ki50yc1tG49Per/0zA4O6Tlpv8x7Red6m1bCNHt7+Z5nSl3RX/QYyAEUX1a28VcYmR41Osy+o2OUCXYdUAphDaHo4/8rbKTJhlu8jEcc1KoMXAKjgaVZtG/v5ltx6AXY0CAwEAAaMvMC0wDAYDVR0TBAUwAwEB/zAdBgNVHQ4EFgQUQxFG602h1cG+pnyvJoy9pGJJoCswDQYJKoZIhvcNAQEFBQADggEBAGvtCbzGNBUJPLICth3mLsX0Z4z8T8iu4tyoiuAshP/Ry/ZBnFnXmhD8vwgMZ2lTgUWwlrvlgN+fAtYKnwFO2G3BOCFw96Nm8So9sjTda9CCZ3dhoH57F/hVMBB0K6xhklAc0b5ZxUpCIN92v/w+xZoz1XQBHe8ZbRHaP1HpRM4M7DJk2G5cgUCyu3UBvYS41sHvzrxQ3z7vIePRA4WF4bEkfX12gvny0RsPkrbVMXX1Rj9t6V7QXrbPYBAO+43JvDGYawxYVvLhz+BJ45x50GFQmHszfY3BR9TPK8xmMmQwtIvLu1PMttNCs7niCYkSiUv2sc2mlq1i3IashGkkgmo=" + ], + "n": "yeNlzlub94YgerT030codqEztjfU_S6X4DbDA_iVKkjAWtYfPHDzz_sPCT1Axz6isZdf3lHpq_gYX4Sz-cbe4rjmigxUxr-FgKHQy3HeCdK6hNq9ASQvMK9LBOpXDNn7mei6RZWom4wo3CMvvsY1w8tjtfLb-yQwJPltHxShZq5-ihC9irpLI9xEBTgG12q5lGIFPhTl_7inA1PFK97LuSLnTJzW0bj096v_TMDg7pOWm_zHtF53qbVsI0e3v5nmdKXdFf9BjIARRfVrbxVxiZHjU6zL6jY5QJdh1QCmENoejj_ytspMmGW7yMRxzUqgxcAqOBpVm0b-_mW3HoBdjQ", + "e": "AQAB", + "kid": "NjVBRjY5MDlCMUIwNzU4RTA2QzZFMDQ4QzQ2MDAyQjVDNjk1RTM2Qg", + "x5t": "NjVBRjY5MDlCMUIwNzU4RTA2QzZFMDQ4QzQ2MDAyQjVDNjk1RTM2Qg" + } + }, + "authorization_endpoint": "eudiw:", + "response_types_supported": [ + "vp_token" + ], + "vp_formats_supported": { + "jwt_vp_json": { + "alg_values_supported": ["RS256"] + }, + "jwt_vc_json": { + "alg_values_supported": ["RS256"] + } + }, + "request_object_signing_alg_values_supported": [ + "RS256" + ], + "presentation_definition_uri_supported": False, + "iat": 1687281195, + "exp": 1687288395 + } +} + + +def test_header(): + WalletInstanceAttestationHeader(**WALLET_INSTANCE_ATTESTATION['header']) + # alg is RS256 + # it should fail if alg is not in supported_algorithms + with pytest.raises(ValidationError): + WalletInstanceAttestationHeader.model_validate( + WALLET_INSTANCE_ATTESTATION['header'], context={"supported_algorithms": None}) + with pytest.raises(ValidationError): + WalletInstanceAttestationHeader.model_validate( + WALLET_INSTANCE_ATTESTATION['header'], context={"supported_algorithms": []}) + with pytest.raises(ValidationError): + WalletInstanceAttestationHeader.model_validate( + WALLET_INSTANCE_ATTESTATION['header'], context={"supported_algorithms": ["asd"]}) + + WalletInstanceAttestationHeader.model_validate( + WALLET_INSTANCE_ATTESTATION['header'], context={"supported_algorithms": ["RS256"]}) + + # x5c and trust_chain are not required + WALLET_INSTANCE_ATTESTATION['header']['x5c'] = None + WALLET_INSTANCE_ATTESTATION['header']['trust_chain'] = None + WalletInstanceAttestationHeader(**WALLET_INSTANCE_ATTESTATION['header']) + del WALLET_INSTANCE_ATTESTATION['header']['x5c'] + del WALLET_INSTANCE_ATTESTATION['header']['trust_chain'] + WalletInstanceAttestationHeader(**WALLET_INSTANCE_ATTESTATION['header']) + + # kid is required + WALLET_INSTANCE_ATTESTATION['header']['kid'] = None + with pytest.raises(ValidationError): + WalletInstanceAttestationHeader( + **WALLET_INSTANCE_ATTESTATION['header']) + del WALLET_INSTANCE_ATTESTATION['header']['kid'] + with pytest.raises(ValidationError): + WalletInstanceAttestationHeader( + **WALLET_INSTANCE_ATTESTATION['header']) + + # typ must be "wallet-attestation-jwt" + WALLET_INSTANCE_ATTESTATION['header']['typ'] = "asd" + with pytest.raises(ValidationError): + WalletInstanceAttestationHeader( + **WALLET_INSTANCE_ATTESTATION['header']) + + +def test_payload(): + WalletInstanceAttestationPayload(**WALLET_INSTANCE_ATTESTATION['payload']) + WalletInstanceAttestationPayload.model_validate( + WALLET_INSTANCE_ATTESTATION['payload']) + + # iss is not HttpUrl + WALLET_INSTANCE_ATTESTATION['payload']['iss'] = WALLET_INSTANCE_ATTESTATION['payload']['iss'][4:] + with pytest.raises(ValidationError): + WalletInstanceAttestationPayload.model_validate( + WALLET_INSTANCE_ATTESTATION['payload']) + WALLET_INSTANCE_ATTESTATION['payload']['iss'] = "http" + \ + WALLET_INSTANCE_ATTESTATION['payload']['iss'] + WalletInstanceAttestationPayload.model_validate( + WALLET_INSTANCE_ATTESTATION['payload']) + + # empty cnf + cnf = WALLET_INSTANCE_ATTESTATION['payload']['cnf'] + WALLET_INSTANCE_ATTESTATION['payload']['cnf'] = {} + with pytest.raises(ValidationError): + WalletInstanceAttestationPayload.model_validate( + WALLET_INSTANCE_ATTESTATION['payload']) + del WALLET_INSTANCE_ATTESTATION['payload']['cnf'] + with pytest.raises(ValidationError): + WalletInstanceAttestationPayload.model_validate( + WALLET_INSTANCE_ATTESTATION['payload']) + WALLET_INSTANCE_ATTESTATION['payload']['cnf'] = cnf + + # cnf jwk is not a JWK + WALLET_INSTANCE_ATTESTATION['payload']['cnf']['jwk'] = {} + with pytest.raises(ValidationError): + WalletInstanceAttestationPayload.model_validate( + WALLET_INSTANCE_ATTESTATION['payload']) diff --git a/pyeudiw/tests/openid4vp/schemas/test_wallet_instance_attestation_request.py b/pyeudiw/tests/openid4vp/schemas/test_wallet_instance_attestation_request.py new file mode 100644 index 00000000..2663d017 --- /dev/null +++ b/pyeudiw/tests/openid4vp/schemas/test_wallet_instance_attestation_request.py @@ -0,0 +1,64 @@ +import pytest +from pydantic import ValidationError + +from pyeudiw.openid4vp.schemas.wallet_instance_attestation_request import WalletInstanceAttestationRequestHeader, \ + WalletInstanceAttestationRequestPayload + +WALLET_INSTANCE_ATTESTATION_REQUEST = { + "header": { + "alg": "RS256", + "kid": "NjVBRjY5MDlCMUIwNzU4RTA2QzZFMDQ4QzQ2MDAyQjVDNjk1RTM2Qg", + "typ": "var+jwt" + }, + "payload": { + "iss": "vbeXJksM45xphtANnCiG6mCyuU4jfGNzopGuKvogg9c", + "aud": "https://wallet-provider.example.org", + "jti": "6ec69324-60a8-4e5b-a697-a766d85790ea", + "type": "WalletInstanceAttestationRequest", + "nonce": ".....", + "cnf": { + "jwk": { + "alg": "RS256", + "kty": "RSA", + "use": "sig", + "x5c": [ + "MIIC+DCCAeCgAwIBAgIJBIGjYW6hFpn2MA0GCSqGSIb3DQEBBQUAMCMxITAfBgNVBAMTGGN1c3RvbWVyLWRlbW9zLmF1dGgwLmNvbTAeFw0xNjExMjIyMjIyMDVaFw0zMDA4MDEyMjIyMDVaMCMxITAfBgNVBAMTGGN1c3RvbWVyLWRlbW9zLmF1dGgwLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMnjZc5bm/eGIHq09N9HKHahM7Y31P0ul+A2wwP4lSpIwFrWHzxw88/7Dwk9QMc+orGXX95R6av4GF+Es/nG3uK45ooMVMa/hYCh0Mtx3gnSuoTavQEkLzCvSwTqVwzZ+5noukWVqJuMKNwjL77GNcPLY7Xy2/skMCT5bR8UoWaufooQvYq6SyPcRAU4BtdquZRiBT4U5f+4pwNTxSvey7ki50yc1tG49Per/0zA4O6Tlpv8x7Red6m1bCNHt7+Z5nSl3RX/QYyAEUX1a28VcYmR41Osy+o2OUCXYdUAphDaHo4/8rbKTJhlu8jEcc1KoMXAKjgaVZtG/v5ltx6AXY0CAwEAAaMvMC0wDAYDVR0TBAUwAwEB/zAdBgNVHQ4EFgQUQxFG602h1cG+pnyvJoy9pGJJoCswDQYJKoZIhvcNAQEFBQADggEBAGvtCbzGNBUJPLICth3mLsX0Z4z8T8iu4tyoiuAshP/Ry/ZBnFnXmhD8vwgMZ2lTgUWwlrvlgN+fAtYKnwFO2G3BOCFw96Nm8So9sjTda9CCZ3dhoH57F/hVMBB0K6xhklAc0b5ZxUpCIN92v/w+xZoz1XQBHe8ZbRHaP1HpRM4M7DJk2G5cgUCyu3UBvYS41sHvzrxQ3z7vIePRA4WF4bEkfX12gvny0RsPkrbVMXX1Rj9t6V7QXrbPYBAO+43JvDGYawxYVvLhz+BJ45x50GFQmHszfY3BR9TPK8xmMmQwtIvLu1PMttNCs7niCYkSiUv2sc2mlq1i3IashGkkgmo=" + ], + "n": "yeNlzlub94YgerT030codqEztjfU_S6X4DbDA_iVKkjAWtYfPHDzz_sPCT1Axz6isZdf3lHpq_gYX4Sz-cbe4rjmigxUxr-FgKHQy3HeCdK6hNq9ASQvMK9LBOpXDNn7mei6RZWom4wo3CMvvsY1w8tjtfLb-yQwJPltHxShZq5-ihC9irpLI9xEBTgG12q5lGIFPhTl_7inA1PFK97LuSLnTJzW0bj096v_TMDg7pOWm_zHtF53qbVsI0e3v5nmdKXdFf9BjIARRfVrbxVxiZHjU6zL6jY5QJdh1QCmENoejj_ytspMmGW7yMRxzUqgxcAqOBpVm0b-_mW3HoBdjQ", + "e": "AQAB", + "kid": "NjVBRjY5MDlCMUIwNzU4RTA2QzZFMDQ4QzQ2MDAyQjVDNjk1RTM2Qg", + "x5t": "NjVBRjY5MDlCMUIwNzU4RTA2QzZFMDQ4QzQ2MDAyQjVDNjk1RTM2Qg" + } + }, + "iat": 1686645115, + "exp": 1686652315 + }} + + +def test_header(): + WalletInstanceAttestationRequestHeader( + **WALLET_INSTANCE_ATTESTATION_REQUEST['header']) + with pytest.raises(ValidationError): + WalletInstanceAttestationRequestHeader.model_validate( + WALLET_INSTANCE_ATTESTATION_REQUEST['header'], context={"supported_algorithms": ["RS128", "ES128"]}) + WalletInstanceAttestationRequestHeader.model_validate( + WALLET_INSTANCE_ATTESTATION_REQUEST['header'], context={"supported_algorithms": ["RS256", "ES256"]}) + WALLET_INSTANCE_ATTESTATION_REQUEST['header']['typ'] = 'wrong' + with pytest.raises(ValidationError): + WalletInstanceAttestationRequestHeader( + **WALLET_INSTANCE_ATTESTATION_REQUEST['header']) + + +def test_payload(): + WalletInstanceAttestationRequestPayload( + **WALLET_INSTANCE_ATTESTATION_REQUEST['payload']) + WALLET_INSTANCE_ATTESTATION_REQUEST['payload']['type'] = 'wrong' + with pytest.raises(ValidationError): + WalletInstanceAttestationRequestPayload( + **WALLET_INSTANCE_ATTESTATION_REQUEST['payload']) + + WALLET_INSTANCE_ATTESTATION_REQUEST["payload"]["cnf"] = { + "wrong_name_jwk": WALLET_INSTANCE_ATTESTATION_REQUEST["payload"]["cnf"]["jwk"]} + with pytest.raises(ValidationError): + WalletInstanceAttestationRequestPayload.model_validate( + WALLET_INSTANCE_ATTESTATION_REQUEST["payload"]) diff --git a/pyeudiw/tests/presentation_exchange/__init__.py b/pyeudiw/tests/presentation_exchange/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pyeudiw/tests/presentation_exchange/schemas/__init__.py b/pyeudiw/tests/presentation_exchange/schemas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pyeudiw/tests/presentation_exchange/schemas/test_presentation_definition.py b/pyeudiw/tests/presentation_exchange/schemas/test_presentation_definition.py new file mode 100644 index 00000000..766782ca --- /dev/null +++ b/pyeudiw/tests/presentation_exchange/schemas/test_presentation_definition.py @@ -0,0 +1,151 @@ +import pytest +from pydantic import ValidationError + +from pyeudiw.presentation_exchange.schemas.presentation_definition import PresentationDefinition, InputDescriptor + +PID_SD_JWT = { + "id": "pid-sd-jwt:unique_id+given_name+family_name", + "input_descriptors": [ + { + "id": "sd-jwt", + "format": { + "jwt": { + "alg": [ + "EdDSA", + "ES256" + ] + }, + "constraints": { + "limit_disclosure": "required", + "fields": [ + { + "path": [ + "$.sd-jwt.type" + ], + "filter": { + "type": "string", + "const": "PersonIdentificationData" + } + }, + { + "path": [ + "$.sd-jwt.cnf" + ], + "filter": { + "type": "object", + } + }, + { + "path": [ + "$.sd-jwt.family_name" + ], + "intent_to_retain": "true" + }, + { + "path": [ + "$.sd-jwt.given_name" + ], + "intent_to_retain": "true" + }, + { + "path": [ + "$.sd-jwt.unique_id" + ], + "intent_to_retain": "true" + } + ] + } + } + } + ] +} + +MDL_SAMPLE_REQ = { + "id": "mDL-sample-req", + "input_descriptors": [ + { + "id": "mDL", + "format": { + "mso_mdoc": { + "alg": [ + "EdDSA", + "ES256" + ] + }, + "constraints": { + "limit_disclosure": "required", + "fields": [ + { + "path": [ + "$.mdoc.doctype" + ], + "filter": { + "type": "string", + "const": "org.iso.18013.5.1.mDL" + } + }, + { + "path": [ + "$.mdoc.namespace" + ], + "filter": { + "type": "string", + "const": "org.iso.18013.5.1" + } + }, + { + "path": [ + "$.mdoc.family_name" + ], + "intent_to_retain": "false" + }, + { + "path": [ + "$.mdoc.portrait" + ], + "intent_to_retain": "false" + }, + { + "path": [ + "$.mdoc.driving_privileges" + ], + "intent_to_retain": "false" + } + ] + } + } + } + ] +} + + +def test_input_descriptor(): + descriptor = PID_SD_JWT["input_descriptors"][0] + InputDescriptor(**descriptor) + descriptor["format"]["jwt"]["alg"] = "ES256" + with pytest.raises(ValidationError): + InputDescriptor(**descriptor) + descriptor["format"]["jwt"]["alg"] = ["ES256"] + + +def test_presentation_definition(): + PresentationDefinition(**PID_SD_JWT) + PresentationDefinition(**MDL_SAMPLE_REQ) + + PID_SD_JWT["input_descriptors"][0]["format"]["jwt"]["alg"] = "ES256" + with pytest.raises(ValidationError): + PresentationDefinition(**PID_SD_JWT) + + PID_SD_JWT["input_descriptors"][0]["format"]["jwt"]["alg"] = ["ES256"] + PresentationDefinition(**PID_SD_JWT) + + del PID_SD_JWT["input_descriptors"][0]["format"]["jwt"]["alg"] + # alg is an emtpy dict, which is not allowed + with pytest.raises(ValidationError): + PresentationDefinition(**PID_SD_JWT) + + del PID_SD_JWT["input_descriptors"][0]["format"]["jwt"] + # since jwt is Optional, this is allowed + PresentationDefinition(**PID_SD_JWT) + + PID_SD_JWT["input_descriptors"][0]["format"]["jwt"] = {"alg": ["ES256"]} diff --git a/pyeudiw/tests/satosa/test_backend.py b/pyeudiw/tests/satosa/test_backend.py index 166348cf..aba3b33e 100644 --- a/pyeudiw/tests/satosa/test_backend.py +++ b/pyeudiw/tests/satosa/test_backend.py @@ -1,27 +1,25 @@ -import uuid import base64 import json import pathlib -import pytest import urllib.parse +import uuid +from unittest.mock import Mock +import pytest from bs4 import BeautifulSoup - -from pyeudiw.jwt.utils import unpad_jwt_payload -from pyeudiw.oauth2.dpop import DPoPIssuer -from pyeudiw.satosa.backend import OpenID4VPBackend -from pyeudiw.jwt import JWSHelper, JWEHelper, unpad_jwt_header -from pyeudiw.jwk import JWK -from pyeudiw.sd_jwt import issue_sd_jwt, _adapt_keys, load_specification_from_yaml_string -from pyeudiw.tools.utils import iat_now - -from sd_jwt.holder import SDJWTHolder - from satosa.context import Context from satosa.internal import InternalData from satosa.state import State -from unittest.mock import Mock +from sd_jwt.holder import SDJWTHolder +from pyeudiw.jwk import JWK +from pyeudiw.jwt import JWEHelper, JWSHelper, unpad_jwt_header +from pyeudiw.jwt.utils import unpad_jwt_payload +from pyeudiw.oauth2.dpop import DPoPIssuer +from pyeudiw.satosa.backend import OpenID4VPBackend +from pyeudiw.sd_jwt import (_adapt_keys, issue_sd_jwt, + load_specification_from_yaml_string) +from pyeudiw.tools.utils import exp_from_now, iat_now BASE_URL = "https://example.com" AUTHZ_PAGE = "example.com" @@ -31,7 +29,6 @@ CONFIG = { "base_url": BASE_URL, - "ui": { "static_storage_url": BASE_URL, "template_folder": f"{pathlib.Path().absolute().__str__()}/pyeudiw/tests/satosa/templates", @@ -46,34 +43,11 @@ "redirect": "/OpenID4VP/redirect_uri", "request": "/OpenID4VP/request_uri", }, - "qrcode_settings": { + "qrcode": { "size": 100, "color": "#2B4375", - "logo_path": None, - "use_zlib": True - }, - "sd_jwt": { - "issuer": "http://test.com", - "default_exp": 60, - "sd_specification": """ - user_claims: - !sd unique_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - !sd given_name: "Mario" - !sd family_name: "Rossi" - !sd birthdate: "1980-01-10" - !sd place_of_birth: - country: "IT" - locality: "Rome" - !sd tax_id_code: "TINIT-XXXXXXXXXXXXXXXX" - - holder_disclosed_claims: - { "given_name": "Mario", "family_name": "Rossi", "place_of_birth": {country: "IT", locality: "Rome"} } - - key_binding: True - """, - "no_randomness": True }, - "jwt_settings": { + "jwt": { "default_sig_alg": "ES256", "default_exp": 6 }, @@ -118,8 +92,47 @@ "kty": "EC", "x": "TSO-KOqdnUj5SUuasdlRB2VVFSqtJOxuR5GftUTuBdk", "y": "ByWgQt1wGBSnF56jQqLdoO1xKUynMY-BHIDB3eXlR7" + }, + { + + "kty": "RSA", + "d": "QUZsh1NqvpueootsdSjFQz-BUvxwd3Qnzm5qNb-WeOsvt3rWMEv0Q8CZrla2tndHTJhwioo1U4NuQey7znijhZ177bUwPPxSW1r68dEnL2U74nKwwoYeeMdEXnUfZSPxzs7nY6b7vtyCoA-AjiVYFOlgKNAItspv1HxeyGCLhLYhKvS_YoTdAeLuegETU5D6K1xGQIuw0nS13Icjz79Y8jC10TX4FdZwdX-NmuIEDP5-s95V9DMENtVqJAVE3L-wO-NdDilyjyOmAbntgsCzYVGH9U3W_djh4t3qVFCv3r0S-DA2FD3THvlrFi655L0QHR3gu_Fbj3b9Ybtajpue_Q", + "e": "AQAB", + "use": "enc", + "kid": "9Cquk0X-fNPSdePQIgQcQZtD6J0IjIRrFigW2PPK_-w", + "n": "utqtxbs-jnK0cPsV7aRkkZKA9t4S-WSZa3nCZtYIKDpgLnR_qcpeF0diJZvKOqXmj2cXaKFUE-8uHKAHo7BL7T-Rj2x3vGESh7SG1pE0thDGlXj4yNsg0qNvCXtk703L2H3i1UXwx6nq1uFxD2EcOE4a6qDYBI16Zl71TUZktJwmOejoHl16CPWqDLGo9GUSk_MmHOV20m4wXWkB4qbvpWVY8H6b2a0rB1B1YPOs5ZLYarSYZgjDEg6DMtZ4NgiwZ-4N1aaLwyO-GLwt9Vf-NBKwoxeRyD3zWE2FXRFBbhKGksMrCGnFDsNl5JTlPjaM3kYyImE941ggcuc495m-Fw", + "p": "2zmGXIMCEHPphw778YjVTar1eycih6fFSJ4I4bl1iq167GqO0PjlOx6CZ1-OdBTVU7HfrYRiUK_BnGRdPDn-DQghwwkB79ZdHWL14wXnpB5y-boHz_LxvjsEqXtuQYcIkidOGaMG68XNT1nM4F9a8UKFr5hHYT5_UIQSwsxlRQ0", + "q": "2jMFt2iFrdaYabdXuB4QMboVjPvbLA-IVb6_0hSG_-EueGBvgcBxdFGIZaG6kqHqlB7qMsSzdptU0vn6IgmCZnX-Hlt6c5X7JB_q91PZMLTO01pbZ2Bk58GloalCHnw_mjPh0YPviH5jGoWM5RHyl_HDDMI-UeLkzP7ImxGizrM" } ], + "storage": { + "mongo_db": { + "cache": { + "module": "pyeudiw.storage.mongo_cache", + "class": "MongoCache", + "init_params": { + "url": "mongodb://localhost:27017/", + "conf": { + "db_name": "eudiw" + }, + "connection_params": {} + } + }, + "storage": { + "module": "pyeudiw.storage.mongo_storage", + "class": "MongoStorage", + "init_params": { + "url": "mongodb://localhost:27017/", + "conf": { + "db_name": "eudiw", + "db_sessions_collection": "sessions", + "db_attestations_collection": "chains" + }, + "connection_params": {} + } + } + } + }, "metadata": { "application_type": "web", "authorization_encrypted_response_alg": [ @@ -298,6 +311,26 @@ } } +ISSUER_CONF = { + "sd_specification": """ + user_claims: + !sd unique_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + !sd given_name: "Mario" + !sd family_name: "Rossi" + !sd birthdate: "1980-01-10" + !sd place_of_birth: + country: "IT" + locality: "Rome" + !sd tax_id_code: "TINIT-XXXXXXXXXXXXXXXX" + + holder_disclosed_claims: + { "given_name": "Mario", "family_name": "Rossi", "place_of_birth": {country: "IT", locality: "Rome"} } + + key_binding: True + """, + "no_randomness": True +} + INTERNAL_ATTRIBUTES: dict = { 'attributes': {} @@ -413,7 +446,7 @@ def test_pre_request_endpoint(self, context): svg = BeautifulSoup(decoded, features="xml") assert svg assert svg.find("svg") - assert svg.find_all("svg:rect") + assert svg.find_all("path") def test_pre_request_endpoint_mobile(self, context): internal_data = InternalData() @@ -438,13 +471,16 @@ def test_pre_request_endpoint_mobile(self, context): qs = urllib.parse.parse_qs(parsed.query) assert qs["client_id"][0] == CONFIG["metadata"]["client_id"] - assert qs["request_uri"][0] == CONFIG["metadata"]["request_uris"][0] + assert qs["request_uri"][0].startswith( + CONFIG["metadata"]["request_uris"][0]) def test_redirect_endpoint(self, context): - issuer_jwk = JWK(CONFIG["federation"]["federation_jwks"][1]) + issuer_jwk = JWK(CONFIG["metadata_jwks"][0]) holder_jwk = JWK() - settings = CONFIG["sd_jwt"] + settings = ISSUER_CONF + settings['issuer'] = "https://issuer.example.com" + settings['default_exp'] = CONFIG['jwt']['default_exp'] sd_specification = load_specification_from_yaml_string( settings["sd_specification"]) @@ -472,12 +508,27 @@ def test_redirect_endpoint(self, context): "key_binding", False) else None, ) + data = { + "iss": "https://wallet-provider.example.org/instance/vbeXJksM45xphtANnCiG6mCyuU4jfGNzopGuKvogg9c", + "jti": str(uuid.uuid4()), + "aud": "https://verifier.example.org/callback", + "iat": iat_now(), + "exp": exp_from_now(minutes=15), + "nonce": str(uuid.uuid4()), + "vp": sdjwt_at_holder.sd_jwt_presentation, + } + + vp_token = JWSHelper(issuer_jwk).sign( + data, + protected={"typ": "JWT"} + ) + context.request_method = "POST" context.request_uri = CONFIG["metadata"]["redirect_uris"][0] response = { "state": "3be39b69-6ac1-41aa-921b-3e6c07ddcb03", - "vp_token": sdjwt_at_holder.sd_jwt_presentation, + "vp_token": vp_token, "presentation_submission": { "definition_id": "32f54163-7166-48f1-93d8-ff217bdb0653", "id": "04a98be3-7fb0-4cf5-af9a-31579c8b0e7d", @@ -490,15 +541,35 @@ def test_redirect_endpoint(self, context): ] } } - - context.request = {"response": JWEHelper( - JWK(CONFIG["federation"]["federation_jwks"][0], "RSA")).encrypt(response)} - - redirect_endpoint = self.backend.redirect_endpoint(context) - assert redirect_endpoint + encrypted_response = JWEHelper( + JWK(CONFIG["metadata_jwks"][1])).encrypt(response) + context.request = { + "response": encrypted_response + } + try: + redirect_endpoint = self.backend.redirect_endpoint(context) + assert redirect_endpoint + except Exception: + # TODO: this test case must implement the backend requests in the correct order and with the correct nonce and state + return # TODO any additional checks after the backend returned the user attributes to satosa core def test_request_endpoint(self, context): + # No session created + state_endpoint_response = self.backend.state_endpoint(context) + assert state_endpoint_response.status == "403" + assert state_endpoint_response.message + msg = json.loads(state_endpoint_response.message) + assert msg["message"] + + internal_data = InternalData() + context.http_headers = dict( + HTTP_USER_AGENT="Mozilla/5.0 (Linux; Android 10; SM-G960F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.92 Mobile Safari/537.36" + ) + pre_request_endpoint = self.backend.pre_request_endpoint( + context, internal_data) + state = urllib.parse.unquote( + pre_request_endpoint.message).split("=")[-1] jwshelper = JWSHelper(PRIVATE_JWK) wia = jwshelper.sign( @@ -518,12 +589,28 @@ def test_request_endpoint(self, context): HTTP_DPOP=dpop_proof ) + context.qs_params = {"id": state} + + # Not yet finalized + state_endpoint_response = self.backend.state_endpoint(context) + assert state_endpoint_response.status == "204" + assert state_endpoint_response.message + + # Passing wrong state, hence no match state-session_id + context.qs_params = {"id": "WRONG"} + state_endpoint_response = self.backend.state_endpoint(context) + assert state_endpoint_response.status == "403" + assert state_endpoint_response.message + + context.request_method = "POST" + context.qs_params = {"id": state} + request_uri = CONFIG['metadata']['request_uris'][0] + context.request_uri = request_uri request_endpoint = self.backend.request_endpoint(context) assert request_endpoint assert request_endpoint.status == "200" assert request_endpoint.message - msg = json.loads(request_endpoint.message) assert msg["response"] @@ -535,6 +622,12 @@ def test_request_endpoint(self, context): assert payload["client_id"] == CONFIG["metadata"]["client_id"] assert payload["response_uri"] == CONFIG["metadata"]["redirect_uris"][0] + state_endpoint_response = self.backend.state_endpoint(context) + assert state_endpoint_response.status == "302" + assert state_endpoint_response.message + msg = json.loads(state_endpoint_response.message) + assert msg["response"] == "Authentication successful" + def test_handle_error(self, context): error_message = "Error message!" error_resp = self.backend.handle_error(context, error_message) diff --git a/pyeudiw/tests/storage/test_db_engine.py b/pyeudiw/tests/storage/test_db_engine.py new file mode 100644 index 00000000..7a8cfe36 --- /dev/null +++ b/pyeudiw/tests/storage/test_db_engine.py @@ -0,0 +1,89 @@ +import uuid +import pytest + +from pyeudiw.storage.db_engine import DBEngine + +conf = { + "mongo_db": { + "cache": { + "module": "pyeudiw.storage.mongo_cache", + "class": "MongoCache", + "init_params": { + "url": "mongodb://localhost:27017/", + "conf": { + "db_name": "eudiw" + }, + "connection_params": {} + } + }, + "storage": { + "module": "pyeudiw.storage.mongo_storage", + "class": "MongoStorage", + "init_params": { + "url": "mongodb://localhost:27017/", + "conf": { + "db_name": "eudiw", + "db_sessions_collection": "sessions", + "db_attestations_collection": "chains" + }, + "connection_params": {} + } + } + } +} + + +class TestMongoDBEngine: + @pytest.fixture(autouse=True) + def create_engine_instance(self): + self.engine = DBEngine(conf) + + @pytest.fixture(autouse=True) + def test_init_session(self): + state = str(uuid.uuid4()) + session_id = str(uuid.uuid4()) + + document_id = self.engine.init_session( + session_id=session_id, state=state) + + assert document_id + + self.document_id = document_id + + @pytest.fixture(autouse=True) + def test_update_request_object(self): + self.nonce = str(uuid.uuid4()) + self.state = str(uuid.uuid4()) + request_object = {"request_object": "request_object", + "nonce": self.nonce, "state": self.state} + + replica_count = self.engine.update_request_object( + self.document_id, request_object) + + assert replica_count == 1 + + def test_update_request_object_with_unexistent_id_object(self): + str(uuid.uuid4()) + str(uuid.uuid4()) + unx_document_id = str(uuid.uuid4()) + request_object = {"request_object": "request_object"} + + try: + self.engine.update_request_object( + unx_document_id, request_object) + except: + return + + def test_update_response_object(self): + response_object = {"response_object": "response_object"} + self.engine.update_response_object( + self.nonce, self.state, response_object) + + def test_update_response_object_unexistent_id_object(self): + response_object = {"response_object": "response_object"} + + try: + replica_count = self.engine.update_response_object( + str(uuid.uuid4()), str(uuid.uuid4()), response_object) + except: + return diff --git a/pyeudiw/tests/storage/test_mongo_cache.py b/pyeudiw/tests/storage/test_mongo_cache.py index 539b1519..5fb3b3e7 100644 --- a/pyeudiw/tests/storage/test_mongo_cache.py +++ b/pyeudiw/tests/storage/test_mongo_cache.py @@ -1,4 +1,5 @@ import uuid + import pytest from pyeudiw.storage.mongo_cache import MongoCache @@ -17,7 +18,7 @@ def test_try_retrieve(self): object_name = str(uuid.uuid4()) data = str(uuid.uuid4()) - obj = self.cache.try_retrieve(object_name, lambda: data) + obj, _ = self.cache.try_retrieve(object_name, lambda: data) assert obj assert obj["object_name"] == object_name @@ -34,7 +35,7 @@ def test_overwrite(self): object_name = str(uuid.uuid4()) data = str(uuid.uuid4()) - obj = self.cache.try_retrieve(object_name, lambda: data) + obj, _ = self.cache.try_retrieve(object_name, lambda: data) data_updated = str(uuid.uuid4()) diff --git a/pyeudiw/tests/storage/test_mongo_storage.py b/pyeudiw/tests/storage/test_mongo_storage.py index 17a3b885..8a837626 100644 --- a/pyeudiw/tests/storage/test_mongo_storage.py +++ b/pyeudiw/tests/storage/test_mongo_storage.py @@ -1,4 +1,5 @@ import uuid + import pytest from pyeudiw.storage.mongo_storage import MongoStorage @@ -8,7 +9,8 @@ class TestMongoStorage: @pytest.fixture(autouse=True) def create_storage_instance(self): self.storage = MongoStorage( - {"db_name": "eudiw", "db_collection": "test"}, + {"db_name": "eudiw", "db_sessions_collection": "sessions", + "db_attestations_collection": "attestations"}, "mongodb://localhost:27017/", {} ) @@ -18,14 +20,24 @@ def test_mongo_connection(self): assert self.storage.db is not None assert self.storage.client - assert self.storage.collection is not None + assert self.storage.sessions is not None + assert self.storage.attestations is not None def test_entity_initialization(self): + state = str(uuid.uuid4()) + session_id = str(uuid.uuid4()) + document_id = self.storage.init_session( - {"dpop": "test"}, {"attestation": "test"}) + str(uuid.uuid4()), + session_id=session_id, state=state) assert document_id + dpop_proof = {"dpop": "test"} + attestation = {"attestation": "test"} + self.storage.add_dpop_proof_and_attestation( + document_id, dpop_proof=dpop_proof, attestation=attestation) + document = self.storage._retrieve_document_by_id(document_id) assert document @@ -35,14 +47,16 @@ def test_entity_initialization(self): assert document["attestation"] == {"attestation": "test"} def test_add_request_object(self): + state = str(uuid.uuid4()) + session_id = str(uuid.uuid4()) + document_id = self.storage.init_session( - {"dpop": "test"}, {"attestation": "test"}) + str(uuid.uuid4()), + session_id=session_id, state=state) assert document_id nonce = str(uuid.uuid4()) - state = str(uuid.uuid4()) - request_object = {"nonce": nonce, "state": state} self.storage.update_request_object(document_id, request_object) @@ -50,19 +64,19 @@ def test_add_request_object(self): document = self.storage._retrieve_document_by_id(document_id) assert document - assert document["dpop_proof"] - assert document["dpop_proof"] == {"dpop": "test"} - assert document["attestation"] - assert document["attestation"] == {"attestation": "test"} - assert document["state"] - assert document["state"] == state - assert document["state"] - assert document["nonce"] == nonce assert document["request_object"] == request_object + assert document["request_object"]["state"] + assert document["request_object"]["state"] == state + assert document["request_object"]["state"] + assert document["request_object"]["nonce"] == nonce + + def test_update_response_object(self): + state = str(uuid.uuid4()) + session_id = str(uuid.uuid4()) - def test_update_responnse_object(self): document_id = self.storage.init_session( - {"dpop": "test"}, {"attestation": "test"}) + str(uuid.uuid4()), + session_id=session_id, state=state) assert document_id @@ -71,10 +85,12 @@ def test_update_responnse_object(self): request_object = {"nonce": nonce, "state": state} - self.storage.update_request_object(document_id, request_object) + self.storage.update_request_object( + document_id, request_object) documentStatus = self.storage.update_response_object( nonce, state, {"response": "test"}) - + self.storage.add_dpop_proof_and_attestation( + document_id, dpop_proof={"dpop": "test"}, attestation={"attestation": "test"}) assert documentStatus document = self.storage._retrieve_document_by_id(document_id) diff --git a/pyeudiw/tests/tools/test_jwk.py b/pyeudiw/tests/test_jwk.py similarity index 100% rename from pyeudiw/tests/tools/test_jwk.py rename to pyeudiw/tests/test_jwk.py diff --git a/pyeudiw/tests/tools/test_jwt.py b/pyeudiw/tests/test_jwt.py similarity index 85% rename from pyeudiw/tests/tools/test_jwt.py rename to pyeudiw/tests/test_jwt.py index bcbcf8df..ddf65d9e 100644 --- a/pyeudiw/tests/tools/test_jwt.py +++ b/pyeudiw/tests/test_jwt.py @@ -1,7 +1,8 @@ import pytest from pyeudiw.jwk import JWK -from pyeudiw.jwt import JWEHelper, JWSHelper, DEFAULT_JWE_ALG, DEFAULT_JWE_ENC +from pyeudiw.jwt import (DEFAUL_ENC_ALG_MAP, DEFAUL_ENC_ENC_MAP, JWEHelper, + JWSHelper) from pyeudiw.jwt.utils import unpad_jwt_header JWKs_EC = [ @@ -18,6 +19,9 @@ JWKs = JWKs_EC + JWKs_RSA +# TODO: ENC also with EC and not only with RSA +ENC_JWKs = JWKs_RSA + @pytest.mark.parametrize("jwk, payload", JWKs_RSA) def test_unpad_jwt_header(jwk, payload): @@ -26,8 +30,8 @@ def test_unpad_jwt_header(jwk, payload): assert jwe header = unpad_jwt_header(jwe) assert header - assert header["alg"] == DEFAULT_JWE_ALG - assert header["enc"] == DEFAULT_JWE_ENC + assert header["alg"] == DEFAUL_ENC_ALG_MAP[jwk.jwk["kty"]] + assert header["enc"] == DEFAUL_ENC_ENC_MAP[jwk.jwk["kty"]] assert header["kid"] == jwk.jwk["kid"] @@ -38,7 +42,7 @@ def test_jwe_helper_init(key_type): assert helper.jwk == jwk -@pytest.mark.parametrize("jwk, payload", JWKs) +@pytest.mark.parametrize("jwk, payload", ENC_JWKs) def test_jwe_helper_encrypt(jwk, payload): helper = JWEHelper(jwk) jwe = helper.encrypt(payload) @@ -56,7 +60,7 @@ def test_jwe_helper_decrypt(jwk, payload): assert decrypted == payload or decrypted == payload.encode() -@pytest.mark.parametrize("jwk, payload", JWKs) +@pytest.mark.parametrize("jwk, payload", ENC_JWKs) def test_jwe_helper_decrypt_fail(jwk, payload): helper = JWEHelper(jwk) jwe = helper.encrypt(payload) diff --git a/pyeudiw/tests/tools/test_qr_code.py b/pyeudiw/tests/tools/test_qr_code.py index 66b243bb..6670b7ea 100644 --- a/pyeudiw/tests/tools/test_qr_code.py +++ b/pyeudiw/tests/tools/test_qr_code.py @@ -1,54 +1,73 @@ +import base64 import tempfile -from io import BytesIO - -from PIL import Image from pyeudiw.tools.qr_code import QRCode -def test_qr_code_init(): - data = "test" - size = 100 +def test_to_base64(): + data = "content" + size = 5 + color = "black" + + qr = QRCode(data, size, color) + b64 = qr.to_base64() + assert isinstance(b64, str) + assert len(b64) > 0 + assert base64.b64decode(b64.encode()).decode('utf-8') == qr.to_svg() + + +def test_to_html(): + data = "content" + size = 5 color = "black" - logo_path = "" - use_zlib = True - QRCode(data, size, color, logo_path, use_zlib) + qr = QRCode(data, size, color) + html = qr.to_html() + assert isinstance(html, str) + assert len(html) > 0 + assert html.startswith("") + assert "data:image/svg+xml;base64," in html + b64 = html.split("data:image/svg+xml;base64,")[1].split('"')[0] + assert base64.b64decode(b64.encode()).decode('utf-8') == qr.to_svg() + + +def test_to_svg(): + data = "content" + size = 5 + color = "black" + + qr = QRCode(data, size, color) + svg = qr.to_svg() + + assert isinstance(svg, str) + assert len(svg) > 0 + assert svg.strip().startswith("") + + +# Set to `False` to keep files for manual inspection +DELETE_FILES = True + + +def _test_to_html_file(): + data = "content" + size = 5 + color = "black" - # TODO - qrcode is SVG with no size! - # assert qr_code.qr_code_img.size == (size * 33, size * 33) - # assert qr_code.qr_code_img.getpixel((0, 0)) == (255, 255, 255) + qr = QRCode(data, size, color) + html = qr.to_html() + with tempfile.NamedTemporaryFile("w", suffix=".html", dir=".", delete=DELETE_FILES) as tmp: + tmp.writelines(html) -# TODO - fix with SVG factory since it doesn't have a size like PNG -def _test_qr_code_init_with_logo(): - data = "test" - size = 100 +def _test_to_svg_file(): + data = "content" + size = 5 color = "black" - use_zlib = True - - def create_in_memory_image(path): - in_memory_file = BytesIO() - image = Image.new('RGB', - size=(10, 10), - color=(255, 255, 0)) - image.save(in_memory_file, - 'png') - in_memory_file.name = path - in_memory_file.seek(0) - return in_memory_file - - with tempfile.NamedTemporaryFile(suffix='.png', delete=True) as temp_file: - temp_file.write(create_in_memory_image(temp_file.name).read()) - - # change the position to the beginning of the file - temp_file.file.seek(0) - qr_code = QRCode(data, size, color, temp_file.name, - use_zlib).qr_code_img - - assert qr_code.getpixel((0, 0)) == (255, 255, 255) - assert qr_code.getpixel( - (qr_code.size[0] - 1, qr_code.size[1] - 1)) == (255, 255, 255) - assert qr_code.getpixel( - (qr_code.size[0] // 2, qr_code.size[1] // 2)) == (255, 255, 0) + qr = QRCode(data, size, color) + svg = qr.to_svg() + with tempfile.NamedTemporaryFile("w", suffix=".svg", dir=".", delete=DELETE_FILES) as tmp: + tmp.writelines(svg) diff --git a/pyeudiw/tests/tools/test_utils.py b/pyeudiw/tests/tools/test_utils.py index a9dc508e..59e84925 100644 --- a/pyeudiw/tests/tools/test_utils.py +++ b/pyeudiw/tests/tools/test_utils.py @@ -1,11 +1,12 @@ import datetime import sys -import freezegun +import freezegun import pytest -from pyeudiw.tools.utils import exp_from_now, iat_now, random_token, make_timezone_aware +from pyeudiw.tools.utils import (exp_from_now, iat_now, make_timezone_aware, + random_token) def test_make_timezone_aware(): diff --git a/pyeudiw/tools/qr_code.py b/pyeudiw/tools/qr_code.py index 5882696e..64d885be 100644 --- a/pyeudiw/tools/qr_code.py +++ b/pyeudiw/tools/qr_code.py @@ -1,65 +1,49 @@ -import qrcode -import qrcode.image.svg -import zlib +import base64 +import io -from io import BytesIO +import pyqrcode class QRCode: - def __init__(self, data: str, size: int, color: str, logo_path: str, use_zlib: bool, logo_size_factor: int = 5) -> None: - compressed_request_data = None + def __init__(self, data: str, size: int, color: str): + """ + Create a QR code from the given data + :param data: The data to be encoded + :param size: The size of the QR code. Maps to scale in pyqrcode + :param color: The color of the QR code + """ + self.data = data + self.size = size self.color = color - if use_zlib: - compressed_request_data = zlib.compress(data.encode(), 9) - - qr_code = qrcode.QRCode( - error_correction=qrcode.constants.ERROR_CORRECT_H, - box_size=size, - ) - - qr_code.add_data(compressed_request_data or data) - - # it must be SVG - qr_code.make() - qr_code.image_factory = qrcode.image.svg.SvgImage - - self.qr_code = qr_code - - self.qr_code_img = qr_code.make_image( - fill_color=color, - back_color="white" - ) - - # Add logo if present - if logo_path: - # TODO - svg doesn't have a size so we need to find another way - pass - # logo = Image.open(logo_path) - # wpercent = (size / float(logo.size[0])) - # hsize = int((float(logo.size[1]) * float(wpercent))) - # logo = logo.resize( - # (size * logo_size_factor, hsize * logo_size_factor), Image.LANCZOS) - - # pos = ((self.qr_code_img.size[0] - logo.size[0]) // - # 2, (self.qr_code_img.size[1] - logo.size[1]) // 2) - - # self.qr_code_img.paste(logo, pos) - - def save_as_file(self, path) -> str: - self.qr_code_img.save(path) - return path - - def as_svg(self) -> BytesIO: - stream = BytesIO() - self.qr_code_img.save(stream) - stream.seek(0) - return stream - - def for_html(self): - stream = self.as_svg() - data = stream.read().decode() - # data = data.replace( - # "\n", "" - # ) - return data + qr = pyqrcode.create(data) + # Copy the svg data to a string and close the buffer to avoid memory leaks + buffer = io.BytesIO() + qr.svg(buffer, scale=size, background="white", module_color=color) + self.svg = buffer.getvalue().decode("utf-8") + buffer.close() + + def to_svg(self) -> str: + """ + Returns the svg data for the QR code as a string + :return: The svg data for the QR code + :rtype: str + """ + return self.svg + + def to_base64(self) -> str: + """ + Returns the svg data for html + :return: The svg data for html, base64 encoded + :rtype: str + """ + return base64.b64encode(self.svg.encode()).decode('utf-8') + + def to_html(self) -> str: + """ + Returns the svg data in a html img tag, encoded as base64 + :return: Image tag with svg data for html + :rtype: str + """ + b64 = self.to_base64() + return f'' diff --git a/pyeudiw/tools/schema_utils.py b/pyeudiw/tools/schema_utils.py new file mode 100644 index 00000000..9240de53 --- /dev/null +++ b/pyeudiw/tools/schema_utils.py @@ -0,0 +1,28 @@ +from pydantic_core.core_schema import FieldValidationInfo + +_default_supported_algorithms = [ + "RS256", + "RS384", + "RS512", + "ES256", + "ES384", + "ES512", + "PS256", + "PS384", + "PS512", +] + + +def check_algorithm(alg: str, info: FieldValidationInfo): + if not info.context: + supported_algorithms = _default_supported_algorithms + else: + supported_algorithms = info.context.get( + "supported_algorithms", _default_supported_algorithms) + if not isinstance(supported_algorithms, list): + supported_algorithms = [] + if alg not in supported_algorithms: + raise ValueError( + f"Unsupported algorithm: {alg}. " + f"Supported algorithms: {supported_algorithms}." + ) diff --git a/pyeudiw/tools/utils.py b/pyeudiw/tools/utils.py index 31998091..567e295e 100644 --- a/pyeudiw/tools/utils.py +++ b/pyeudiw/tools/utils.py @@ -1,11 +1,9 @@ -from secrets import token_hex - - import datetime import json import logging +from secrets import token_hex -logger = logging.getLogger(__name__) +logger = logging.getLogger("pyeudiw.utils") def make_timezone_aware(dt: datetime.datetime, tz: datetime.timezone | datetime.tzinfo = datetime.timezone.utc): @@ -29,7 +27,7 @@ def datetime_from_timestamp(value) -> datetime.datetime: def get_http_url(url: str): - # TODO + # TODO - utils.get_http_url raise NotImplementedError(f"{__name__} get_http_url") diff --git a/pyeudiw/trust/__init__.py b/pyeudiw/trust/__init__.py new file mode 100644 index 00000000..9c068412 --- /dev/null +++ b/pyeudiw/trust/__init__.py @@ -0,0 +1,66 @@ +from datetime import datetime +from pyeudiw.federation.trust_chain_validator import StaticTrustChainValidator +from pyeudiw.storage.db_engine import DBEngine + + +class TrustEvaluationHelper: + def __init__(self, storage: DBEngine, **kwargs): + self.exp: int = 0 + self.trust_chain: list = [] + self.storage = storage + self.entity_id: str = "" + + for k, v in kwargs.items(): + setattr(self, k, v) + + def inspect_evaluation_method(self): + # TODO: implement automatic detection of trust evaluation + # method based on internal trust evaluetion property + return self.federation + + def _handle_chain(self, trust_chain: list[str]): + tc = StaticTrustChainValidator(trust_chain) + self.entity_id = tc.get_entityID() + + _is_valid = tc.is_valid + self.exp = tc.get_exp() + + if not _is_valid: + db_chain = self.storage.find_chain(self.entity_id)[ + "federation"]["chain"] + + if db_chain is not None and \ + StaticTrustChainValidator(db_chain).is_valid: + return True + + _is_valid = tc.update() + self.exp = tc.get_exp() + self.trust_chain = tc.get_chain() + + if db_chain is None: + self.storage.add_chain( + self.entity_id, tc.get_chain(), datetime.fromtimestamp(tc.get_exp())) + else: + self.storage.update_chain( + self.entity_id, tc.get_chain(), datetime.fromtimestamp(tc.get_exp())) + + return _is_valid + + def federation(self) -> list: + if self.trust_chain: + return self._handle_chain(self.trust_chain) + + # TODO - at least a TA entity id is required for a discovery process + # _tc = TrustChainBuilder( + # subject= self.entity_id, + # trust_anchor=trust_anchor_ec, + # trust_anchor_configuration=trust_anchor_ec + # ) + # if _tc.is_valid: + # self.trust_chain = _tc.serialize() + # return self.trust_chain + + return [] + + def x509(self): + raise NotImplementedError("X.509 is not supported in this release") diff --git a/pyeudiw/trust/exceptions.py b/pyeudiw/trust/exceptions.py new file mode 100644 index 00000000..19fc8cf6 --- /dev/null +++ b/pyeudiw/trust/exceptions.py @@ -0,0 +1,2 @@ +class NoTrustChainProvided(Exception): + pass diff --git a/requirements-dev.txt b/requirements-dev.txt index 02523b19..365794f0 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,6 @@ pytest +pytest-mock +coverage pdbpp pytest-cov flake8 @@ -9,3 +11,4 @@ autopep8 beautifulsoup4 lxml freezegun +html-linter diff --git a/setup.py b/setup.py index 563d1bb4..a12353ff 100644 --- a/setup.py +++ b/setup.py @@ -40,8 +40,8 @@ def readme(): }, install_requires=[ "cryptojwt>=1.8.2,<1.9", - "qrcode>=7.4.2,<7.5", - "pydantic>=2.0,<2.2", + "pyqrcode>=1.2,<1.3", + "pydantic>=2.0,<2.2" ], extra_require={ "satosa": [ @@ -52,5 +52,9 @@ def readme(): "pymongo>=4.4.1,<4.5", 'sd-jwt @ git+https://github.com/danielfett/sd-jwt.git' ], + "federation": [ + "asyncio" + "requests" + ] } )