summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSteve Martinelli <stevemar@ca.ibm.com>2014-11-15 05:47:32 -0500
committerSteve Martinelli <stevemar@ca.ibm.com>2015-06-17 11:15:03 -0400
commit02f07cfb493b2b81ab4e64d3d674a0ea6af7500b (patch)
tree687d518395efa2a08ae2a4e743fb6fac5bef652d
parent54d5b1a4cabb9902b79dafc879d49b4f2b84fb72 (diff)
downloadpython-keystoneclient-02f07cfb493b2b81ab4e64d3d674a0ea6af7500b.tar.gz
Add openid connect client support
This patch allows a federated user to obtain an unscoped token by providing login credentials for a keystone identity provider. The current implementation should work with any properly configured openid connect provider. partially implements bp openid-connect Change-Id: Iade52b5c1432d64582cbaa8bac41ac6366c210f9
-rw-r--r--keystoneclient/contrib/auth/v3/oidc.py189
-rw-r--r--keystoneclient/tests/unit/v3/test_auth_oidc.py190
-rw-r--r--setup.cfg1
3 files changed, 380 insertions, 0 deletions
diff --git a/keystoneclient/contrib/auth/v3/oidc.py b/keystoneclient/contrib/auth/v3/oidc.py
new file mode 100644
index 0000000..6105e06
--- /dev/null
+++ b/keystoneclient/contrib/auth/v3/oidc.py
@@ -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
diff --git a/keystoneclient/tests/unit/v3/test_auth_oidc.py b/keystoneclient/tests/unit/v3/test_auth_oidc.py
new file mode 100644
index 0000000..a866e88
--- /dev/null
+++ b/keystoneclient/tests/unit/v3/test_auth_oidc.py
@@ -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)
diff --git a/setup.cfg b/setup.cfg
index ae374ef..e7aa998 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -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