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 <hiromu.asahina.az@hco.ntt.co.jp>
Change-Id: I0e02ef18da5d60cdd1bcde07b07c2071b74b73d6
Implements: blueprint support-oauth2-mtls
This commit is contained in:
sunyonggen 2022-10-07 11:17:13 +09:00 committed by Hiromu Asahina
parent 6ee21bd722
commit ca28df8480
11 changed files with 497 additions and 3 deletions

View File

@ -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']

View File

@ -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')

View File

@ -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')

View File

@ -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',
)

View File

@ -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

View File

@ -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)

View File

@ -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'])

View File

@ -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)

View File

@ -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)

View File

@ -0,0 +1,10 @@
---
features:
- |
[`blueprint support-oauth2-mtls <https://blueprints.launchpad.net/keystone/+spec/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.

View File

@ -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