From b3a454c56cfffe631bde1ba04915e567c9f13fd7 Mon Sep 17 00:00:00 2001 From: Steve Martinelli Date: Fri, 18 Sep 2015 00:18:03 -0400 Subject: [PATCH] add openid connect plugins Refactor and add the existing password based plugin that exists in keystoneclient: https://github.com/openstack/python-keystoneclient/blob/master/keystoneclient/contrib/auth/v3/oidc.py It's been refactored so that a base class is created with arguments that are common to several openid connect flows. The patch also includes support for a few auth flow called authorization code. Co-Authored-By: Zhao Jian Change-Id: I35db68e288e174617a05c0db3d77d5a86048fe9e --- keystoneauth1/identity/__init__.py | 7 +- keystoneauth1/identity/v3/__init__.py | 6 +- keystoneauth1/identity/v3/oidc.py | 266 ++++++++++++++++++ keystoneauth1/loading/_plugins/identity/v3.py | 58 ++++ .../unit/identity/test_identity_v3_oidc.py | 159 +++++++++++ keystoneauth1/tests/unit/oidc_fixtures.py | 51 ++++ setup.cfg | 2 + 7 files changed, 547 insertions(+), 2 deletions(-) create mode 100644 keystoneauth1/identity/v3/oidc.py create mode 100644 keystoneauth1/tests/unit/identity/test_identity_v3_oidc.py create mode 100644 keystoneauth1/tests/unit/oidc_fixtures.py diff --git a/keystoneauth1/identity/__init__.py b/keystoneauth1/identity/__init__.py index 67e8970e..20eaa135 100644 --- a/keystoneauth1/identity/__init__.py +++ b/keystoneauth1/identity/__init__.py @@ -14,6 +14,7 @@ from keystoneauth1.identity import base from keystoneauth1.identity import generic from keystoneauth1.identity import v2 from keystoneauth1.identity import v3 +from keystoneauth1.identity.v3 import oidc BaseIdentityPlugin = base.BaseIdentityPlugin @@ -27,6 +28,8 @@ V3Token = v3.Token Password = generic.Password Token = generic.Token +V3OidcPassword = oidc.OidcPassword +V3OidcAuthorizationCode = oidc.OidcAuthorizationCode __all__ = ['BaseIdentityPlugin', 'Password', @@ -34,4 +37,6 @@ __all__ = ['BaseIdentityPlugin', 'V2Password', 'V2Token', 'V3Password', - 'V3Token'] + 'V3Token', + 'V3OidcPassword', + 'V3OidcAuthorizationCode'] diff --git a/keystoneauth1/identity/v3/__init__.py b/keystoneauth1/identity/v3/__init__.py index b0ab97a7..5cddd6ba 100644 --- a/keystoneauth1/identity/v3/__init__.py +++ b/keystoneauth1/identity/v3/__init__.py @@ -15,6 +15,7 @@ from keystoneauth1.identity.v3.federation import * # noqa from keystoneauth1.identity.v3.k2k import * # noqa from keystoneauth1.identity.v3.password import * # noqa from keystoneauth1.identity.v3.token import * # noqa +from keystoneauth1.identity.v3.oidc import * # noqa __all__ = ['Auth', @@ -30,4 +31,7 @@ __all__ = ['Auth', 'PasswordMethod', 'Token', - 'TokenMethod'] + 'TokenMethod' + + 'OidcAuthorizationCode', + 'OidcPassword'] diff --git a/keystoneauth1/identity/v3/oidc.py b/keystoneauth1/identity/v3/oidc.py new file mode 100644 index 00000000..f54d308f --- /dev/null +++ b/keystoneauth1/identity/v3/oidc.py @@ -0,0 +1,266 @@ +# 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 keystoneauth1 import _utils +from keystoneauth1 import access +from keystoneauth1.identity.v3 import federation + +__all__ = ['OidcAuthorizationCode', + 'OidcPassword'] + + +class _OidcBase(federation.FederationBaseAuth): + """Base class for different OpenID Connect based flows + + The OpenID Connect specification can be found at:: + ``http://openid.net/specs/openid-connect-core-1_0.html`` + """ + + def __init__(self, auth_url, identity_provider, protocol, + client_id, client_secret, access_token_endpoint, + grant_type, access_token_type, **kwargs): + """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 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 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 + + :param access_token_type: OAuth 2.0 Authorization Server Introspection + token type, it is used to decide which type + of token will be used when processing token + introspection. Valid values are: + "access_token" or "id_token" + :type access_token_type: string + + """ + super(_OidcBase, self).__init__(auth_url, identity_provider, protocol, + **kwargs) + self.client_id = client_id + self.client_secret = client_secret + self.access_token_endpoint = access_token_endpoint + self.grant_type = grant_type + self.access_token_type = access_token_type + + 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: keystoneauth.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: keystoneauth.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 + + +class OidcPassword(_OidcBase): + """Implementation for OpenID Connect Resource Owner Password Credential""" + + @_utils.positional(4) + def __init__(self, auth_url, identity_provider, protocol, + client_id, client_secret, access_token_endpoint, + grant_type='password', access_token_type='access_token', + username=None, password=None, scope='profile'): + """The OpenID Password plugin expects the following: + :param username: Username used to authenticate + :type username: string + + :param password: Password used to authenticate + :type password: string + + :param scope: OpenID Connect scope that is requested from OP, + defaults to "profile", for example: "profile email" + :type scope: string + + """ + super(OidcPassword, 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, + grant_type=grant_type, + access_token_type=access_token_type) + self.username = username + self.password = password + self.scope = scope + + 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: keystoneauth1.session.Session + + :returns: a token data representation + :rtype: :py:class:`keystoneauth1.access.AccessInfoV3` + """ + + # 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()[self.access_token_type] + + # 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 + return access.create(resp=response) + + +class OidcAuthorizationCode(_OidcBase): + """Implementation for OpenID Connect Authorization Code""" + + @_utils.positional(4) + def __init__(self, auth_url, identity_provider, protocol, + client_id, client_secret, access_token_endpoint, + grant_type='authorization_code', + access_token_type='access_token', + redirect_uri=None, code=None): + """The OpenID Authorization Code plugin expects the following: + :param redirect_uri: OpenID Connect Client Redirect URL + :type redirect_uri: string + + :param code: OAuth 2.0 Authorization Code + :type code: string + + """ + super(OidcAuthorizationCode, 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, + grant_type=grant_type, + access_token_type=access_token_type) + self.redirect_uri = redirect_uri + self.code = code + + 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, an authorization code and redirect URL must be given. + + 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: keystoneauth1.session.Session + + :returns: a token data representation + :rtype: :py:class:`keystoneauth1.access.AccessInfoV3` + """ + + # get an access token + client_auth = (self.client_id, self.client_secret) + payload = {'grant_type': self.grant_type, + 'redirect_uri': self.redirect_uri, + 'code': self.code} + response = self._get_access_token(session, client_auth, payload, + self.access_token_endpoint) + access_token = response.json()[self.access_token_type] + + # 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 + return access.create(resp=response) diff --git a/keystoneauth1/loading/_plugins/identity/v3.py b/keystoneauth1/loading/_plugins/identity/v3.py index dea872ce..7c70a690 100644 --- a/keystoneauth1/loading/_plugins/identity/v3.py +++ b/keystoneauth1/loading/_plugins/identity/v3.py @@ -113,3 +113,61 @@ class FederatedBase(BaseV3Loader): ]) return options + + +class _OpenIDConnectBase(FederatedBase): + + def get_options(self): + options = super(_OpenIDConnectBase, self).get_options() + + options.extend([ + loading.Opt('client-id', help='OAuth 2.0 Client ID'), + loading.Opt('client-secret', help='OAuth 2.0 Client Secret'), + loading.Opt('access-token-endpoint', + help='OpenID Connect Provider Token Endpoint'), + loading.Opt('access-token-type', + help='OAuth 2.0 Authorization Server Introspection ' + 'token type, it is used to decide which type ' + 'of token will be used when processing token ' + 'introspection. Valid values are: ' + '"access_token" or "id_token"'), + ]) + + return options + + +class OpenIDConnectPassword(_OpenIDConnectBase): + + @property + def plugin_class(self): + return identity.V3OidcPassword + + def get_options(self): + options = super(OpenIDConnectPassword, self).get_options() + + options.extend([ + loading.Opt('username', help='Username'), + loading.Opt('password', help='Password'), + loading.Opt('openid-scope', default="profile", + help='OpenID Connect scope that is requested from OP') + ]) + + return options + + +class OpenIDConnectAuthorizationCode(_OpenIDConnectBase): + + @property + def plugin_class(self): + return identity.V3OidcAuthorizationCode + + def get_options(self): + options = super(OpenIDConnectAuthorizationCode, self).get_options() + + options.extend([ + loading.Opt('redirect-uri', help='OpenID Connect Redirect URL'), + loading.Opt('authorization-code', + help='OAuth 2.0 Authorization Code'), + ]) + + return options diff --git a/keystoneauth1/tests/unit/identity/test_identity_v3_oidc.py b/keystoneauth1/tests/unit/identity/test_identity_v3_oidc.py new file mode 100644 index 00000000..cab7fbf2 --- /dev/null +++ b/keystoneauth1/tests/unit/identity/test_identity_v3_oidc.py @@ -0,0 +1,159 @@ +# 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 six.moves import urllib + +from keystoneauth1.identity.v3 import oidc +from keystoneauth1 import session +from keystoneauth1.tests.unit import oidc_fixtures +from keystoneauth1.tests.unit import utils + + +KEYSTONE_TOKEN_VALUE = uuid.uuid4().hex + + +class AuthenticateOIDCTests(utils.TestCase): + + def setUp(self): + super(AuthenticateOIDCTests, self).setUp() + self.session = session.Session() + + self.AUTH_URL = 'http://keystone:5000/v3' + 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.AUTH_URL, + 'OS-FEDERATION/identity_providers/bluepages/protocols/oidc/auth') + self.REDIRECT_URL = 'urn:ietf:wg:oauth:2.0:oob' + self.CODE = '4/M9TNz2G9WVwYxSjx0w9AgA1bOmryJltQvOhQMq0czJs.cnLNVAfqwG' + + self.oidc_password = oidc.OidcPassword( + 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, + username=self.USER_NAME, + password=self.PASSWORD) + + self.oidc_grant = oidc.OidcAuthorizationCode( + 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, + redirect_uri=self.REDIRECT_URL, + code=self.CODE) + + +class OIDCPasswordTests(AuthenticateOIDCTests): + + 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 + 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.oidc_password._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=oidc_fixtures.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.oidc_password._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=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.oidc_password.get_unscoped_auth_ref(self.session) + self.assertEqual(KEYSTONE_TOKEN_VALUE, response.auth_token) + + +class OIDCAuthorizationGrantTests(AuthenticateOIDCTests): + + 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_AUTH_GRANT_RESP) + + # Prep all the values and send the request + grant_type = 'authorization_code' + client_auth = (self.CLIENT_ID, self.CLIENT_SECRET) + payload = {'grant_type': grant_type, + 'redirect_uri': self.REDIRECT_URL, + 'code': self.CODE} + res = self.oidc_grant._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) diff --git a/keystoneauth1/tests/unit/oidc_fixtures.py b/keystoneauth1/tests/unit/oidc_fixtures.py new file mode 100644 index 00000000..aefb990f --- /dev/null +++ b/keystoneauth1/tests/unit/oidc_fixtures.py @@ -0,0 +1,51 @@ +# 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. + +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" + } + } +} + +ACCESS_TOKEN_VIA_PASSWORD_RESP = { + "access_token": "z5H1ITZLlJVDHQXqJun", + "token_type": "bearer", + "expires_in": 3599, + "scope": "profile", + "refresh_token": "DCERsh83IAhu9bhavrp" +} + +ACCESS_TOKEN_VIA_AUTH_GRANT_RESP = { + "access_token": "ya29.jgGIjfVrBPWLStWSU3eh8ioE6hG06QQ", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "1/ySXNO9XISBMIgOrJDtdun6zK6XiATCKT", + "id_token": "eyJhbGciOiJSUzI1Ni8hOYHuZT8dt_yynmJVhcU" +} diff --git a/setup.cfg b/setup.cfg index 7b130bdd..cf437b1f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -33,6 +33,8 @@ keystoneauth1.plugin = v2token = keystoneauth1.loading._plugins.identity.v2:Token v3password = keystoneauth1.loading._plugins.identity.v3:Password v3token = keystoneauth1.loading._plugins.identity.v3:Token + v3oidcpassword = keystoneauth1.loading._plugins.identity.v3:OpenIDConnectPassword + v3oidcauthcode = keystoneauth1.loading._plugins.identity.v3:OpenIDConnectAuthorizationCode [build_sphinx] source-dir = doc/source