oidc: add discovery document support

The OpenID Connect specifies that all providers must return a JSON
discovery document [1] in a well-known location. We can let the user
pass this document instead of the individual endpoints (i.e. token and
authorization endpoint). Moreover, we can also check if the requested
grant_type (implicit to the used plugin, and one of client_credentials,
password, authorization_code) is supported by the provider before
starting the auth flow.

[1] https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata

Fixes-bug: #1583682
Change-Id: I24b7960b25ddcff45552c0ab5541d92122d1d560
This commit is contained in:
Alvaro Lopez Garcia 2016-06-16 10:33:52 +02:00
parent 88d4fdb14a
commit 00746ea636
8 changed files with 357 additions and 18 deletions

View File

@ -18,5 +18,6 @@ from keystoneauth1.exceptions.catalog import * # noqa
from keystoneauth1.exceptions.connection import * # noqa
from keystoneauth1.exceptions.discovery import * # noqa
from keystoneauth1.exceptions.http import * # noqa
from keystoneauth1.exceptions.oidc import * # noqa
from keystoneauth1.exceptions.response import * # noqa
from keystoneauth1.exceptions.service_providers import * # noqa

View File

@ -0,0 +1,40 @@
# 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.exceptions import auth_plugins
__all__ = (
'InvalidDiscoveryEndpoint', 'InvalidOidcDiscoveryDocument',
'OidcAccessTokenEndpointNotFound', 'OidcAuthorizationEndpointNotFound',
'OidcPluginNotSupported',
)
class InvalidDiscoveryEndpoint(auth_plugins.AuthPluginException):
message = "OpenID Connect Discovery Document endpoint not set."""
class InvalidOidcDiscoveryDocument(auth_plugins.AuthPluginException):
message = "OpenID Connect Discovery Document is not valid JSON."""
class OidcAccessTokenEndpointNotFound(auth_plugins.AuthPluginException):
message = "OpenID Connect access token endpoint not provided."
class OidcAuthorizationEndpointNotFound(auth_plugins.AuthPluginException):
message = "OpenID Connect authorization endpoint not provided."
class OidcPluginNotSupported(auth_plugins.AuthPluginException):
message = "OpenID Connect grant type not supported by provider."

View File

@ -12,9 +12,13 @@
from positional import positional
from keystoneauth1 import _utils as utils
from keystoneauth1 import access
from keystoneauth1 import exceptions
from keystoneauth1.identity.v3 import federation
_logger = utils.get_logger(__name__)
__all__ = ('OidcAuthorizationCode',
'OidcPassword',
'OidcAccessToken')
@ -28,9 +32,11 @@ class _OidcBase(federation.FederationBaseAuth):
"""
def __init__(self, auth_url, identity_provider, protocol,
client_id, client_secret, access_token_endpoint,
grant_type, access_token_type,
scope="openid profile", **kwargs):
client_id, client_secret, grant_type,
access_token_type,
scope="openid profile",
access_token_endpoint=None,
discovery_endpoint=None, **kwargs):
"""The OpenID Connect plugin expects the following.
:param auth_url: URL of the Identity Service
@ -49,15 +55,12 @@ class _OidcBase(federation.FederationBaseAuth):
: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".
"password". If a discovery document is being used,
this class will check if the provided value is
supported by the provider.
:type grant_type: string
:param access_token_type: OAuth 2.0 Authorization Server Introspection
@ -67,6 +70,19 @@ class _OidcBase(federation.FederationBaseAuth):
"access_token" or "id_token"
:type access_token_type: string
:param access_token_endpoint: OpenID Connect Provider Token Endpoint,
for example:
https://localhost:8020/oidc/OP/token
Note that if a discovery document is
provided this value will override
the discovered one.
:type access_token_endpoint: string
:param discovery_endpoint: OpenID Connect Discovery Document URL,
for example:
https://localhost:8020/oidc/.well-known/openid-configuration
:type access_token_endpoint: string
:param scope: OpenID Connect scope that is requested from OP,
for example: "openid profile email", defaults to
"openid profile". Note that OpenID Connect specification
@ -77,11 +93,94 @@ class _OidcBase(federation.FederationBaseAuth):
**kwargs)
self.client_id = client_id
self.client_secret = client_secret
self.discovery_endpoint = discovery_endpoint
self._discovery_document = {}
self.access_token_endpoint = access_token_endpoint
self.grant_type = grant_type
self.access_token_type = access_token_type
self.scope = scope
self.grant_type = grant_type
def _get_discovery_document(self, session):
"""Get the contents of the OpenID Connect Discovery Document.
This method grabs the contents of the OpenID Connect Discovery Document
if a discovery_endpoint was passed to the constructor and returns it as
a dict, otherwise returns an empty dict. Note that it will fetch the
discovery document only once, so subsequent calls to this method will
return the cached result, if any.
:param session: a session object to send out HTTP requests.
:type session: keystoneauth1.session.Session
:returns: a python dictionary containing the discovery document if any,
otherwise it will return an empty dict.
:rtype: dict
"""
if (self.discovery_endpoint is not None and
not self._discovery_document):
try:
resp = session.get(self.discovery_endpoint,
authenticated=False)
except exceptions.HttpError:
_logger.error("Cannot fetch discovery document %(discovery)s" %
{"discovery": self.discovery_endpoint})
raise
try:
self._discovery_document = resp.json()
except Exception:
pass
if not self._discovery_document:
raise exceptions.InvalidOidcDiscoveryDocument()
return self._discovery_document
def _check_grant_type(self, session):
"""Check if the grant_type requested is supported by the server.
If a discovery_endpoint is provided and the discoverty document
advertises the supported grant types, this method will check if the
requested grant_type is supported by the server, raising an exception
otherwise.
:param session: a session object to send out HTTP requests.
:type session: keystoneauth1.session.Session
"""
discovery = self._get_discovery_document(session)
grant_types = discovery.get("grant_types_supported")
if (grant_types and
self.grant_type is not None and
self.grant_type not in grant_types):
raise exceptions.OidcPluginNotSupported()
def _get_access_token_endpoint(self, session):
"""Get the "token_endpoint" for the OpenID Connect flow.
This method will return the correct access token endpoint to be used.
If the user has explicitly passed an access_token_endpoint to the
constructor that will be returned. If there is no explicit endpoint and
a discovery url is provided, it will try to get it from the discovery
document. If nothing is found, an exception will be raised.
:param session: a session object to send out HTTP requests.
:type session: keystoneauth1.session.Session
:return: the endpoint to use
:rtype: string or None if no endpoint is found
"""
if self.access_token_endpoint is not None:
return self.access_token_endpoint
discovery = self._get_discovery_document(session)
endpoint = discovery.get("token_endpoint")
if endpoint is None:
raise exceptions.OidcAccessTokenEndpointNotFound()
return endpoint
def _get_access_token(self, session, payload):
"""Exchange a variety of user supplied values for an access token.
@ -95,7 +194,9 @@ class _OidcBase(federation.FederationBaseAuth):
:type payload: dict
"""
client_auth = (self.client_id, self.client_secret)
op_response = session.post(self.access_token_endpoint,
access_token_endpoint = self._get_access_token_endpoint(session)
op_response = session.post(access_token_endpoint,
requests_auth=client_auth,
data=payload,
authenticated=False)
@ -132,7 +233,9 @@ class OidcPassword(_OidcBase):
@positional(4)
def __init__(self, auth_url, identity_provider, protocol,
client_id, client_secret, access_token_endpoint,
client_id, client_secret,
access_token_endpoint=None,
discovery_endpoint=None,
grant_type='password', access_token_type='access_token',
username=None, password=None, **kwargs):
"""The OpenID Password plugin expects the following.
@ -150,6 +253,7 @@ class OidcPassword(_OidcBase):
client_id=client_id,
client_secret=client_secret,
access_token_endpoint=access_token_endpoint,
discovery_endpoint=discovery_endpoint,
grant_type=grant_type,
access_token_type=access_token_type,
**kwargs)
@ -176,6 +280,9 @@ class OidcPassword(_OidcBase):
:returns: a token data representation
:rtype: :py:class:`keystoneauth1.access.AccessInfoV3`
"""
# First of all, check if the grant type is supported
self._check_grant_type(session)
# get an access token
payload = {'grant_type': self.grant_type, 'username': self.username,
'password': self.password, 'scope': self.scope}
@ -192,7 +299,9 @@ class OidcAuthorizationCode(_OidcBase):
@positional(4)
def __init__(self, auth_url, identity_provider, protocol,
client_id, client_secret, access_token_endpoint,
client_id, client_secret,
access_token_endpoint=None,
discovery_endpoint=None,
grant_type='authorization_code',
access_token_type='access_token',
redirect_uri=None, code=None, **kwargs):
@ -212,6 +321,7 @@ class OidcAuthorizationCode(_OidcBase):
client_id=client_id,
client_secret=client_secret,
access_token_endpoint=access_token_endpoint,
discovery_endpoint=discovery_endpoint,
grant_type=grant_type,
access_token_type=access_token_type,
**kwargs)
@ -237,6 +347,9 @@ class OidcAuthorizationCode(_OidcBase):
:returns: a token data representation
:rtype: :py:class:`keystoneauth1.access.AccessInfoV3`
"""
# First of all, check if the grant type is supported
self._check_grant_type(session)
# get an access token
payload = {'grant_type': self.grant_type,
'redirect_uri': self.redirect_uri,

View File

@ -78,6 +78,15 @@ class Token(loading.BaseV3Loader):
class _OpenIDConnectBase(loading.BaseFederationLoader):
def load_from_options(self, **kwargs):
if not (kwargs.get('access_token_endpoint') or
kwargs.get('discovery_endpoint')):
m = ("You have to specify either an 'access-token-endpoint' or "
"a 'discovery-endpoint'.")
raise exceptions.OptionError(m)
return super(_OpenIDConnectBase, self).load_from_options(**kwargs)
def get_options(self):
options = super(_OpenIDConnectBase, self).get_options()
@ -92,7 +101,17 @@ class _OpenIDConnectBase(loading.BaseFederationLoader):
'Connect specification states that "openid" '
'must be always specified.'),
loading.Opt('access-token-endpoint',
help='OpenID Connect Provider Token Endpoint'),
help='OpenID Connect Provider Token Endpoint. Note '
'that if a discovery document is being passed this '
'option will override the endpoint provided by the '
'server in the discovery document.'),
loading.Opt('discovery-endpoint',
help='OpenID Connect Discovery Document URL. '
'The discovery document will be used to obtain the '
'values of the access token endpoint and the '
'authentication endpoint. This URL should look like '
'https://idp.example.org/.well-known/'
'openid-configuration'),
loading.Opt('access-token-type',
help='OAuth 2.0 Authorization Server Introspection '
'token type, it is used to decide which type '

View File

@ -14,6 +14,7 @@ import uuid
from six.moves import urllib
from keystoneauth1 import exceptions
from keystoneauth1.identity.v3 import oidc
from keystoneauth1 import session
from keystoneauth1.tests.unit import oidc_fixtures
@ -23,7 +24,7 @@ from keystoneauth1.tests.unit import utils
KEYSTONE_TOKEN_VALUE = uuid.uuid4().hex
class BaseOIDCTests(utils.TestCase):
class BaseOIDCTests(object):
def setUp(self):
super(BaseOIDCTests, self).setUp()
@ -45,8 +46,113 @@ class BaseOIDCTests(utils.TestCase):
self.REDIRECT_URL = 'urn:ietf:wg:oauth:2.0:oob'
self.CODE = '4/M9TNz2G9WVwYxSjx0w9AgA1bOmryJltQvOhQMq0czJs.cnLNVAfqwG'
self.DISCOVERY_URL = ('https://localhost:8020/oidc/.well-known/'
'openid-configuration')
class OIDCPasswordTests(BaseOIDCTests):
def test_discovery_not_found(self):
self.requests_mock.get("http://not.found",
status_code=404)
plugin = self.plugin.__class__(
self.AUTH_URL,
self.IDENTITY_PROVIDER,
self.PROTOCOL,
client_id=self.CLIENT_ID,
client_secret=self.CLIENT_SECRET,
discovery_endpoint="http://not.found")
self.assertRaises(exceptions.http.NotFound,
plugin._get_discovery_document,
self.session)
def test_no_discovery(self):
plugin = self.plugin.__class__(
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,
)
self.assertEqual(self.ACCESS_TOKEN_ENDPOINT,
plugin.access_token_endpoint)
def test_load_discovery(self):
self.requests_mock.get(self.DISCOVERY_URL,
json=oidc_fixtures.DISCOVERY_DOCUMENT)
plugin = self.plugin.__class__(self.AUTH_URL,
self.IDENTITY_PROVIDER,
self.PROTOCOL,
client_id=self.CLIENT_ID,
client_secret=self.CLIENT_SECRET,
discovery_endpoint=self.DISCOVERY_URL)
self.assertEqual(
oidc_fixtures.DISCOVERY_DOCUMENT["token_endpoint"],
plugin._get_access_token_endpoint(self.session)
)
def test_no_access_token_endpoint(self):
plugin = self.plugin.__class__(self.AUTH_URL,
self.IDENTITY_PROVIDER,
self.PROTOCOL,
client_id=self.CLIENT_ID,
client_secret=self.CLIENT_SECRET)
self.assertRaises(exceptions.OidcAccessTokenEndpointNotFound,
plugin._get_access_token_endpoint,
self.session)
def test_invalid_discovery_document(self):
self.requests_mock.get(self.DISCOVERY_URL,
json={})
plugin = self.plugin.__class__(self.AUTH_URL,
self.IDENTITY_PROVIDER,
self.PROTOCOL,
client_id=self.CLIENT_ID,
client_secret=self.CLIENT_SECRET,
discovery_endpoint=self.DISCOVERY_URL)
self.assertRaises(exceptions.InvalidOidcDiscoveryDocument,
plugin._get_discovery_document,
self.session)
def test_load_discovery_override_by_endpoints(self):
self.requests_mock.get(self.DISCOVERY_URL,
json=oidc_fixtures.DISCOVERY_DOCUMENT)
access_token_endpoint = uuid.uuid4().hex
plugin = self.plugin.__class__(
self.AUTH_URL,
self.IDENTITY_PROVIDER,
self.PROTOCOL,
client_id=self.CLIENT_ID,
client_secret=self.CLIENT_SECRET,
discovery_endpoint=self.DISCOVERY_URL,
access_token_endpoint=access_token_endpoint
)
self.assertEqual(access_token_endpoint,
plugin._get_access_token_endpoint(self.session))
def test_wrong_grant_type(self):
self.requests_mock.get(self.DISCOVERY_URL,
json={"grant_types_supported": ["foo", "bar"]})
plugin = self.plugin.__class__(self.AUTH_URL,
self.IDENTITY_PROVIDER,
self.PROTOCOL,
client_id=self.CLIENT_ID,
client_secret=self.CLIENT_SECRET,
discovery_endpoint=self.DISCOVERY_URL)
self.assertRaises(exceptions.OidcPluginNotSupported,
plugin._check_grant_type,
self.session)
class OIDCPasswordTests(BaseOIDCTests, utils.TestCase):
def setUp(self):
super(OIDCPasswordTests, self).setUp()
@ -118,7 +224,7 @@ class OIDCPasswordTests(BaseOIDCTests):
self.assertEqual(KEYSTONE_TOKEN_VALUE, response.auth_token)
class OIDCAuthorizationGrantTests(BaseOIDCTests):
class OIDCAuthorizationGrantTests(BaseOIDCTests, utils.TestCase):
def setUp(self):
super(OIDCAuthorizationGrantTests, self).setUp()

View File

@ -135,7 +135,8 @@ class OpenIDConnectBaseTests(object):
options = loading.get_plugin_loader(self.plugin_name).get_options()
self.assertTrue(
set(['client-id', 'client-secret', 'access-token-endpoint',
'access-token-type', 'openid-scope']).issubset(
'access-token-type', 'openid-scope',
'discovery-endpoint']).issubset(
set([o.name for o in options]))
)
# openid-scope gets renamed into "scope"

View File

@ -49,3 +49,50 @@ ACCESS_TOKEN_VIA_AUTH_GRANT_RESP = {
"refresh_token": "1/ySXNO9XISBMIgOrJDtdun6zK6XiATCKT",
"id_token": "eyJhbGciOiJSUzI1Ni8hOYHuZT8dt_yynmJVhcU"
}
DISCOVERY_DOCUMENT = {
"authorization_endpoint": "https://localhost:8020/oidc/authorize",
"claims_supported": [
"sub",
"name",
"preferred_username",
"given_name",
"family_name",
"middle_name",
"nickname",
"profile",
"picture",
"website",
"gender",
"zoneinfo",
"locale",
"updated_at",
"birthdate",
"email",
"email_verified",
"phone_number",
"phone_number_verified",
"address"
],
"grant_types_supported": [
"authorization_code",
"password",
],
"introspection_endpoint": "https://localhost:8020/oidc/introspect",
"issuer": "https://localhost:8020/oidc/",
"jwks_uri": "https://localhost:8020/oidc/jwk",
"op_policy_uri": "https://localhost:8020/oidc/about",
"op_tos_uri": "https://localhost:8020/oidc/about",
"registration_endpoint": "https://localhost:8020/oidc/register",
"revocation_endpoint": "https://localhost:8020/oidc/revoke",
"service_documentation": "https://localhost:8020/oidc/about",
"token_endpoint": "https://localhost:8020/oidc/token",
"userinfo_endpoint": "https://localhost:8020/oidc/userinfo",
"token_endpoint_auth_methods_supported": [
"client_secret_post",
"client_secret_basic",
"client_secret_jwt",
"private_key_jwt",
"none"
],
}

View File

@ -0,0 +1,12 @@
---
features:
- >
Add support for the `OpenID Connect Discovery Document
<https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata>`_
into the OpenID Connect related plugins. Now it is possible to only pass the
`discovery-url` option and the plugins will try to fetch the required
metadata from there.
fixes:
- >
[`bug 1583682 <https://bugs.launchpad.net/keystoneauth/+bug/1583682>`_]
OpenID Connect plugins should support OpenID Connect Discovery.