From e5fd66ca35424108ca0c1234119d57dca85c93f7 Mon Sep 17 00:00:00 2001
From: Alvaro Lopez Garcia <aloga@ifca.unican.es>
Date: Wed, 20 Jul 2016 09:31:07 +0200
Subject: [PATCH] oidc: implement client_credentials grant type

Change-Id: If1538726cb7e4cb87fad82c5daf50c67b161b52d
---
 keystoneauth1/identity/__init__.py            |  3 +
 keystoneauth1/identity/v3/__init__.py         |  1 +
 keystoneauth1/identity/v3/oidc.py             | 44 ++++++++++++
 keystoneauth1/loading/_plugins/identity/v3.py | 12 ++++
 .../unit/identity/test_identity_v3_oidc.py    | 70 +++++++++++++++++++
 keystoneauth1/tests/unit/loading/test_v3.py   | 36 ++++++++++
 ...c-client-credentials-2be065926ba4b849.yaml |  4 ++
 setup.cfg                                     |  1 +
 8 files changed, 171 insertions(+)
 create mode 100644 releasenotes/notes/add-oidc-client-credentials-2be065926ba4b849.yaml

diff --git a/keystoneauth1/identity/__init__.py b/keystoneauth1/identity/__init__.py
index 74bb988c..18207ce3 100644
--- a/keystoneauth1/identity/__init__.py
+++ b/keystoneauth1/identity/__init__.py
@@ -37,6 +37,9 @@ Password = generic.Password
 Token = generic.Token
 """See :class:`keystoneauth1.identity.generic.Token`"""
 
+V3OidcClientCredentials = oidc.OidcClientCredentials
+"""See :class:`keystoneauth1.identity.v3.oidc.OidcClientCredentials`"""
+
 V3OidcPassword = oidc.OidcPassword
 """See :class:`keystoneauth1.identity.v3.oidc.OidcPassword`"""
 
diff --git a/keystoneauth1/identity/v3/__init__.py b/keystoneauth1/identity/v3/__init__.py
index e44f8860..1df5d466 100644
--- a/keystoneauth1/identity/v3/__init__.py
+++ b/keystoneauth1/identity/v3/__init__.py
@@ -36,6 +36,7 @@ __all__ = ('Auth',
            'TokenMethod',
 
            'OidcAuthorizationCode',
+           'OidcClientCredentials',
            'OidcPassword',
 
            'TOTPMethod',
diff --git a/keystoneauth1/identity/v3/oidc.py b/keystoneauth1/identity/v3/oidc.py
index b25e693d..a99b9b10 100644
--- a/keystoneauth1/identity/v3/oidc.py
+++ b/keystoneauth1/identity/v3/oidc.py
@@ -331,6 +331,50 @@ class OidcPassword(_OidcBase):
         return payload
 
 
+class OidcClientCredentials(_OidcBase):
+    """Implementation for OpenID Connect Client Credentials."""
+
+    grant_type = 'client_credentials'
+
+    @positional(4)
+    def __init__(self, auth_url, identity_provider, protocol,
+                 client_id, client_secret,
+                 access_token_endpoint=None,
+                 discovery_endpoint=None,
+                 access_token_type='access_token',
+                 **kwargs):
+        """The OpenID Client Credentials expects the following.
+
+        :param client_id: Client ID used to authenticate
+        :type username: string
+
+        :param client_secret: Client Secret used to authenticate
+        :type password: string
+        """
+        super(OidcClientCredentials, self).__init__(
+            auth_url=auth_url,
+            identity_provider=identity_provider,
+            protocol=protocol,
+            client_id=client_id,
+            client_secret=client_secret,
+            access_token_endpoint=access_token_endpoint,
+            discovery_endpoint=discovery_endpoint,
+            access_token_type=access_token_type,
+            **kwargs)
+
+    def get_payload(self, session):
+        """Get an authorization grant for the client credentials grant type.
+
+        :param session: a session object to send out HTTP requests.
+        :type session: keystoneauth1.session.Session
+
+        :returns: a python dictionary containing the payload to be exchanged
+        :rtype: dict
+        """
+        payload = {'scope': self.scope}
+        return payload
+
+
 class OidcAuthorizationCode(_OidcBase):
     """Implementation for OpenID Connect Authorization Code."""
 
diff --git a/keystoneauth1/loading/_plugins/identity/v3.py b/keystoneauth1/loading/_plugins/identity/v3.py
index fb36a708..3faea15d 100644
--- a/keystoneauth1/loading/_plugins/identity/v3.py
+++ b/keystoneauth1/loading/_plugins/identity/v3.py
@@ -123,6 +123,18 @@ class _OpenIDConnectBase(loading.BaseFederationLoader):
         return options
 
 
+class OpenIDConnectClientCredentials(_OpenIDConnectBase):
+
+    @property
+    def plugin_class(self):
+        return identity.V3OidcClientCredentials
+
+    def get_options(self):
+        options = super(OpenIDConnectClientCredentials, self).get_options()
+
+        return options
+
+
 class OpenIDConnectPassword(_OpenIDConnectBase):
 
     @property
diff --git a/keystoneauth1/tests/unit/identity/test_identity_v3_oidc.py b/keystoneauth1/tests/unit/identity/test_identity_v3_oidc.py
index 4489d88e..9bf12181 100644
--- a/keystoneauth1/tests/unit/identity/test_identity_v3_oidc.py
+++ b/keystoneauth1/tests/unit/identity/test_identity_v3_oidc.py
@@ -180,6 +180,76 @@ class BaseOIDCTests(object):
                           self.session)
 
 
+class OIDCClientCredentialsTests(BaseOIDCTests, utils.TestCase):
+    def setUp(self):
+        super(OIDCClientCredentialsTests, self).setUp()
+
+        self.GRANT_TYPE = 'client_credentials'
+
+        self.plugin = oidc.OidcClientCredentials(
+            self.AUTH_URL,
+            self.IDENTITY_PROVIDER,
+            self.PROTOCOL,
+            client_id=self.CLIENT_ID,
+            client_secret=self.CLIENT_SECRET,
+            access_token_endpoint=self.ACCESS_TOKEN_ENDPOINT,
+            project_name=self.PROJECT_NAME)
+
+    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=oidc_fixtures.ACCESS_TOKEN_VIA_PASSWORD_RESP)
+
+        # Prep all the values and send the request
+        scope = 'profile email'
+        payload = {'grant_type': self.GRANT_TYPE, 'scope': scope}
+        self.plugin._get_access_token(self.session, payload)
+
+        # Verify the request matches the expected structure
+        last_req = self.requests_mock.last_request
+        self.assertEqual(self.ACCESS_TOKEN_ENDPOINT, last_req.url)
+        self.assertEqual('POST', last_req.method)
+        encoded_payload = urllib.parse.urlencode(payload)
+        self.assertEqual(encoded_payload, last_req.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=oidc_fixtures.UNSCOPED_TOKEN,
+            headers={'X-Subject-Token': KEYSTONE_TOKEN_VALUE})
+
+        res = self.plugin._get_keystone_token(self.session,
+                                              self.ACCESS_TOKEN)
+
+        # Verify the request matches the expected structure
+        self.assertEqual(self.FEDERATION_AUTH_URL, res.request.url)
+        self.assertEqual('POST', res.request.method)
+
+        headers = {'Authorization': 'Bearer ' + self.ACCESS_TOKEN}
+        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=oidc_fixtures.ACCESS_TOKEN_VIA_PASSWORD_RESP)
+
+        # Mock the output that creates the keystone token
+        self.requests_mock.post(
+            self.FEDERATION_AUTH_URL,
+            json=oidc_fixtures.UNSCOPED_TOKEN,
+            headers={'X-Subject-Token': KEYSTONE_TOKEN_VALUE})
+
+        response = self.plugin.get_unscoped_auth_ref(self.session)
+        self.assertEqual(KEYSTONE_TOKEN_VALUE, response.auth_token)
+
+
 class OIDCPasswordTests(BaseOIDCTests, utils.TestCase):
     def setUp(self):
         super(OIDCPasswordTests, self).setUp()
diff --git a/keystoneauth1/tests/unit/loading/test_v3.py b/keystoneauth1/tests/unit/loading/test_v3.py
index 090e38f5..c83e9ce0 100644
--- a/keystoneauth1/tests/unit/loading/test_v3.py
+++ b/keystoneauth1/tests/unit/loading/test_v3.py
@@ -143,6 +143,42 @@ class OpenIDConnectBaseTests(object):
         self.assertIn('scope', [o.dest for o in options])
 
 
+class OpenIDConnectClientCredentialsTests(OpenIDConnectBaseTests,
+                                          utils.TestCase):
+
+    plugin_name = "v3oidcclientcredentials"
+
+    def test_options(self):
+        options = loading.get_plugin_loader(self.plugin_name).get_options()
+        self.assertTrue(
+            set(['openid-scope']).issubset(
+                set([o.name for o in options]))
+        )
+
+    def test_basic(self):
+        access_token_endpoint = uuid.uuid4().hex
+        scope = uuid.uuid4().hex
+        identity_provider = uuid.uuid4().hex
+        protocol = uuid.uuid4().hex
+        scope = uuid.uuid4().hex
+        client_id = uuid.uuid4().hex
+        client_secret = uuid.uuid4().hex
+
+        oidc = self.create(identity_provider=identity_provider,
+                           protocol=protocol,
+                           access_token_endpoint=access_token_endpoint,
+                           client_id=client_id,
+                           client_secret=client_secret,
+                           scope=scope)
+
+        self.assertEqual(scope, oidc.scope)
+        self.assertEqual(identity_provider, oidc.identity_provider)
+        self.assertEqual(protocol, oidc.protocol)
+        self.assertEqual(access_token_endpoint, oidc.access_token_endpoint)
+        self.assertEqual(client_id, oidc.client_id)
+        self.assertEqual(client_secret, oidc.client_secret)
+
+
 class OpenIDConnectPasswordTests(OpenIDConnectBaseTests, utils.TestCase):
 
     plugin_name = "v3oidcpassword"
diff --git a/releasenotes/notes/add-oidc-client-credentials-2be065926ba4b849.yaml b/releasenotes/notes/add-oidc-client-credentials-2be065926ba4b849.yaml
new file mode 100644
index 00000000..a5dadd77
--- /dev/null
+++ b/releasenotes/notes/add-oidc-client-credentials-2be065926ba4b849.yaml
@@ -0,0 +1,4 @@
+---
+features:
+  - Add support for the Client Credentials OpenID Connect
+    grant type.
diff --git a/setup.cfg b/setup.cfg
index eb71d93a..07cc12ea 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -45,6 +45,7 @@ keystoneauth1.plugin =
     v2token = keystoneauth1.loading._plugins.identity.v2:Token
     v3password = keystoneauth1.loading._plugins.identity.v3:Password
     v3token = keystoneauth1.loading._plugins.identity.v3:Token
+    v3oidcclientcredentials = keystoneauth1.loading._plugins.identity.v3:OpenIDConnectClientCredentials
     v3oidcpassword = keystoneauth1.loading._plugins.identity.v3:OpenIDConnectPassword
     v3oidcauthcode = keystoneauth1.loading._plugins.identity.v3:OpenIDConnectAuthorizationCode
     v3oidcaccesstoken = keystoneauth1.loading._plugins.identity.v3:OpenIDConnectAccessToken