From ca28df84808787342303666e1b286dbc5ec88c61 Mon Sep 17 00:00:00 2001 From: sunyonggen Date: Fri, 7 Oct 2022 11:17:13 +0900 Subject: [PATCH] OAuth 2.0 Mutual-TLS Support Added a new OAuth2mTlsClientCredential plugin, accessible via the 'v3oauth2mtlsclientcredential' entry point, making possible to authenticate using an OAuth 2.0 Mutual-TLS client credentials. Co-Authored-By: Hiromu Asahina Change-Id: I0e02ef18da5d60cdd1bcde07b07c2071b74b73d6 Implements: blueprint support-oauth2-mtls --- keystoneauth1/access/access.py | 8 + keystoneauth1/fixture/v3.py | 18 +- keystoneauth1/identity/__init__.py | 6 +- keystoneauth1/identity/v3/__init__.py | 6 +- .../v3/oauth2_mtls_client_credential.py | 125 +++++++++ keystoneauth1/loading/_plugins/identity/v3.py | 31 +++ .../tests/unit/identity/test_identity_v3.py | 243 ++++++++++++++++++ keystoneauth1/tests/unit/loading/test_v3.py | 38 +++ keystoneauth1/tests/unit/test_fixtures.py | 14 + ...-support-oauth2-mtls-177cda05265ae65c.yaml | 10 + setup.cfg | 1 + 11 files changed, 497 insertions(+), 3 deletions(-) create mode 100644 keystoneauth1/identity/v3/oauth2_mtls_client_credential.py create mode 100644 releasenotes/notes/bp-support-oauth2-mtls-177cda05265ae65c.yaml diff --git a/keystoneauth1/access/access.py b/keystoneauth1/access/access.py index cbec0127..4a9d83c5 100644 --- a/keystoneauth1/access/access.py +++ b/keystoneauth1/access/access.py @@ -782,3 +782,11 @@ class AccessInfoV3(AccessInfo): @_missingproperty def bind(self): return self._data['token']['bind'] + + @property + def oauth2_credential(self): + return self._data['token']['oauth2_credential'] + + @_missingproperty + def oauth2_credential_thumbprint(self): + return self._data['token']['oauth2_credential']['x5t#S256'] diff --git a/keystoneauth1/fixture/v3.py b/keystoneauth1/fixture/v3.py index 33cdea85..a831792c 100644 --- a/keystoneauth1/fixture/v3.py +++ b/keystoneauth1/fixture/v3.py @@ -65,7 +65,8 @@ class Token(dict): application_credential_access_rules=None, oauth_access_token_id=None, oauth_consumer_id=None, audit_id=None, audit_chain_id=None, - is_admin_project=None, project_is_domain=None): + is_admin_project=None, project_is_domain=None, + oauth2_thumbprint=None): super(Token, self).__init__() self.user_id = user_id or uuid.uuid4().hex @@ -129,6 +130,9 @@ class Token(dict): if is_admin_project is not None: self.is_admin_project = is_admin_project + if oauth2_thumbprint: + self.oauth2_thumbprint = oauth2_thumbprint + @property def root(self): return self.setdefault('token', {}) @@ -395,6 +399,18 @@ class Token(dict): def is_admin_project(self): self.root.pop('is_admin_project', None) + @property + def oauth2_thumbprint(self): + return self.root.get('oauth2_credential', {}).get('x5t#S256') + + @oauth2_thumbprint.setter + def oauth2_thumbprint(self, value): + self.root.setdefault('oauth2_credential', {})['x5t#S256'] = value + + @property + def oauth2_credential(self): + return self.root.get('oauth2_credential') + def validate(self): project = self.root.get('project') domain = self.root.get('domain') diff --git a/keystoneauth1/identity/__init__.py b/keystoneauth1/identity/__init__.py index 0f25ea69..005632da 100644 --- a/keystoneauth1/identity/__init__.py +++ b/keystoneauth1/identity/__init__.py @@ -64,6 +64,9 @@ V3MultiFactor = v3.MultiFactor V3OAuth2ClientCredential = v3.OAuth2ClientCredential """See :class:`keystoneauth1.identity.v3.OAuth2ClientCredential`""" +V3OAuth2mTlsClientCredential = v3.OAuth2mTlsClientCredential +"""See :class:`keystoneauth1.identity.v3.OAuth2mTlsClientCredential`""" + __all__ = ('BaseIdentityPlugin', 'Password', 'Token', @@ -78,4 +81,5 @@ __all__ = ('BaseIdentityPlugin', 'V3TokenlessAuth', 'V3ApplicationCredential', 'V3MultiFactor', - 'V3OAuth2ClientCredential') + 'V3OAuth2ClientCredential', + 'V3OAuth2mTlsClientCredential') diff --git a/keystoneauth1/identity/v3/__init__.py b/keystoneauth1/identity/v3/__init__.py index 095c381f..b10c390a 100644 --- a/keystoneauth1/identity/v3/__init__.py +++ b/keystoneauth1/identity/v3/__init__.py @@ -24,6 +24,7 @@ from keystoneauth1.identity.v3.token import * # noqa from keystoneauth1.identity.v3.totp import * # noqa from keystoneauth1.identity.v3.tokenless_auth import * # noqa from keystoneauth1.identity.v3.oauth2_client_credential import * # noqa +from keystoneauth1.identity.v3.oauth2_mtls_client_credential import * # noqa __all__ = ('ApplicationCredential', @@ -59,4 +60,7 @@ __all__ = ('ApplicationCredential', 'MultiFactor', 'OAuth2ClientCredential', - 'OAuth2ClientCredentialMethod',) + 'OAuth2ClientCredentialMethod', + + 'OAuth2mTlsClientCredential', + ) diff --git a/keystoneauth1/identity/v3/oauth2_mtls_client_credential.py b/keystoneauth1/identity/v3/oauth2_mtls_client_credential.py new file mode 100644 index 00000000..c1179274 --- /dev/null +++ b/keystoneauth1/identity/v3/oauth2_mtls_client_credential.py @@ -0,0 +1,125 @@ +# Copyright 2022 OpenStack Foundation +# +# 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 abc + +import six + +from keystoneauth1 import access +from keystoneauth1 import exceptions +from keystoneauth1.identity.v3 import base + + +@six.add_metaclass(abc.ABCMeta) +class OAuth2mTlsClientCredential(base.BaseAuth): + """A plugin for authenticating via an OAuth2.0 mTLS client credential. + + :param string auth_url: keystone authorization endpoint. + :param string oauth2_endpoint: OAuth2.0 endpoint. + :param string oauth2_client_id: OAuth2.0 client credential id. + """ + + def __init__(self, auth_url, oauth2_endpoint, oauth2_client_id, + *args, **kwargs): + super(OAuth2mTlsClientCredential, self).__init__( + auth_url, *args, **kwargs) + self.auth_url = auth_url + self.oauth2_endpoint = oauth2_endpoint + self.oauth2_client_id = oauth2_client_id + self.oauth2_access_token = None + + def get_auth_ref(self, session, **kwargs): + """Obtain a token from an OpenStack Identity Service. + + This method is overridden by the various token version plugins. + + This function should not be called independently and is expected to be + invoked via the do_authenticate function. + + This function will be invoked if the AcessInfo object cached by the + plugin is not valid. Thus plugins should always fetch a new AccessInfo + when invoked. If you are looking to just retrieve the current auth + data then you should use get_access. + + :param session: A session object that can be used for communication. + :type session: keystoneauth1.session.Session + + :raises keystoneauth1.exceptions.response.InvalidResponse: + The response returned wasn't appropriate. + :raises keystoneauth1.exceptions.http.HttpError: + An error from an invalid HTTP response. + :raises keystoneauth1.exceptions.ClientException: + An error from getting OAuth2.0 access token. + + :returns: Token access information. + :rtype: :class:`keystoneauth1.access.AccessInfo` + """ + # Get OAuth2.0 access token and add the field 'Authorization' when + # using the HTTPS protocol. + data = {'grant_type': 'client_credentials', + 'client_id': self.oauth2_client_id} + resp = session.post(url=self.oauth2_endpoint, + authenticated=False, + raise_exc=False, + data=data) + if resp.status_code == 200: + oauth2 = resp.json() + self.oauth2_access_token = oauth2.get('access_token') + else: + error = resp.json() + msg = error.get('error_description') + raise exceptions.ClientException(msg) + + headers = {'Accept': 'application/json', + 'X-Auth-Token': self.oauth2_access_token, + 'X-Subject-Token': self.oauth2_access_token} + + token_url = '%s/auth/tokens' % self.auth_url.rstrip('/') + if not self.auth_url.rstrip('/').endswith('v3'): + token_url = '%s/v3/auth/tokens' % self.auth_url.rstrip('/') + resp = session.get(url=token_url, + authenticated=False, + headers=headers, + log=False) + try: + resp_data = resp.json() + except ValueError: + raise exceptions.InvalidResponse(response=resp) + if 'token' not in resp_data: + raise exceptions.InvalidResponse(response=resp) + + return access.AccessInfoV3(auth_token=self.oauth2_access_token, + body=resp_data) + + def get_headers(self, session, **kwargs): + """Fetch authentication headers for message. + + :param session: The session object that the auth_plugin belongs to. + :type session: keystoneauth1.session.Session + + :returns: Headers that are set to authenticate a message or None for + failure. Note that when checking this value that the empty + dict is a valid, non-failure response. + :rtype: dict + """ + # get headers for X-Auth-Token + headers = super(OAuth2mTlsClientCredential, self).get_headers( + session, **kwargs) + + # add OAuth2.0 access token to the headers + if headers: + headers['Authorization'] = f'Bearer {self.oauth2_access_token}' + else: + headers = {'Authorization': f'Bearer {self.oauth2_access_token}'} + return headers diff --git a/keystoneauth1/loading/_plugins/identity/v3.py b/keystoneauth1/loading/_plugins/identity/v3.py index a5fcb05f..3028c837 100644 --- a/keystoneauth1/loading/_plugins/identity/v3.py +++ b/keystoneauth1/loading/_plugins/identity/v3.py @@ -379,3 +379,34 @@ class OAuth2ClientCredential(loading.BaseV3Loader): raise exceptions.OptionError(m) return super(OAuth2ClientCredential, self).load_from_options(**kwargs) + + +class OAuth2mTlsClientCredential(loading.BaseV3Loader): + + @property + def plugin_class(self): + return identity.V3OAuth2mTlsClientCredential + + def get_options(self): + options = super(OAuth2mTlsClientCredential, self).get_options() + options.extend([ + loading.Opt('oauth2-endpoint', + required=True, + help='Endpoint for OAuth2.0 Mutual-TLS Authorization'), + loading.Opt('oauth2-client-id', + required=True, + help='Client credential ID for OAuth2.0 Mutual-TLS ' + 'Authorization') + ]) + return options + + def load_from_options(self, **kwargs): + if not kwargs.get('oauth2_endpoint'): + m = 'You must provide an OAuth2.0 Mutual-TLS endpoint.' + raise exceptions.OptionError(m) + if not kwargs.get('oauth2_client_id'): + m = ('You must provide an client credential ID for ' + 'OAuth2.0 Mutual-TLS Authorization.') + raise exceptions.OptionError(m) + return super(OAuth2mTlsClientCredential, + self).load_from_options(**kwargs) diff --git a/keystoneauth1/tests/unit/identity/test_identity_v3.py b/keystoneauth1/tests/unit/identity/test_identity_v3.py index 75deab29..321a0c77 100644 --- a/keystoneauth1/tests/unit/identity/test_identity_v3.py +++ b/keystoneauth1/tests/unit/identity/test_identity_v3.py @@ -225,6 +225,37 @@ class V3IdentityPlugin(utils.TestCase): "application_credential_restricted": True }, } + self.TEST_OAUTH2_MTLS_TOKEN_RESPONSE = { + "token": { + "methods": [ + "oauth2_credential" + ], + + "expires_at": "%i-02-01T00:00:10.000123Z" % nextyear, + "project": { + "domain": { + "id": self.TEST_DOMAIN_ID, + "name": self.TEST_DOMAIN_NAME + }, + "id": self.TEST_TENANT_ID, + "name": self.TEST_TENANT_NAME + }, + "user": { + "domain": { + "id": self.TEST_DOMAIN_ID, + "name": self.TEST_DOMAIN_NAME + }, + "id": self.TEST_USER, + "name": self.TEST_USER + }, + "issued_at": "2013-05-29T16:55:21.468960Z", + "catalog": self.TEST_SERVICE_CATALOG, + "service_providers": self.TEST_SERVICE_PROVIDERS, + "oauth2_credential": { + "x5t#S256": "7UN-z4yFIm9s4jakecGoKa4rc353pDCuFUo9fsDD_1s=" + } + }, + } self.TEST_RECEIPT_RESPONSE = { "receipt": { "methods": ["password"], @@ -1085,3 +1116,215 @@ class V3IdentityPlugin(utils.TestCase): f'Bearer {oauth2_token}') self.assertEqual(200, resp.status_code) self.assertEqual(resp_text, resp.text) + + def test_oauth2_mtls_client_credential_method(self): + base_https = self.TEST_URL.replace('http:', 'https:') + token_endpoint = f'{self.TEST_URL}/auth/tokens' + oauth2_endpoint = f'{base_https}/OS-OAUTH2/token' + oauth2_token = 'HW9bB6oYWJywz6mAN_KyIBXlof15Pk' + a = v3.OAuth2mTlsClientCredential( + self.TEST_URL, + oauth2_endpoint=oauth2_endpoint, + oauth2_client_id=self.TEST_CLIENT_CRED_ID + ) + + oauth2_post_resp = { + 'status_code': 200, + 'json': { + 'access_token': oauth2_token, + 'expires_in': 3600, + 'token_type': 'Bearer' + } + } + self.requests_mock.post(oauth2_endpoint, [oauth2_post_resp]) + token_verify_resp = { + 'status_code': 200, + 'json': { + **self.TEST_OAUTH2_MTLS_TOKEN_RESPONSE + } + } + self.requests_mock.get(token_endpoint, [token_verify_resp]) + + sess = session.Session(auth=a) + auth_ref = a.get_auth_ref(sess) + self.assertEqual(auth_ref.auth_token, oauth2_token) + self.assertEqual(auth_ref._data, + self.TEST_OAUTH2_MTLS_TOKEN_RESPONSE) + self.assertEqual( + auth_ref.project_id, + self.TEST_OAUTH2_MTLS_TOKEN_RESPONSE.get( + 'token', {}).get('project', {}).get('id')) + self.assertIsNone(auth_ref.domain_id) + self.assertEqual( + auth_ref.oauth2_credential, + self.TEST_OAUTH2_MTLS_TOKEN_RESPONSE.get( + 'token', {}).get('oauth2_credential')) + self.assertEqual( + auth_ref.oauth2_credential_thumbprint, + self.TEST_OAUTH2_MTLS_TOKEN_RESPONSE.get( + 'token', {}).get('oauth2_credential', {}).get('x5t#S256') + ) + + auth_head = sess.get_auth_headers() + self.assertEqual(f'Bearer {oauth2_token}', auth_head['Authorization']) + self.assertEqual(oauth2_token, auth_head['X-Auth-Token']) + + def test_oauth2_mtls_client_credential_method_without_v3(self): + base_https = self.TEST_URL.replace('http:', 'https:') + token_endpoint = f'{self.TEST_URL}/auth/tokens' + oauth2_endpoint = f'{base_https}/OS-OAUTH2/token' + oauth2_token = 'HW9bB6oYWJywz6mAN_KyIBXlof15Pk' + a = v3.OAuth2mTlsClientCredential( + self.TEST_URL.replace('v3', ''), + oauth2_endpoint=oauth2_endpoint, + oauth2_client_id=self.TEST_CLIENT_CRED_ID + ) + + oauth2_post_resp = { + 'status_code': 200, + 'json': { + 'access_token': oauth2_token, + 'expires_in': 3600, + 'token_type': 'Bearer' + } + } + self.requests_mock.post(oauth2_endpoint, [oauth2_post_resp]) + token_verify_resp = { + 'status_code': 200, + 'json': { + **self.TEST_OAUTH2_MTLS_TOKEN_RESPONSE + } + } + self.requests_mock.get(token_endpoint, [token_verify_resp]) + + sess = session.Session(auth=a) + auth_ref = a.get_auth_ref(sess) + self.assertEqual(auth_ref.auth_token, oauth2_token) + self.assertEqual(auth_ref._data, + self.TEST_OAUTH2_MTLS_TOKEN_RESPONSE) + self.assertEqual( + auth_ref.oauth2_credential, + self.TEST_OAUTH2_MTLS_TOKEN_RESPONSE.get( + 'token', {}).get('oauth2_credential')) + self.assertEqual( + auth_ref.oauth2_credential_thumbprint, + self.TEST_OAUTH2_MTLS_TOKEN_RESPONSE.get( + 'token', {}).get('oauth2_credential', {}).get('x5t#S256') + ) + auth_head = sess.get_auth_headers() + self.assertEqual(f'Bearer {oauth2_token}', auth_head['Authorization']) + self.assertEqual(oauth2_token, auth_head['X-Auth-Token']) + + def test_oauth2_mtls_client_credential_method_resp_invalid_json(self): + base_https = self.TEST_URL.replace('http:', 'https:') + token_endpoint = f'{self.TEST_URL}/auth/tokens' + oauth2_endpoint = f'{base_https}/OS-OAUTH2/token' + oauth2_token = 'HW9bB6oYWJywz6mAN_KyIBXlof15Pk' + a = v3.OAuth2mTlsClientCredential( + self.TEST_URL, + oauth2_endpoint=oauth2_endpoint, + oauth2_client_id=self.TEST_CLIENT_CRED_ID + ) + + oauth2_post_resp = { + 'status_code': 200, + 'json': { + 'access_token': oauth2_token, + 'expires_in': 3600, + 'token_type': 'Bearer' + } + } + self.requests_mock.post(oauth2_endpoint, [oauth2_post_resp]) + token_verify_resp = { + 'status_code': 200, + 'text': 'invalid json' + } + self.requests_mock.get(token_endpoint, [token_verify_resp]) + + sess = session.Session(auth=a) + self.assertRaises(exceptions.InvalidResponse, a.get_auth_ref, sess) + + def test_oauth2_mtls_client_credential_method_resp_without_token(self): + base_https = self.TEST_URL.replace('http:', 'https:') + token_endpoint = f'{self.TEST_URL}/auth/tokens' + oauth2_endpoint = f'{base_https}/OS-OAUTH2/token' + oauth2_token = 'HW9bB6oYWJywz6mAN_KyIBXlof15Pk' + a = v3.OAuth2mTlsClientCredential( + self.TEST_URL, + oauth2_endpoint=oauth2_endpoint, + oauth2_client_id=self.TEST_CLIENT_CRED_ID + ) + + oauth2_post_resp = { + 'status_code': 200, + 'json': { + 'access_token': oauth2_token, + 'expires_in': 3600, + 'token_type': 'Bearer' + } + } + self.requests_mock.post(oauth2_endpoint, [oauth2_post_resp]) + token_verify_resp = { + 'status_code': 200, + 'json': { + 'without_token': {} + } + } + self.requests_mock.get(token_endpoint, [token_verify_resp]) + + sess = session.Session(auth=a) + self.assertRaises(exceptions.InvalidResponse, a.get_auth_ref, sess) + + def test_oauth2_mtls_client_credential_method_client_exception(self): + base_https = self.TEST_URL.replace('http:', 'https:') + oauth2_endpoint = f'{base_https}/OS-OAUTH2/token' + a = v3.OAuth2mTlsClientCredential( + self.TEST_URL, + oauth2_endpoint=oauth2_endpoint, + oauth2_client_id=self.TEST_CLIENT_CRED_ID + ) + + oauth2_post_resp = { + 'status_code': 400, + 'json': {} + } + self.requests_mock.post(oauth2_endpoint, [oauth2_post_resp]) + + sess = session.Session(auth=a) + self.assertRaises(exceptions.ClientException, a.get_auth_ref, sess) + + def test_oauth2_mtls_client_credential_method_base_header_none(self): + base_https = self.TEST_URL.replace('http:', 'https:') + token_endpoint = f'{self.TEST_URL}/auth/tokens' + oauth2_endpoint = f'{base_https}/OS-OAUTH2/token' + oauth2_token = 'HW9bB6oYWJywz6mAN_KyIBXlof15Pk' + a = v3.OAuth2mTlsClientCredential( + self.TEST_URL, + oauth2_endpoint=oauth2_endpoint, + oauth2_client_id=self.TEST_CLIENT_CRED_ID + ) + oauth2_post_resp = { + 'status_code': 200, + 'json': { + 'access_token': oauth2_token, + 'expires_in': 3600, + 'token_type': 'Bearer' + } + } + self.requests_mock.post(oauth2_endpoint, + [oauth2_post_resp]) + token_verify_resp = { + 'status_code': 200, + 'json': { + **self.TEST_OAUTH2_MTLS_TOKEN_RESPONSE + } + } + self.requests_mock.get(token_endpoint, [token_verify_resp]) + sess = session.Session(auth=a) + + with unittest.mock.patch( + 'keystoneauth1.plugin.BaseAuthPlugin.' + 'get_headers') as co_mock: + co_mock.return_value = None + auth_head = sess.get_auth_headers() + self.assertEqual('Bearer None', auth_head['Authorization']) diff --git a/keystoneauth1/tests/unit/loading/test_v3.py b/keystoneauth1/tests/unit/loading/test_v3.py index 07d84277..60d82256 100644 --- a/keystoneauth1/tests/unit/loading/test_v3.py +++ b/keystoneauth1/tests/unit/loading/test_v3.py @@ -539,3 +539,41 @@ class V3Oauth2ClientCredentialTests(utils.TestCase): self.create, oauth2_endpoint=oauth2_endpoint, oauth2_client_id=uuid.uuid4().hex) + + +class V3Oauth2mTlsClientCredentialTests(utils.TestCase): + + def setUp(self): + super(V3Oauth2mTlsClientCredentialTests, self).setUp() + + self.auth_url = uuid.uuid4().hex + + def create(self, **kwargs): + kwargs.setdefault('auth_url', self.auth_url) + loader = loading.get_plugin_loader('v3oauth2mtlsclientcredential') + return loader.load_from_options(**kwargs) + + def test_basic(self): + client_id = uuid.uuid4().hex + oauth2_endpoint = "https://localhost/token" + + client_cred = self.create(oauth2_endpoint=oauth2_endpoint, + oauth2_client_id=client_id + ) + self.assertEqual(self.auth_url, client_cred.auth_url) + self.assertEqual(client_id, client_cred.oauth2_client_id) + self.assertEqual(oauth2_endpoint, client_cred.oauth2_endpoint) + + def test_without_oauth2_endpoint(self): + client_id = uuid.uuid4().hex + self.assertRaises(exceptions.OptionError, + self.create, + oauth2_client_id=client_id, + ) + + def test_without_client_id(self): + oauth2_endpoint = "https://localhost/token" + self.assertRaises(exceptions.OptionError, + self.create, + oauth2_endpoint=oauth2_endpoint, + oauth2_client_secret=uuid.uuid4().hex) diff --git a/keystoneauth1/tests/unit/test_fixtures.py b/keystoneauth1/tests/unit/test_fixtures.py index 234cca08..c7ee636e 100644 --- a/keystoneauth1/tests/unit/test_fixtures.py +++ b/keystoneauth1/tests/unit/test_fixtures.py @@ -346,3 +346,17 @@ class V3TokenTests(utils.TestCase): del token.is_admin_project self.assertIsNone(token.is_admin_project) self.assertNotIn('is_admin_project', token['token']) + + def test_oauth2(self): + methods = ['oauth2_credential'] + oauth2_thumbprint = uuid.uuid4().hex + token = fixture.V3Token( + methods=methods, + oauth2_thumbprint=oauth2_thumbprint, + ) + oauth2_credential = { + 'x5t#S256': oauth2_thumbprint, + } + self.assertEqual(methods, token.methods) + self.assertEqual(oauth2_credential, token.oauth2_credential) + self.assertEqual(oauth2_thumbprint, token.oauth2_thumbprint) diff --git a/releasenotes/notes/bp-support-oauth2-mtls-177cda05265ae65c.yaml b/releasenotes/notes/bp-support-oauth2-mtls-177cda05265ae65c.yaml new file mode 100644 index 00000000..cd630bbf --- /dev/null +++ b/releasenotes/notes/bp-support-oauth2-mtls-177cda05265ae65c.yaml @@ -0,0 +1,10 @@ +--- +features: + - | + [`blueprint support-oauth2-mtls `_] + Added a new OAuth2mTlsClientCredential plugin, accessible via the + 'v3oauth2mtlsclientcredential' entry point, making possible to authenticate + using an OAuth 2.0 Mutual-TLS client credentials. Keystoneauth can now + be used to access the OpenStack APIs that use the keystone middleware to + support OAuth2.0 mutual-TLS client authentication through the keystone + identity server. diff --git a/setup.cfg b/setup.cfg index c6f595f8..9f4de6b5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -62,3 +62,4 @@ keystoneauth1.plugin = v3applicationcredential = keystoneauth1.loading._plugins.identity.v3:ApplicationCredential v3multifactor = keystoneauth1.loading._plugins.identity.v3:MultiFactor v3oauth2clientcredential = keystoneauth1.loading._plugins.identity.v3:OAuth2ClientCredential + v3oauth2mtlsclientcredential = keystoneauth1.loading._plugins.identity.v3:OAuth2mTlsClientCredential