Merge "Add openid connect client support"
This commit is contained in:
		
							
								
								
									
										189
									
								
								keystoneclient/contrib/auth/v3/oidc.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										189
									
								
								keystoneclient/contrib/auth/v3/oidc.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,189 @@
 | 
			
		||||
# 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.
 | 
			
		||||
 | 
			
		||||
from oslo_config import cfg
 | 
			
		||||
 | 
			
		||||
from keystoneclient import access
 | 
			
		||||
from keystoneclient.auth.identity.v3 import federated
 | 
			
		||||
from keystoneclient import utils
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class OidcPassword(federated.FederatedBaseAuth):
 | 
			
		||||
    """Implement authentication plugin for OpenID Connect protocol.
 | 
			
		||||
 | 
			
		||||
    OIDC or OpenID Connect is a protocol for federated authentication.
 | 
			
		||||
 | 
			
		||||
    The OpenID Connect specification can be found at::
 | 
			
		||||
    ``http://openid.net/specs/openid-connect-core-1_0.html``
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def get_options(cls):
 | 
			
		||||
        options = super(OidcPassword, cls).get_options()
 | 
			
		||||
        options.extend([
 | 
			
		||||
            cfg.StrOpt('username', help='Username'),
 | 
			
		||||
            cfg.StrOpt('password', help='Password'),
 | 
			
		||||
            cfg.StrOpt('client-id', help='OAuth 2.0 Client ID'),
 | 
			
		||||
            cfg.StrOpt('client-secret', help='OAuth 2.0 Client Secret'),
 | 
			
		||||
            cfg.StrOpt('access-token-endpoint',
 | 
			
		||||
                       help='OpenID Connect Provider Token Endpoint'),
 | 
			
		||||
            cfg.StrOpt('scope', default="profile",
 | 
			
		||||
                       help='OpenID Connect scope that is requested from OP')
 | 
			
		||||
        ])
 | 
			
		||||
        return options
 | 
			
		||||
 | 
			
		||||
    @utils.positional(4)
 | 
			
		||||
    def __init__(self, auth_url, identity_provider, protocol,
 | 
			
		||||
                 username, password, client_id, client_secret,
 | 
			
		||||
                 access_token_endpoint, scope='profile',
 | 
			
		||||
                 grant_type='password'):
 | 
			
		||||
        """The OpenID Connect plugin expects the following:
 | 
			
		||||
 | 
			
		||||
        :param auth_url: URL of the Identity Service
 | 
			
		||||
        :type auth_url: string
 | 
			
		||||
 | 
			
		||||
        :param identity_provider: Name of the Identity Provider the client
 | 
			
		||||
                                  will authenticate against
 | 
			
		||||
        :type identity_provider: string
 | 
			
		||||
 | 
			
		||||
        :param protocol: Protocol name as configured in keystone
 | 
			
		||||
        :type protocol: string
 | 
			
		||||
 | 
			
		||||
        :param username: Username used to authenticate
 | 
			
		||||
        :type username: string
 | 
			
		||||
 | 
			
		||||
        :param password: Password used to authenticate
 | 
			
		||||
        :type password: string
 | 
			
		||||
 | 
			
		||||
        :param client_id: OAuth 2.0 Client ID
 | 
			
		||||
        :type client_id: string
 | 
			
		||||
 | 
			
		||||
        :param client_secret: OAuth 2.0 Client Secret
 | 
			
		||||
        :type client_secret: string
 | 
			
		||||
 | 
			
		||||
        :param access_token_endpoint: OpenID Connect Provider Token Endpoint,
 | 
			
		||||
                                      for example:
 | 
			
		||||
                                      https://localhost:8020/oidc/OP/token
 | 
			
		||||
        :type access_token_endpoint: string
 | 
			
		||||
 | 
			
		||||
        :param scope: OpenID Connect scope that is requested from OP,
 | 
			
		||||
                      defaults to "profile", for example: "profile email"
 | 
			
		||||
        :type scope: string
 | 
			
		||||
 | 
			
		||||
        :param grant_type: OpenID Connect grant type, it represents the flow
 | 
			
		||||
                           that is used to talk to the OP. Valid values are:
 | 
			
		||||
                           "authorization_code", "refresh_token", or
 | 
			
		||||
                           "password".
 | 
			
		||||
        :type grant_type: string
 | 
			
		||||
        """
 | 
			
		||||
        super(OidcPassword, self).__init__(auth_url, identity_provider,
 | 
			
		||||
                                           protocol)
 | 
			
		||||
        self.username = username
 | 
			
		||||
        self.password = password
 | 
			
		||||
        self.client_id = client_id
 | 
			
		||||
        self.client_secret = client_secret
 | 
			
		||||
        self.access_token_endpoint = access_token_endpoint
 | 
			
		||||
        self.scope = scope
 | 
			
		||||
        self.grant_type = grant_type
 | 
			
		||||
 | 
			
		||||
    def get_unscoped_auth_ref(self, session):
 | 
			
		||||
        """Authenticate with OpenID Connect and get back claims.
 | 
			
		||||
 | 
			
		||||
        This is a multi-step process. First an access token must be retrieved,
 | 
			
		||||
        to do this, the username and password, the OpenID Connect client ID
 | 
			
		||||
        and secret, and the access token endpoint must be known.
 | 
			
		||||
 | 
			
		||||
        Secondly, we then exchange the access token upon accessing the
 | 
			
		||||
        protected Keystone endpoint (federated auth URL). This will trigger
 | 
			
		||||
        the OpenID Connect Provider to perform a user introspection and
 | 
			
		||||
        retrieve information (specified in the scope) about the user in
 | 
			
		||||
        the form of an OpenID Connect Claim. These claims will be sent
 | 
			
		||||
        to Keystone in the form of environment variables.
 | 
			
		||||
 | 
			
		||||
        :param session: a session object to send out HTTP requests.
 | 
			
		||||
        :type session: keystoneclient.session.Session
 | 
			
		||||
 | 
			
		||||
        :returns: a token data representation
 | 
			
		||||
        :rtype: :py:class:`keystoneclient.access.AccessInfo`
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        # get an access token
 | 
			
		||||
        client_auth = (self.client_id, self.client_secret)
 | 
			
		||||
        payload = {'grant_type': self.grant_type, 'username': self.username,
 | 
			
		||||
                   'password': self.password, 'scope': self.scope}
 | 
			
		||||
        response = self._get_access_token(session, client_auth, payload,
 | 
			
		||||
                                          self.access_token_endpoint)
 | 
			
		||||
        access_token = response.json()['access_token']
 | 
			
		||||
 | 
			
		||||
        # use access token against protected URL
 | 
			
		||||
        headers = {'Authorization': 'Bearer ' + access_token}
 | 
			
		||||
        response = self._get_keystone_token(session, headers,
 | 
			
		||||
                                            self.federated_token_url)
 | 
			
		||||
 | 
			
		||||
        # grab the unscoped token
 | 
			
		||||
        token = response.headers['X-Subject-Token']
 | 
			
		||||
        token_json = response.json()['token']
 | 
			
		||||
        return access.AccessInfoV3(token, **token_json)
 | 
			
		||||
 | 
			
		||||
    def _get_access_token(self, session, client_auth, payload,
 | 
			
		||||
                          access_token_endpoint):
 | 
			
		||||
        """Exchange a variety of user supplied values for an access token.
 | 
			
		||||
 | 
			
		||||
        :param session: a session object to send out HTTP requests.
 | 
			
		||||
        :type session: keystoneclient.session.Session
 | 
			
		||||
 | 
			
		||||
        :param client_auth: a tuple representing client id and secret
 | 
			
		||||
        :type client_auth: tuple
 | 
			
		||||
 | 
			
		||||
        :param payload: a dict containing various OpenID Connect values, for
 | 
			
		||||
                        example::
 | 
			
		||||
                          {'grant_type': 'password', 'username': self.username,
 | 
			
		||||
                           'password': self.password, 'scope': self.scope}
 | 
			
		||||
        :type payload: dict
 | 
			
		||||
 | 
			
		||||
        :param access_token_endpoint: URL to use to get an access token, for
 | 
			
		||||
                                      example: https://localhost/oidc/token
 | 
			
		||||
        :type access_token_endpoint: string
 | 
			
		||||
        """
 | 
			
		||||
        op_response = session.post(self.access_token_endpoint,
 | 
			
		||||
                                   requests_auth=client_auth,
 | 
			
		||||
                                   data=payload,
 | 
			
		||||
                                   authenticated=False)
 | 
			
		||||
        return op_response
 | 
			
		||||
 | 
			
		||||
    def _get_keystone_token(self, session, headers, federated_token_url):
 | 
			
		||||
        """Exchange an acess token for a keystone token.
 | 
			
		||||
 | 
			
		||||
        By Sending the access token in an `Authorization: Bearer` header, to
 | 
			
		||||
        an OpenID Connect protected endpoint (Federated Token URL). The
 | 
			
		||||
        OpenID Connect server will use the access token to look up information
 | 
			
		||||
        about the authenticated user (this technique is called instrospection).
 | 
			
		||||
        The output of the instrospection will be an OpenID Connect Claim, that
 | 
			
		||||
        will be used against the mapping engine. Should the mapping engine
 | 
			
		||||
        succeed, a Keystone token will be presented to the user.
 | 
			
		||||
 | 
			
		||||
        :param session: a session object to send out HTTP requests.
 | 
			
		||||
        :type session: keystoneclient.session.Session
 | 
			
		||||
 | 
			
		||||
        :param headers: an Authorization header containing the access token.
 | 
			
		||||
        :type headers_: dict
 | 
			
		||||
 | 
			
		||||
        :param federated_auth_url: Protected URL for federated authentication,
 | 
			
		||||
                                   for example: https://localhost:5000/v3/\
 | 
			
		||||
                                   OS-FEDERATION/identity_providers/bluepages/\
 | 
			
		||||
                                   protocols/oidc/auth
 | 
			
		||||
        :type federated_auth_url: string
 | 
			
		||||
        """
 | 
			
		||||
        auth_response = session.post(self.federated_token_url,
 | 
			
		||||
                                     headers=headers,
 | 
			
		||||
                                     authenticated=False)
 | 
			
		||||
        return auth_response
 | 
			
		||||
							
								
								
									
										190
									
								
								keystoneclient/tests/unit/v3/test_auth_oidc.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										190
									
								
								keystoneclient/tests/unit/v3/test_auth_oidc.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,190 @@
 | 
			
		||||
#    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 oslo_config import fixture as config
 | 
			
		||||
from six.moves import urllib
 | 
			
		||||
import testtools
 | 
			
		||||
 | 
			
		||||
from keystoneclient.auth import conf
 | 
			
		||||
from keystoneclient.contrib.auth.v3 import oidc
 | 
			
		||||
from keystoneclient import session
 | 
			
		||||
from keystoneclient.tests.unit.v3 import utils
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
ACCESS_TOKEN_ENDPOINT_RESP = {"access_token": "z5H1ITZLlJVDHQXqJun",
 | 
			
		||||
                              "token_type": "bearer",
 | 
			
		||||
                              "expires_in": 3599,
 | 
			
		||||
                              "scope": "profile",
 | 
			
		||||
                              "refresh_token": "DCERsh83IAhu9bhavrp"}
 | 
			
		||||
 | 
			
		||||
KEYSTONE_TOKEN_VALUE = uuid.uuid4().hex
 | 
			
		||||
UNSCOPED_TOKEN = {
 | 
			
		||||
    "token": {
 | 
			
		||||
        "issued_at": "2014-06-09T09:48:59.643406Z",
 | 
			
		||||
        "extras": {},
 | 
			
		||||
        "methods": ["oidc"],
 | 
			
		||||
        "expires_at": "2014-06-09T10:48:59.643375Z",
 | 
			
		||||
        "user": {
 | 
			
		||||
            "OS-FEDERATION": {
 | 
			
		||||
                "identity_provider": {
 | 
			
		||||
                    "id": "bluepages"
 | 
			
		||||
                },
 | 
			
		||||
                "protocol": {
 | 
			
		||||
                    "id": "oidc"
 | 
			
		||||
                },
 | 
			
		||||
                "groups": [
 | 
			
		||||
                    {"id": "1764fa5cf69a49a4918131de5ce4af9a"}
 | 
			
		||||
                ]
 | 
			
		||||
            },
 | 
			
		||||
            "id": "oidc_user%40example.com",
 | 
			
		||||
            "name": "oidc_user@example.com"
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AuthenticateOIDCTests(utils.TestCase):
 | 
			
		||||
 | 
			
		||||
    GROUP = 'auth'
 | 
			
		||||
 | 
			
		||||
    def setUp(self):
 | 
			
		||||
        super(AuthenticateOIDCTests, self).setUp()
 | 
			
		||||
 | 
			
		||||
        self.conf_fixture = self.useFixture(config.Config())
 | 
			
		||||
        conf.register_conf_options(self.conf_fixture.conf, group=self.GROUP)
 | 
			
		||||
 | 
			
		||||
        self.session = session.Session()
 | 
			
		||||
 | 
			
		||||
        self.IDENTITY_PROVIDER = 'bluepages'
 | 
			
		||||
        self.PROTOCOL = 'oidc'
 | 
			
		||||
        self.USER_NAME = 'oidc_user@example.com'
 | 
			
		||||
        self.PASSWORD = uuid.uuid4().hex
 | 
			
		||||
        self.CLIENT_ID = uuid.uuid4().hex
 | 
			
		||||
        self.CLIENT_SECRET = uuid.uuid4().hex
 | 
			
		||||
        self.ACCESS_TOKEN_ENDPOINT = 'https://localhost:8020/oidc/token'
 | 
			
		||||
        self.FEDERATION_AUTH_URL = '%s/%s' % (
 | 
			
		||||
            self.TEST_URL,
 | 
			
		||||
            'OS-FEDERATION/identity_providers/bluepages/protocols/oidc/auth')
 | 
			
		||||
 | 
			
		||||
        self.oidcplugin = oidc.OidcPassword(
 | 
			
		||||
            self.TEST_URL,
 | 
			
		||||
            self.IDENTITY_PROVIDER,
 | 
			
		||||
            self.PROTOCOL,
 | 
			
		||||
            username=self.USER_NAME,
 | 
			
		||||
            password=self.PASSWORD,
 | 
			
		||||
            client_id=self.CLIENT_ID,
 | 
			
		||||
            client_secret=self.CLIENT_SECRET,
 | 
			
		||||
            access_token_endpoint=self.ACCESS_TOKEN_ENDPOINT)
 | 
			
		||||
 | 
			
		||||
    @testtools.skip("TypeError: __init__() got an unexpected keyword"
 | 
			
		||||
                    " argument 'project_name'")
 | 
			
		||||
    def test_conf_params(self):
 | 
			
		||||
        """Ensure OpenID Connect config options work."""
 | 
			
		||||
 | 
			
		||||
        section = uuid.uuid4().hex
 | 
			
		||||
        identity_provider = uuid.uuid4().hex
 | 
			
		||||
        protocol = uuid.uuid4().hex
 | 
			
		||||
        username = uuid.uuid4().hex
 | 
			
		||||
        password = uuid.uuid4().hex
 | 
			
		||||
        client_id = uuid.uuid4().hex
 | 
			
		||||
        client_secret = uuid.uuid4().hex
 | 
			
		||||
        access_token_endpoint = uuid.uuid4().hex
 | 
			
		||||
 | 
			
		||||
        self.conf_fixture.config(auth_section=section, group=self.GROUP)
 | 
			
		||||
        conf.register_conf_options(self.conf_fixture.conf, group=self.GROUP)
 | 
			
		||||
 | 
			
		||||
        self.conf_fixture.register_opts(oidc.OidcPassword.get_options(),
 | 
			
		||||
                                        group=section)
 | 
			
		||||
        self.conf_fixture.config(auth_plugin='v3oidcpassword',
 | 
			
		||||
                                 identity_provider=identity_provider,
 | 
			
		||||
                                 protocol=protocol,
 | 
			
		||||
                                 username=username,
 | 
			
		||||
                                 password=password,
 | 
			
		||||
                                 client_id=client_id,
 | 
			
		||||
                                 client_secret=client_secret,
 | 
			
		||||
                                 access_token_endpoint=access_token_endpoint,
 | 
			
		||||
                                 group=section)
 | 
			
		||||
 | 
			
		||||
        a = conf.load_from_conf_options(self.conf_fixture.conf, self.GROUP)
 | 
			
		||||
        self.assertEqual(identity_provider, a.identity_provider)
 | 
			
		||||
        self.assertEqual(protocol, a.protocol)
 | 
			
		||||
        self.assertEqual(username, a.username)
 | 
			
		||||
        self.assertEqual(password, a.password)
 | 
			
		||||
        self.assertEqual(client_id, a.client_id)
 | 
			
		||||
        self.assertEqual(client_secret, a.client_secret)
 | 
			
		||||
        self.assertEqual(access_token_endpoint, a.access_token_endpoint)
 | 
			
		||||
 | 
			
		||||
    def test_initial_call_to_get_access_token(self):
 | 
			
		||||
        """Test initial call, expect JSON access token."""
 | 
			
		||||
 | 
			
		||||
        # Mock the output that creates the access token
 | 
			
		||||
        self.requests_mock.post(
 | 
			
		||||
            self.ACCESS_TOKEN_ENDPOINT,
 | 
			
		||||
            json=ACCESS_TOKEN_ENDPOINT_RESP)
 | 
			
		||||
 | 
			
		||||
        # Prep all the values and send the request
 | 
			
		||||
        grant_type = 'password'
 | 
			
		||||
        scope = 'profile email'
 | 
			
		||||
        client_auth = (self.CLIENT_ID, self.CLIENT_SECRET)
 | 
			
		||||
        payload = {'grant_type': grant_type, 'username': self.USER_NAME,
 | 
			
		||||
                   'password': self.PASSWORD, 'scope': scope}
 | 
			
		||||
        res = self.oidcplugin._get_access_token(self.session,
 | 
			
		||||
                                                client_auth,
 | 
			
		||||
                                                payload,
 | 
			
		||||
                                                self.ACCESS_TOKEN_ENDPOINT)
 | 
			
		||||
 | 
			
		||||
        # Verify the request matches the expected structure
 | 
			
		||||
        self.assertEqual(self.ACCESS_TOKEN_ENDPOINT, res.request.url)
 | 
			
		||||
        self.assertEqual('POST', res.request.method)
 | 
			
		||||
        encoded_payload = urllib.parse.urlencode(payload)
 | 
			
		||||
        self.assertEqual(encoded_payload, res.request.body)
 | 
			
		||||
 | 
			
		||||
    def test_second_call_to_protected_url(self):
 | 
			
		||||
        """Test subsequent call, expect Keystone token."""
 | 
			
		||||
 | 
			
		||||
        # Mock the output that creates the keystone token
 | 
			
		||||
        self.requests_mock.post(
 | 
			
		||||
            self.FEDERATION_AUTH_URL,
 | 
			
		||||
            json=UNSCOPED_TOKEN,
 | 
			
		||||
            headers={'X-Subject-Token': KEYSTONE_TOKEN_VALUE})
 | 
			
		||||
 | 
			
		||||
        # Prep all the values and send the request
 | 
			
		||||
        access_token = uuid.uuid4().hex
 | 
			
		||||
        headers = {'Authorization': 'Bearer ' + access_token}
 | 
			
		||||
        res = self.oidcplugin._get_keystone_token(self.session,
 | 
			
		||||
                                                  headers,
 | 
			
		||||
                                                  self.FEDERATION_AUTH_URL)
 | 
			
		||||
 | 
			
		||||
        # Verify the request matches the expected structure
 | 
			
		||||
        self.assertEqual(self.FEDERATION_AUTH_URL, res.request.url)
 | 
			
		||||
        self.assertEqual('POST', res.request.method)
 | 
			
		||||
        self.assertEqual(headers['Authorization'],
 | 
			
		||||
                         res.request.headers['Authorization'])
 | 
			
		||||
 | 
			
		||||
    def test_end_to_end_workflow(self):
 | 
			
		||||
        """Test full OpenID Connect workflow."""
 | 
			
		||||
 | 
			
		||||
        # Mock the output that creates the access token
 | 
			
		||||
        self.requests_mock.post(
 | 
			
		||||
            self.ACCESS_TOKEN_ENDPOINT,
 | 
			
		||||
            json=ACCESS_TOKEN_ENDPOINT_RESP)
 | 
			
		||||
 | 
			
		||||
        # Mock the output that creates the keystone token
 | 
			
		||||
        self.requests_mock.post(
 | 
			
		||||
            self.FEDERATION_AUTH_URL,
 | 
			
		||||
            json=UNSCOPED_TOKEN,
 | 
			
		||||
            headers={'X-Subject-Token': KEYSTONE_TOKEN_VALUE})
 | 
			
		||||
 | 
			
		||||
        response = self.oidcplugin.get_unscoped_auth_ref(self.session)
 | 
			
		||||
        self.assertEqual(KEYSTONE_TOKEN_VALUE, response.auth_token)
 | 
			
		||||
@@ -34,6 +34,7 @@ keystoneclient.auth.plugin =
 | 
			
		||||
    v2token = keystoneclient.auth.identity.v2:Token
 | 
			
		||||
    v3password = keystoneclient.auth.identity.v3:Password
 | 
			
		||||
    v3token = keystoneclient.auth.identity.v3:Token
 | 
			
		||||
    v3oidcpassword = keystoneclient.contrib.auth.v3.oidc:OidcPassword
 | 
			
		||||
    v3unscopedsaml = keystoneclient.contrib.auth.v3.saml2:Saml2UnscopedToken
 | 
			
		||||
    v3scopedsaml = keystoneclient.contrib.auth.v3.saml2:Saml2ScopedToken
 | 
			
		||||
    v3unscopedadfs = keystoneclient.contrib.auth.v3.saml2:ADFSUnscopedToken
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user