diff --git a/keystoneauth1/exceptions/__init__.py b/keystoneauth1/exceptions/__init__.py index 021b99fc..e4449261 100644 --- a/keystoneauth1/exceptions/__init__.py +++ b/keystoneauth1/exceptions/__init__.py @@ -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 diff --git a/keystoneauth1/exceptions/oidc.py b/keystoneauth1/exceptions/oidc.py new file mode 100644 index 00000000..b7f282ae --- /dev/null +++ b/keystoneauth1/exceptions/oidc.py @@ -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." diff --git a/keystoneauth1/identity/v3/oidc.py b/keystoneauth1/identity/v3/oidc.py index 2d839bb0..e58c4be7 100644 --- a/keystoneauth1/identity/v3/oidc.py +++ b/keystoneauth1/identity/v3/oidc.py @@ -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, diff --git a/keystoneauth1/loading/_plugins/identity/v3.py b/keystoneauth1/loading/_plugins/identity/v3.py index 7b64c385..fb36a708 100644 --- a/keystoneauth1/loading/_plugins/identity/v3.py +++ b/keystoneauth1/loading/_plugins/identity/v3.py @@ -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 ' diff --git a/keystoneauth1/tests/unit/identity/test_identity_v3_oidc.py b/keystoneauth1/tests/unit/identity/test_identity_v3_oidc.py index 5b0276ed..3afaa094 100644 --- a/keystoneauth1/tests/unit/identity/test_identity_v3_oidc.py +++ b/keystoneauth1/tests/unit/identity/test_identity_v3_oidc.py @@ -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() diff --git a/keystoneauth1/tests/unit/loading/test_v3.py b/keystoneauth1/tests/unit/loading/test_v3.py index 07c0d5c9..090e38f5 100644 --- a/keystoneauth1/tests/unit/loading/test_v3.py +++ b/keystoneauth1/tests/unit/loading/test_v3.py @@ -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" diff --git a/keystoneauth1/tests/unit/oidc_fixtures.py b/keystoneauth1/tests/unit/oidc_fixtures.py index 63d94eb3..df81254c 100644 --- a/keystoneauth1/tests/unit/oidc_fixtures.py +++ b/keystoneauth1/tests/unit/oidc_fixtures.py @@ -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" + ], +} diff --git a/releasenotes/notes/add-oidc-discovery-document-support-b07fe54f83286d62.yaml b/releasenotes/notes/add-oidc-discovery-document-support-b07fe54f83286d62.yaml new file mode 100644 index 00000000..80b4bc7e --- /dev/null +++ b/releasenotes/notes/add-oidc-discovery-document-support-b07fe54f83286d62.yaml @@ -0,0 +1,12 @@ +--- +features: + - > + Add support for the `OpenID Connect Discovery Document + `_ + 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 `_] + OpenID Connect plugins should support OpenID Connect Discovery.