JWT drivers: Deprecate RS256withJWKS, introduce OpenIDConnect

Replace the RS256withJWKS driver with the simplified OpenIDConnect
driver. The new driver doesn't require the 'keys_url' parameter,
all needed parameters are fetched from the well-known config
endpoint inferred from the issuer_id.

Add a simple workflow test of the OpenIDConnect driver.

Change-Id: I4b0936a587918d6051a4206e20cad68577617e3d
This commit is contained in:
Matthieu Huin 2020-01-10 15:16:38 +01:00
parent 9ce06b4a73
commit 8f243b0126
7 changed files with 403 additions and 31 deletions

View File

@ -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
~~~~~~~~~

View File

@ -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.

51
tests/fixtures/auth/oidc-key vendored Normal file
View File

@ -0,0 +1,51 @@
-----BEGIN RSA PRIVATE KEY-----
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
-----END RSA PRIVATE KEY-----

14
tests/fixtures/auth/oidc-key.pub vendored Normal file
View File

@ -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-----

View File

@ -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"
}

97
tests/unit/test_auth.py Normal file
View File

@ -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])

View File

@ -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]