diff options
author | Steve Martinelli <stevemar@ca.ibm.com> | 2014-11-15 05:47:32 -0500 |
---|---|---|
committer | Steve Martinelli <stevemar@ca.ibm.com> | 2015-06-17 11:15:03 -0400 |
commit | 02f07cfb493b2b81ab4e64d3d674a0ea6af7500b (patch) | |
tree | 687d518395efa2a08ae2a4e743fb6fac5bef652d | |
parent | 54d5b1a4cabb9902b79dafc879d49b4f2b84fb72 (diff) | |
download | python-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.py | 189 | ||||
-rw-r--r-- | keystoneclient/tests/unit/v3/test_auth_oidc.py | 190 | ||||
-rw-r--r-- | setup.cfg | 1 |
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) @@ -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 |