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
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
|
v2token = keystoneclient.auth.identity.v2:Token
|
||||||
v3password = keystoneclient.auth.identity.v3:Password
|
v3password = keystoneclient.auth.identity.v3:Password
|
||||||
v3token = keystoneclient.auth.identity.v3:Token
|
v3token = keystoneclient.auth.identity.v3:Token
|
||||||
|
v3oidcpassword = keystoneclient.contrib.auth.v3.oidc:OidcPassword
|
||||||
v3unscopedsaml = keystoneclient.contrib.auth.v3.saml2:Saml2UnscopedToken
|
v3unscopedsaml = keystoneclient.contrib.auth.v3.saml2:Saml2UnscopedToken
|
||||||
v3scopedsaml = keystoneclient.contrib.auth.v3.saml2:Saml2ScopedToken
|
v3scopedsaml = keystoneclient.contrib.auth.v3.saml2:Saml2ScopedToken
|
||||||
v3unscopedadfs = keystoneclient.contrib.auth.v3.saml2:ADFSUnscopedToken
|
v3unscopedadfs = keystoneclient.contrib.auth.v3.saml2:ADFSUnscopedToken
|
||||||
|
Reference in New Issue
Block a user