diff --git a/doc/source/discussion/components.rst b/doc/source/discussion/components.rst index 3c53716f6b..c2ed7e4c49 100644 --- a/doc/source/discussion/components.rst +++ b/doc/source/discussion/components.rst @@ -977,8 +977,9 @@ protected endpoints and configure JWT validation: .. attr:: driver - The signing algorithm to use. Accepted values are ``HS256``, ``RS256`` or - ``RS256withJWKS``. See below for driver-specific configuration options. + The signing algorithm to use. Accepted values are ``HS256``, ``RS256``, + ``RS256withJWKS`` or ``OpenIDConnect``. See below for driver-specific + configuration options. .. attr:: allow_authz_override :default: false @@ -1064,6 +1065,10 @@ the public key is needed by Zuul for signature validation. RS256withJWKS ,,,,,,,,,,,,, +.. warning:: + + This driver is deprecated, use ``OpenIDConnect`` instead. + Some Identity Providers use key sets (also known as **JWKS**), therefore the key to use when verifying the Authentication Token's signatures cannot be known in advance; the key's id is stored in the JWT's header and the key must then be @@ -1076,6 +1081,31 @@ The key set is usually available at a specific URL that can be found in the The URL where the Identity Provider's key set can be found. For example, for Google's OAuth service: https://www.googleapis.com/oauth2/v3/certs +OpenIDConnect +,,,,,,,,,,,,, + +Use a third-party Identity Provider implementing the OpenID Connect protocol. +The issuer ID should be an URI, from which the "well-known" configuration URI +of the Identity Provider can be inferred. This is intended to be used for +authentication on Zuul's web user interface. + +.. attr:: scope + :default: openid profile + + The scope(s) to use when requesting access to a user's details. This attribute + can be multivalued (values must be separated by a space). Most OpenID Connect + Identity Providers support the default scopes "openid profile". A full list + of supported scopes can be found in the well-known configuration of the + Identity Provider under the key "scopes_supported". + +.. attr:: keys_url + + Optional. The URL where the Identity Provider's key set can be found. + For example, for Google's OAuth service: https://www.googleapis.com/oauth2/v3/certs + The well-known configuration of the Identity Provider should provide this URL + under the key "jwks_uri", therefore this attribute is usually not necessary. + + Operation ~~~~~~~~~ diff --git a/releasenotes/notes/deprecate-RS256withJWKS-0303fd688958dff9.yaml b/releasenotes/notes/deprecate-RS256withJWKS-0303fd688958dff9.yaml new file mode 100644 index 0000000000..9c6fbdb836 --- /dev/null +++ b/releasenotes/notes/deprecate-RS256withJWKS-0303fd688958dff9.yaml @@ -0,0 +1,7 @@ +--- +deprecations: + - | + Authentication: the JWT driver "RS256withJWKS" is deprecated in favor of the + "OpenIDConnect" driver. The "OpenIDConnect" driver simplifies configuration + for administrators and is better aligned with OIDC configuration discovery + conventions. diff --git a/tests/fixtures/auth/oidc-key b/tests/fixtures/auth/oidc-key new file mode 100644 index 0000000000..7d9aa5038d --- /dev/null +++ b/tests/fixtures/auth/oidc-key @@ -0,0 +1,51 @@ +-----BEGIN RSA PRIVATE KEY----- +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 +-----END RSA PRIVATE KEY----- diff --git a/tests/fixtures/auth/oidc-key.pub b/tests/fixtures/auth/oidc-key.pub new file mode 100644 index 0000000000..4ca9aad36d --- /dev/null +++ b/tests/fixtures/auth/oidc-key.pub @@ -0,0 +1,14 @@ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAsaUXr+7qhvCcHniUgZWz +RHtMqeYMT6obdd7zh6ZKLPVb6G13Sn8aVGM0C+sei6tcCTSBkbb0vjPa/z3pWSF7 +Ozvt68fjUDElJImb7RUC7g8gTU/pHCqW7UKiSjTpggUbgU2Driz45/890CAguG5m +efQ918ITy3MPBR9/yDgPsy7b+Opml6fuodSvm6FCv90CZW+Y3o/bSLvTIUxbVhmj +rDLYk4TpeaGPGV3CxopPEE3KQXoFCE8V7OCK+KB82gUqDD5e3Gk2cIwNoh90nCqc +XCnR7+lGZJxK9D+00xeKBFvhIgD0wlt86T47sr1hwWTT8Ds5lKsE1JhGYs01/9Ia +6QIqpW9sEUa61OMiaROM9IhI5PGUSeOA+9NZokEWfMkYeV1W2ziuKerCpRGjUgHF +e9aDh3T2ssQIznaIvxMgWg10XuVl8bxY4pusPizpMuxOb4R+7KVkSBPd7HnuXOs2 +xatOABoNms7plHbrEVsnpX6hfta3z47H+MkXAuo10/8SKl0MYhQAMo/bYqYaZUY8 +4+tgxN6twZzTj3vf5zDD1N7oYCt+pneRmywQmDJvXXG9uwF6E5BqMxHwNGdB980g +6oJWUqJ1HdQG7ML2r/DHEe06DeIQ2bN87ihiQifgZ1U+oyp0a2zkAOWCDEx4RQqw +c/EE7rmq5NpcSvpbVaXLOeMCAwEAAQ== +-----END PUBLIC KEY----- diff --git a/tests/fixtures/auth/openid-configuration.json b/tests/fixtures/auth/openid-configuration.json new file mode 100644 index 0000000000..514e5bc9d6 --- /dev/null +++ b/tests/fixtures/auth/openid-configuration.json @@ -0,0 +1,130 @@ +{ + "issuer": "https://my.oidc.provider/auth/realms/realm-one", + "authorization_endpoint": "https://my.oidc.provider/auth/realms/realm-one/protocol/openid-connect/auth", + "token_endpoint": "https://my.oidc.provider/auth/realms/realm-one/protocol/openid-connect/token", + "token_introspection_endpoint": "https://my.oidc.provider/auth/realms/realm-one/protocol/openid-connect/token/introspect", + "userinfo_endpoint": "https://my.oidc.provider/auth/realms/realm-one/protocol/openid-connect/userinfo", + "end_session_endpoint": "https://my.oidc.provider/auth/realms/realm-one/protocol/openid-connect/logout", + "jwks_uri": "https://my.oidc.provider/auth/realms/realm-one/protocol/openid-connect/certs", + "check_session_iframe": "https://my.oidc.provider/auth/realms/realm-one/protocol/openid-connect/login-status-iframe.html", + "grant_types_supported": [ + "authorization_code", + "implicit", + "refresh_token", + "password", + "client_credentials" + ], + "response_types_supported": [ + "code", + "none", + "id_token", + "token", + "id_token token", + "code id_token", + "code token", + "code id_token token" + ], + "subject_types_supported": [ + "public", + "pairwise" + ], + "id_token_signing_alg_values_supported": [ + "PS384", + "ES384", + "RS384", + "HS256", + "HS512", + "ES256", + "RS256", + "HS384", + "ES512", + "PS256", + "PS512", + "RS512" + ], + "id_token_encryption_alg_values_supported": [ + "RSA-OAEP", + "RSA1_5" + ], + "id_token_encryption_enc_values_supported": [ + "A128GCM", + "A128CBC-HS256" + ], + "userinfo_signing_alg_values_supported": [ + "PS384", + "ES384", + "RS384", + "HS256", + "HS512", + "ES256", + "RS256", + "HS384", + "ES512", + "PS256", + "PS512", + "RS512", + "none" + ], + "request_object_signing_alg_values_supported": [ + "PS384", + "ES384", + "RS384", + "ES256", + "RS256", + "ES512", + "PS256", + "PS512", + "RS512", + "none" + ], + "response_modes_supported": [ + "query", + "fragment", + "form_post" + ], + "registration_endpoint": "https://my.oidc.provider/auth/realms/realm-one/clients-registrations/openid-connect", + "token_endpoint_auth_methods_supported": [ + "private_key_jwt", + "client_secret_basic", + "client_secret_post", + "client_secret_jwt" + ], + "token_endpoint_auth_signing_alg_values_supported": [ + "RS256" + ], + "claims_supported": [ + "aud", + "sub", + "iss", + "auth_time", + "name", + "given_name", + "family_name", + "preferred_username", + "email" + ], + "claim_types_supported": [ + "normal" + ], + "claims_parameter_supported": false, + "scopes_supported": [ + "openid", + "web-origins", + "email", + "profile", + "microprofile-jwt", + "address", + "zuul_audience", + "offline_access", + "phone", + "roles" + ], + "request_parameter_supported": true, + "request_uri_parameter_supported": true, + "code_challenge_methods_supported": [ + "plain", + "S256" + ], + "tls_client_certificate_bound_access_tokens": true, + "introspection_endpoint": "https://my.oidc.provider/auth/realms/realm-one/protocol/openid-connect/token/introspect" +} diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py new file mode 100644 index 0000000000..768db8d734 --- /dev/null +++ b/tests/unit/test_auth.py @@ -0,0 +1,97 @@ +# Copyright 2020 OpenStack Foundation +# Copyright 2020 Red Hat, Inc. +# +# 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 json +from unittest import mock +import os.path +import jwt +import time + +from zuul.driver import auth + +from tests.base import BaseTestCase, FIXTURE_DIR + +with open(os.path.join(FIXTURE_DIR, + 'auth/openid-configuration.json'), 'r') as well_known: + FAKE_WELL_KNOWN_CONFIG = json.loads(well_known.read()) + + +algo = jwt.algorithms.RSAAlgorithm(jwt.algorithms.RSAAlgorithm.SHA256) +with open(os.path.join(FIXTURE_DIR, + 'auth/oidc-key'), 'r') as k: + OIDC_PRIVATE_KEY = algo.prepare_key(k.read().encode('utf-8')) +with open(os.path.join(FIXTURE_DIR, + 'auth/oidc-key.pub'), 'r') as k: + pub_key = algo.prepare_key(k.read().encode('utf-8')) + pub_jwk = algo.to_jwk(pub_key) + key = { + "kid": "OwO", + "use": "sig", + "alg": "RS256" + } + key.update(json.loads(pub_jwk)) + # not present in keycloak jwks + if "key_ops" in key: + del key["key_ops"] + FAKE_CERTS = { + "keys": [ + key + ] + } + + +def mock_get(url, params=None, **kwargs): + if url == ("https://my.oidc.provider/auth/realms/realm-one/" + ".well-known/openid-configuration"): + return FakeResponse(FAKE_WELL_KNOWN_CONFIG) + elif url == ("https://my.oidc.provider/auth/realms/realm-one/" + "protocol/openid-connect/certs"): + return FakeResponse(FAKE_CERTS) + else: + raise Exception("Unknown URL %s" % url) + + +class FakeResponse: + def __init__(self, json_dict): + self._json = json_dict + + def json(self): + return self._json + + +class TestOpenIDConnectAuthenticator(BaseTestCase): + def test_decodeToken(self): + """Test the decoding workflow""" + config = { + 'issuer_id': FAKE_WELL_KNOWN_CONFIG['issuer'], + 'client_id': 'zuul-app', + 'realm': 'realm-one', + } + OIDCAuth = auth.jwt.OpenIDConnectAuthenticator(**config) + payload = { + 'iss': FAKE_WELL_KNOWN_CONFIG['issuer'], + 'aud': config['client_id'], + 'exp': time.time() + 3600, + 'sub': 'someone' + } + token = jwt.encode( + payload, + OIDC_PRIVATE_KEY, + algorithm='RS256', + headers={'kid': 'OwO'}) + with mock.patch('requests.get', side_effect=mock_get): + decoded = OIDCAuth.decodeToken(token) + for claim in payload.keys(): + self.assertEqual(payload[claim], decoded[claim]) diff --git a/zuul/driver/auth/jwt.py b/zuul/driver/auth/jwt.py index 3517ff3e5b..f8fb5af016 100644 --- a/zuul/driver/auth/jwt.py +++ b/zuul/driver/auth/jwt.py @@ -19,6 +19,7 @@ import time import jwt import requests import json +from urllib.parse import urljoin from zuul import exceptions from zuul.driver import AuthenticatorInterface @@ -164,19 +165,74 @@ class RS256Authenticator(JWTAuthenticator): algorithms=self.algorithm) -class RS256withJWKSAuthenticator(JWTAuthenticator): - """JWT authentication using the RS256 algorithm. +class OpenIDConnectAuthenticator(JWTAuthenticator): + """JWT authentication using an OpenIDConnect provider. - Requires the URL of the certificates used by the Identity Provier. It can - be found usually under the key "jwks_uri" at the provider's - .well-known/openid-configuration URL.""" + If the optional 'keys_url' parameter is not specified, the authenticator + will attempt to determine it via the well-known configuration URI as + described in + https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig""" # noqa + # default algorithm, TOFO: should this be a config param? algorithm = 'RS256' - name = 'RS256withJWKS' + name = 'OpenIDConnect' def __init__(self, **conf): - super(RS256withJWKSAuthenticator, self).__init__(**conf) + super(OpenIDConnectAuthenticator, self).__init__(**conf) self.keys_url = conf.get('keys_url', None) + self.scope = conf.get('scope', 'openid profile') + + def get_key(self, key_id): + keys_url = self.keys_url + if keys_url is None: + well_known = self.get_well_known_config() + keys_url = well_known.get('jwks_uri', None) + if keys_url is None: + msg = 'Invalid OpenID configuration: "jwks_uri" not found' + logger.error(msg) + raise exceptions.JWKSException( + realm=self.realm, + msg=msg) + # TODO keys can probably be cached + try: + certs = requests.get(keys_url).json() + except Exception as e: + msg = 'Could not fetch Identity Provider keys at %s: %s' + logger.error(msg % (keys_url, e)) + raise exceptions.JWKSException( + realm=self.realm, + msg='There was an error while fetching ' + 'keys for Identity Provider, check logs for details') + for key_dict in certs['keys']: + if key_dict.get('kid') == key_id: + # TODO: theoretically two other types of keys are + # supported by the JWKS standard. We should raise an error + # in the unlikely case 'kty' is not RSA. + # (see https://tools.ietf.org/html/rfc7518#section-6.1) + key = jwt.algorithms.RSAAlgorithm.from_jwk( + json.dumps(key_dict)) + algorithm = key_dict.get('alg', None) or self.algorithm + return key, algorithm + raise exceptions.JWKSException( + self.realm, + 'Cannot verify token: public key %s ' + 'not listed by Identity Provider' % key_id) + + def get_well_known_config(self): + issuer = self.issuer_id + if not issuer.endswith('/'): + issuer += '/' + well_known_uri = urljoin(issuer, + '.well-known/openid-configuration') + try: + return requests.get(well_known_uri).json() + except Exception as e: + msg = 'Could not fetch OpenID configuration at %s: %s' + logger.error(msg % (well_known_uri, e)) + raise exceptions.JWKSException( + realm=self.realm, + msg='There was an error while fetching ' + 'OpenID configuration, check logs for details') def _decode(self, rawToken): unverified_headers = jwt.get_unverified_header(rawToken) @@ -184,35 +240,22 @@ class RS256withJWKSAuthenticator(JWTAuthenticator): if key_id is None: raise exceptions.JWKSException( self.realm, 'No key ID in token header') - # TODO keys can probably be cached - try: - certs = requests.get(self.keys_url).json() - except Exception as e: - msg = 'Could not fetch Identity Provider keys at %s: %s' - logger.error(msg % (self.keys_url, e)) - raise exceptions.JWKSException( - realm=self.realm, - msg='There was an error while fetching ' - 'keys for Identity Provider') - for key_dict in certs['keys']: - if key_dict.get('kid') == key_id: - key = jwt.algorithms.RSAAlgorithm.from_jwk( - json.dumps(key_dict)) - return jwt.decode(rawToken, key, issuer=self.issuer_id, - audience=self.audience, - algorithms=self.algorithm) - raise exceptions.JWKSException( - self.realm, - 'Cannot verify token: public key %s ' - 'not listed by Identity Provider' % key_id) + key, algorithm = self.get_key(key_id) + return jwt.decode(rawToken, key, issuer=self.issuer_id, + audience=self.audience, + algorithms=algorithm) AUTHENTICATORS = { 'HS256': HS256Authenticator, 'RS256': RS256Authenticator, - 'RS256withJWKS': RS256withJWKSAuthenticator, + 'RS256withJWKS': OpenIDConnectAuthenticator, + 'OpenIDConnect': OpenIDConnectAuthenticator, } def get_authenticator_by_name(name): + if name == 'RS256withJWKS': + logger.info( + 'Driver "%s" is deprecated, please use "OpenIDConnect" instead') return AUTHENTICATORS[name]