From 47a5e98ae94529eb2a95ffe2dd40f42a9da7d138 Mon Sep 17 00:00:00 2001 From: Ade Lee Date: Tue, 15 Nov 2022 16:34:30 +0000 Subject: [PATCH] Add keystone oidc tests This adds tests to test getting a token (scoped and unscoped) when keystone is configured to use oidc for authentication. The oidc provider is keycloak. This is based in very large part on Kristi's work in [1] and [2]. [1] https://github.com/knikolla/devstack-plugin-oidc [2] https://github.com/CCI-MOC/onboarding-tools Co-Authored-By: David Wilde Change-Id: I1772b65f1cc3830ac293a800a79d044a6ab69d65 --- keystone_tempest_plugin/config.py | 11 +- .../tests/scenario/keycloak.py | 90 +++++++++++ .../scenario/test_federated_authentication.py | 8 + .../test_oidc_federated_authentication.py | 151 ++++++++++++++++++ requirements.txt | 1 + 5 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 keystone_tempest_plugin/tests/scenario/keycloak.py create mode 100644 keystone_tempest_plugin/tests/scenario/test_oidc_federated_authentication.py diff --git a/keystone_tempest_plugin/config.py b/keystone_tempest_plugin/config.py index 2d4d189..ae93471 100644 --- a/keystone_tempest_plugin/config.py +++ b/keystone_tempest_plugin/config.py @@ -47,6 +47,14 @@ FedScenarioGroup = [ help='Password used to login in the Identity Provider'), cfg.StrOpt('idp_ecp_url', help='Identity Provider SAML2/ECP URL'), + cfg.StrOpt('idp_oidc_url', + help='Identity Provider OIDC URL'), + + # client id (oidc) + cfg.StrOpt('idp_client_id', + help='Identity Provider Client ID'), + cfg.StrOpt('idp_client_secret', + help='Identity Provider Client Secret'), # Mapping rules cfg.StrOpt('mapping_remote_type', @@ -72,5 +80,6 @@ FedScenarioGroup = [ # Protocol cfg.StrOpt('protocol_id', default='mapped', - help='The Protocol ID') + help='The Protocol ID'), + ] diff --git a/keystone_tempest_plugin/tests/scenario/keycloak.py b/keystone_tempest_plugin/tests/scenario/keycloak.py new file mode 100644 index 0000000..50c3495 --- /dev/null +++ b/keystone_tempest_plugin/tests/scenario/keycloak.py @@ -0,0 +1,90 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import requests + + +class KeycloakClient(object): + def __init__(self, keycloak_url, keycloak_username, keycloak_password, + realm='master', ca_certs_file=False): + self.keycloak_url = keycloak_url + self.keycloak_username = keycloak_username + self.keycloak_password = keycloak_password + self.session = requests.session() + self.realm = realm + self.ca_certs_file = ca_certs_file + self._admin_auth() + + @property + def url_base(self): + return self.keycloak_url + f'/admin/realms' + + @property + def token_endpoint(self): + return self.keycloak_url + \ + f'/realms/{self.realm}/protocol/openid-connect/token' + + @property + def discovery_endpoint(self): + return self.keycloak_url + \ + f'/realms/{self.realm}/.well-known/openid-configuration' + + def _construct_url(self, path): + return self.url_base + f'/{self.realm}/{path}' + + def _admin_auth(self): + params = { + 'grant_type': 'password', + 'client_id': 'admin-cli', + 'username': self.keycloak_username, + 'password': self.keycloak_password, + 'scope': 'openid', + } + r = requests.post( + self.token_endpoint, + data=params, + verify=self.ca_certs_file).json() + + headers = { + 'Authorization': ("Bearer %s" % r['access_token']), + 'Content-Type': 'application/json' + } + self.session.headers.update(headers) + return r + + def create_user(self, email, first_name, last_name): + self._admin_auth() + data = { + 'username': email, + 'email': email, + 'firstName': first_name, + 'lastName': last_name, + 'enabled': True, + 'emailVerified': True, + 'credentials': [{ + 'value': 'secret', + 'type': 'password', + }], + 'requiredActions': [] + } + return self.session.post( + self._construct_url('users'), + json=data, verify=self.ca_certs_file) + + def delete_user(self, username): + self._admin_auth() + data = { + 'id': username, + } + return self.session.delete( + self._construct_url('users'), + json=data, verify=self.ca_certs_file) diff --git a/keystone_tempest_plugin/tests/scenario/test_federated_authentication.py b/keystone_tempest_plugin/tests/scenario/test_federated_authentication.py index 09e95d9..68ee19d 100644 --- a/keystone_tempest_plugin/tests/scenario/test_federated_authentication.py +++ b/keystone_tempest_plugin/tests/scenario/test_federated_authentication.py @@ -206,6 +206,8 @@ class TestSaml2FederatedExternalAuthentication( "Federated Identity feature not enabled") @testtools.skipUnless(CONF.identity_feature_enabled.external_idp, "External identity provider is not available") + @testtools.skipUnless(CONF.fed_scenario.protocol_id == 'mapped', + "Protocol not mapped") def test_request_unscoped_token(self): self._test_request_unscoped_token() @@ -213,6 +215,8 @@ class TestSaml2FederatedExternalAuthentication( "Federated Identity feature not enabled") @testtools.skipUnless(CONF.identity_feature_enabled.external_idp, "External identity provider is not available") + @testtools.skipUnless(CONF.fed_scenario.protocol_id == 'mapped', + "Protocol not mapped") def test_request_scoped_token(self): self._test_request_scoped_token() @@ -328,10 +332,14 @@ class TestK2KFederatedAuthentication(TestSaml2EcpFederatedAuthentication): @testtools.skipUnless(CONF.identity_feature_enabled.federation, "Federated Identity feature not enabled") + @testtools.skipUnless(CONF.fed_scenario.protocol_id == 'mapped', + "Protocol not mapped") def test_request_unscoped_token(self): self._test_request_unscoped_token() @testtools.skipUnless(CONF.identity_feature_enabled.federation, "Federated Identity feature not enabled") + @testtools.skipUnless(CONF.fed_scenario.protocol_id == 'mapped', + "Protocol not mapped") def test_request_scoped_token(self): self._test_request_scoped_token() diff --git a/keystone_tempest_plugin/tests/scenario/test_oidc_federated_authentication.py b/keystone_tempest_plugin/tests/scenario/test_oidc_federated_authentication.py new file mode 100644 index 0000000..d6d064f --- /dev/null +++ b/keystone_tempest_plugin/tests/scenario/test_oidc_federated_authentication.py @@ -0,0 +1,151 @@ +# Copyright 2022 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import uuid + +from keystoneauth1 import identity +from keystoneauth1 import session as ks_session +from tempest import config +from tempest.lib.common.utils import data_utils +import testtools + +from .keycloak import KeycloakClient +from keystone_tempest_plugin.tests import base + +CONF = config.CONF + + +class TestOidcFederatedAuthentication(base.BaseIdentityTest): + + def _setup_settings(self): + # Keycloak Settings + self.idp_id = CONF.fed_scenario.idp_id + self.idp_remote_ids = CONF.fed_scenario.idp_remote_ids + self.idp_url = CONF.fed_scenario.idp_oidc_url + self.idp_client_id = CONF.fed_scenario.idp_client_id + self.idp_client_secret = CONF.fed_scenario.idp_client_secret + self.idp_password = CONF.fed_scenario.idp_password + self.idp_username = CONF.fed_scenario.idp_username + + self.protocol_id = CONF.fed_scenario.protocol_id + self.keystone_v3_endpoint = CONF.identity.uri_v3 + + # mapping settings + self.mapping_remote_type = CONF.fed_scenario.mapping_remote_type + self.mapping_user_name = CONF.fed_scenario.mapping_user_name + self.mapping_group_name = CONF.fed_scenario.mapping_group_name + self.mapping_group_domain_name = \ + CONF.fed_scenario.mapping_group_domain_name + + # custom CA certificate settings + self.ca_certificates_file = CONF.identity.ca_certificates_file + + def _setup_mapping(self): + self.mapping_id = data_utils.rand_uuid_hex() + rules = [{ + 'local': [ + { + 'user': {'name': self.mapping_user_name} + }, + { + 'group': { + 'domain': {'name': self.mapping_group_domain_name}, + 'name': self.mapping_group_name + } + } + ], + 'remote': [ + { + 'type': self.mapping_remote_type + } + ] + }] + mapping_ref = {'rules': rules} + self.mappings_client.create_mapping_rule(self.mapping_id, mapping_ref) + self.addCleanup( + self.mappings_client.delete_mapping_rule, self.mapping_id) + + def _setup_protocol(self): + self.idps_client.add_protocol_and_mapping( + self.idp_id, self.protocol_id, self.mapping_id) + self.addCleanup( + self.idps_client.delete_protocol_and_mapping, + self.idp_id, + self.protocol_id) + + def setUp(self): + super(TestOidcFederatedAuthentication, self).setUp() + self._setup_settings() + + # Setup mapping and protocol + self._setup_mapping() + self._setup_protocol() + self.keycloak = KeycloakClient( + keycloak_url=self.idp_url, + keycloak_username=self.idp_username, + keycloak_password=self.idp_password, + ca_certs_file=self.ca_certificates_file, + ) + + def _setup_user(self, email=None): + email = email if email else f'test-{uuid.uuid4().hex}@example.com' + self.keycloak.create_user(email, 'Test', 'User') + return email + + def _request_unscoped_token(self, user): + auth = identity.v3.OidcPassword( + auth_url=self.keystone_v3_endpoint, + identity_provider=self.idp_id, + protocol=self.protocol_id, + client_id=self.idp_client_id, + client_secret=self.idp_client_secret, + access_token_endpoint=self.keycloak.token_endpoint, + discovery_endpoint=self.keycloak.discovery_endpoint, + username=user, + password='secret' + ) + s = ks_session.Session(auth, verify=self.ca_certificates_file) + return s.get_auth_headers() + + @testtools.skipUnless(CONF.identity_feature_enabled.federation, + "Federated Identity feature not enabled") + @testtools.skipUnless(CONF.identity_feature_enabled.external_idp, + "External identity provider is not available") + @testtools.skipUnless(CONF.fed_scenario.protocol_id == 'openid', + "Protocol not openid") + def test_request_unscoped_token(self): + user = self._setup_user() + token = self._request_unscoped_token(user) + self.assertNotEmpty(token) + self.keycloak.delete_user(user) + + @testtools.skipUnless(CONF.identity_feature_enabled.federation, + "Federated Identity feature not enabled") + @testtools.skipUnless(CONF.identity_feature_enabled.external_idp, + "External identity provider is not available") + @testtools.skipUnless(CONF.fed_scenario.protocol_id == 'openid', + "Protocol not openid") + def test_request_scoped_token(self): + user = self._setup_user() + token = self._request_unscoped_token(user) + token_id = token['X-Auth-Token'] + + projects = self.auth_client.get_available_projects_scopes( + self.keystone_v3_endpoint, token_id)['projects'] + self.assertNotEmpty(projects) + + # Get a scoped token to one of the listed projects + self.tokens_client.auth( + project_id=projects[0]['id'], token=token_id) + self.keycloak.delete_user(user) diff --git a/requirements.txt b/requirements.txt index 790e605..67f303d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ pbr!=2.1.0,>=2.0.0 # Apache-2.0 # xml parsing +keystoneauth1>=5.1.1 # Apache-2.0 lxml!=3.7.0,>=3.4.1 # BSD tempest>=17.1.0 # Apache-2.0 oslo.config>=5.2.0 # Apache-2.0