Fix keycloak authentication
* Implement offline access token validation using Keycloak public key. Closes-bug: #1857871 Change-Id: I0eecec4b4e64381cac005622b16c6d9e4bed4df6
This commit is contained in:
parent
fef538cce7
commit
c888a46ccc
@ -102,7 +102,7 @@ pycparser==2.18
|
|||||||
pyflakes==0.8.1
|
pyflakes==0.8.1
|
||||||
Pygments==2.2.0
|
Pygments==2.2.0
|
||||||
pyinotify==0.9.6
|
pyinotify==0.9.6
|
||||||
PyJWT==1.0.1
|
PyJWT==1.5
|
||||||
pyOpenSSL==17.1.0
|
pyOpenSSL==17.1.0
|
||||||
pyparsing==2.1.0
|
pyparsing==2.1.0
|
||||||
pyperclip==1.5.27
|
pyperclip==1.5.27
|
||||||
|
@ -14,7 +14,11 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from cachetools import cached
|
||||||
|
from cachetools import LRUCache
|
||||||
|
import json
|
||||||
import jwt
|
import jwt
|
||||||
|
from jwt import algorithms as jwt_algos
|
||||||
from oslo_config import cfg
|
from oslo_config import cfg
|
||||||
from oslo_log import log as logging
|
from oslo_log import log as logging
|
||||||
import pprint
|
import pprint
|
||||||
@ -32,31 +36,34 @@ CONF = cfg.CONF
|
|||||||
|
|
||||||
|
|
||||||
class KeycloakAuthHandler(auth.AuthHandler):
|
class KeycloakAuthHandler(auth.AuthHandler):
|
||||||
|
|
||||||
def authenticate(self, req):
|
def authenticate(self, req):
|
||||||
certfile = CONF.keycloak_oidc.certfile
|
|
||||||
keyfile = CONF.keycloak_oidc.keyfile
|
|
||||||
cafile = CONF.keycloak_oidc.cafile or self.get_system_ca_file()
|
|
||||||
insecure = CONF.keycloak_oidc.insecure
|
|
||||||
|
|
||||||
if 'X-Auth-Token' not in req.headers:
|
if 'X-Auth-Token' not in req.headers:
|
||||||
msg = _("Auth token must be provided in 'X-Auth-Token' header.")
|
msg = _("Auth token must be provided in 'X-Auth-Token' header.")
|
||||||
|
|
||||||
LOG.error(msg)
|
LOG.error(msg)
|
||||||
|
|
||||||
raise exc.UnauthorizedException(message=msg)
|
raise exc.UnauthorizedException(message=msg)
|
||||||
|
|
||||||
access_token = req.headers.get('X-Auth-Token')
|
access_token = req.headers.get('X-Auth-Token')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
decoded = jwt.decode(access_token, algorithms=['RS256'],
|
decoded = jwt.decode(
|
||||||
verify=False)
|
access_token,
|
||||||
|
algorithms=['RS256'],
|
||||||
|
verify=False
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
msg = _("Token can't be decoded because of wrong format %s")\
|
msg = _("Token can't be decoded because of wrong format %s")\
|
||||||
% str(e)
|
% str(e)
|
||||||
|
|
||||||
LOG.error(msg)
|
LOG.error(msg)
|
||||||
|
|
||||||
raise exc.UnauthorizedException(message=msg)
|
raise exc.UnauthorizedException(message=msg)
|
||||||
|
|
||||||
# Get user realm from parsed token
|
# Get user realm from parsed token
|
||||||
# Format is "iss": "http://<host>:<port>/auth/realms/<realm_name>",
|
# Format is "iss": "http://<host>:<port>/auth/realms/<realm_name>",
|
||||||
__, __, realm_name = decoded['iss'].strip().rpartition('/realms/')
|
__, __, realm_name = decoded['iss'].strip().rpartition('/realms/')
|
||||||
|
audience = decoded.get('aud')
|
||||||
|
|
||||||
# Get roles from parsed token
|
# Get roles from parsed token
|
||||||
roles = ','.join(decoded['realm_access']['roles']) \
|
roles = ','.join(decoded['realm_access']['roles']) \
|
||||||
@ -70,46 +77,31 @@ class KeycloakAuthHandler(auth.AuthHandler):
|
|||||||
user_info_endpoint_url = CONF.keycloak_oidc.user_info_endpoint_url
|
user_info_endpoint_url = CONF.keycloak_oidc.user_info_endpoint_url
|
||||||
|
|
||||||
if user_info_endpoint_url.startswith(('http://', 'https://')):
|
if user_info_endpoint_url.startswith(('http://', 'https://')):
|
||||||
user_info_endpoint = user_info_endpoint_url
|
self.send_request_to_auth_server(
|
||||||
|
url=user_info_endpoint_url,
|
||||||
|
access_token=access_token
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
user_info_endpoint = (
|
public_key = self.get_public_key(realm_name)
|
||||||
("%s" + user_info_endpoint_url) %
|
|
||||||
(CONF.keycloak_oidc.auth_url, realm_name))
|
|
||||||
|
|
||||||
verify = None
|
keycloak_iss = None
|
||||||
if urllib.parse.urlparse(user_info_endpoint).scheme == "https":
|
|
||||||
verify = False if insecure else cafile
|
|
||||||
|
|
||||||
cert = (certfile, keyfile) if certfile and keyfile else None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = requests.get(
|
if CONF.keycloak_oidc.keycloak_iss:
|
||||||
user_info_endpoint,
|
keycloak_iss = CONF.keycloak_oidc.keycloak_iss % realm_name
|
||||||
headers={"Authorization": "Bearer %s" % access_token},
|
|
||||||
verify=verify,
|
jwt.decode(
|
||||||
cert=cert
|
access_token,
|
||||||
|
public_key,
|
||||||
|
audience=audience,
|
||||||
|
issuer=keycloak_iss,
|
||||||
|
algorithms=['RS256'],
|
||||||
|
verify=True
|
||||||
)
|
)
|
||||||
except requests.ConnectionError:
|
except Exception:
|
||||||
msg = _("Can't connect to keycloak server with address '%s'."
|
LOG.exception('The request access token is invalid.')
|
||||||
) % CONF.keycloak_oidc.auth_url
|
|
||||||
LOG.error(msg)
|
|
||||||
raise exc.MistralException(message=msg)
|
|
||||||
|
|
||||||
if resp.status_code == 401:
|
raise exc.UnauthorizedException()
|
||||||
LOG.warning("HTTP response from OIDC provider:"
|
|
||||||
" [%s] with WWW-Authenticate: [%s]",
|
|
||||||
pprint.pformat(resp.text),
|
|
||||||
resp.headers.get("WWW-Authenticate"))
|
|
||||||
else:
|
|
||||||
LOG.debug("HTTP response from OIDC provider: %s",
|
|
||||||
pprint.pformat(resp.text))
|
|
||||||
|
|
||||||
resp.raise_for_status()
|
|
||||||
|
|
||||||
LOG.debug(
|
|
||||||
"HTTP response from OIDC provider: %s",
|
|
||||||
pprint.pformat(resp.json())
|
|
||||||
)
|
|
||||||
|
|
||||||
req.headers["X-Identity-Status"] = "Confirmed"
|
req.headers["X-Identity-Status"] = "Confirmed"
|
||||||
req.headers["X-Project-Id"] = realm_name
|
req.headers["X-Project-Id"] = realm_name
|
||||||
@ -119,16 +111,95 @@ class KeycloakAuthHandler(auth.AuthHandler):
|
|||||||
def get_system_ca_file():
|
def get_system_ca_file():
|
||||||
"""Return path to system default CA file."""
|
"""Return path to system default CA file."""
|
||||||
# Standard CA file locations for Debian/Ubuntu, RedHat/Fedora,
|
# Standard CA file locations for Debian/Ubuntu, RedHat/Fedora,
|
||||||
# Suse, FreeBSD/OpenBSD, MacOSX, and the bundled ca
|
# Suse, FreeBSD/OpenBSD, MacOSX, and the bundled ca.
|
||||||
ca_path = ['/etc/ssl/certs/ca-certificates.crt',
|
ca_path = [
|
||||||
|
'/etc/ssl/certs/ca-certificates.crt',
|
||||||
'/etc/pki/tls/certs/ca-bundle.crt',
|
'/etc/pki/tls/certs/ca-bundle.crt',
|
||||||
'/etc/ssl/ca-bundle.pem',
|
'/etc/ssl/ca-bundle.pem',
|
||||||
'/etc/ssl/cert.pem',
|
'/etc/ssl/cert.pem',
|
||||||
'/System/Library/OpenSSL/certs/cacert.pem',
|
'/System/Library/OpenSSL/certs/cacert.pem',
|
||||||
requests.certs.where()]
|
requests.certs.where()
|
||||||
|
]
|
||||||
|
|
||||||
for ca in ca_path:
|
for ca in ca_path:
|
||||||
LOG.debug("Looking for ca file %s", ca)
|
LOG.debug("Looking for ca file %s", ca)
|
||||||
|
|
||||||
if os.path.exists(ca):
|
if os.path.exists(ca):
|
||||||
LOG.debug("Using ca file %s", ca)
|
LOG.debug("Using ca file %s", ca)
|
||||||
|
|
||||||
return ca
|
return ca
|
||||||
|
|
||||||
LOG.warning("System ca file could not be found.")
|
LOG.warning("System ca file could not be found.")
|
||||||
|
|
||||||
|
@cached(LRUCache(maxsize=32))
|
||||||
|
def get_public_key(self, realm_name):
|
||||||
|
keycloak_key_url = (
|
||||||
|
CONF.keycloak_oidc.auth_url +
|
||||||
|
CONF.keycloak_oidc.public_cert_url % realm_name
|
||||||
|
)
|
||||||
|
|
||||||
|
response_json = self.send_request_to_auth_server(keycloak_key_url)
|
||||||
|
|
||||||
|
keys = response_json.get('keys')
|
||||||
|
|
||||||
|
if not keys:
|
||||||
|
raise exc.MistralException(
|
||||||
|
'Unexpected response structure from the keycloak server.'
|
||||||
|
)
|
||||||
|
|
||||||
|
public_key = jwt_algos.RSAAlgorithm.from_jwk(
|
||||||
|
json.dumps(keys[0])
|
||||||
|
)
|
||||||
|
|
||||||
|
return public_key
|
||||||
|
|
||||||
|
def send_request_to_auth_server(self, url, access_token=None):
|
||||||
|
certfile = CONF.keycloak_oidc.certfile
|
||||||
|
keyfile = CONF.keycloak_oidc.keyfile
|
||||||
|
cafile = CONF.keycloak_oidc.cafile or self.get_system_ca_file()
|
||||||
|
insecure = CONF.keycloak_oidc.insecure
|
||||||
|
|
||||||
|
verify = None
|
||||||
|
|
||||||
|
if urllib.parse.urlparse(url).scheme == "https":
|
||||||
|
verify = False if insecure else cafile
|
||||||
|
|
||||||
|
cert = (certfile, keyfile) if certfile and keyfile else None
|
||||||
|
|
||||||
|
headers = {}
|
||||||
|
|
||||||
|
if access_token:
|
||||||
|
headers["Authorization"] = "Bearer %s" % access_token
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
url,
|
||||||
|
headers=headers,
|
||||||
|
verify=verify,
|
||||||
|
cert=cert
|
||||||
|
)
|
||||||
|
except requests.ConnectionError:
|
||||||
|
msg = _(
|
||||||
|
"Can't connect to the keycloak server with address '%s'."
|
||||||
|
) % url
|
||||||
|
|
||||||
|
LOG.exception(msg)
|
||||||
|
|
||||||
|
raise exc.MistralException(message=msg)
|
||||||
|
|
||||||
|
if resp.status_code == 401:
|
||||||
|
LOG.warning(
|
||||||
|
"HTTP response from OIDC provider:"
|
||||||
|
" [%s] with WWW-Authenticate: [%s]",
|
||||||
|
pprint.pformat(resp.text),
|
||||||
|
resp.headers.get("WWW-Authenticate")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
LOG.debug(
|
||||||
|
"HTTP response from the OIDC provider: %s",
|
||||||
|
pprint.pformat(resp.json())
|
||||||
|
)
|
||||||
|
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
return resp.json()
|
||||||
|
@ -546,6 +546,16 @@ keycloak_oidc_opts = [
|
|||||||
default='/realms/%s/protocol/openid-connect/userinfo',
|
default='/realms/%s/protocol/openid-connect/userinfo',
|
||||||
help='Endpoint against which authorization will be performed'
|
help='Endpoint against which authorization will be performed'
|
||||||
),
|
),
|
||||||
|
cfg.StrOpt(
|
||||||
|
'public_cert_url',
|
||||||
|
default="/realms/%s/protocol/openid-connect/certs",
|
||||||
|
help="URL to get the public key for a particular realm"
|
||||||
|
),
|
||||||
|
cfg.StrOpt(
|
||||||
|
'keycloak_iss',
|
||||||
|
help="Keycloak issuer(iss) url. "
|
||||||
|
"Example: https://ip_add:port/auth/realms/%s"
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
openstack_actions_opts = [
|
openstack_actions_opts = [
|
||||||
|
@ -19,6 +19,7 @@ from mistral_lib.actions import context as lib_ctx
|
|||||||
from mistral_lib import serialization
|
from mistral_lib import serialization
|
||||||
from oslo_config import cfg
|
from oslo_config import cfg
|
||||||
from oslo_context import context as oslo_context
|
from oslo_context import context as oslo_context
|
||||||
|
from oslo_log import log as logging
|
||||||
import oslo_messaging as messaging
|
import oslo_messaging as messaging
|
||||||
from oslo_serialization import jsonutils
|
from oslo_serialization import jsonutils
|
||||||
from osprofiler import profiler
|
from osprofiler import profiler
|
||||||
@ -29,7 +30,11 @@ from mistral import auth
|
|||||||
from mistral import exceptions as exc
|
from mistral import exceptions as exc
|
||||||
from mistral_lib import utils
|
from mistral_lib import utils
|
||||||
|
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
CONF = cfg.CONF
|
CONF = cfg.CONF
|
||||||
|
|
||||||
_CTX_THREAD_LOCAL_NAME = "MISTRAL_APP_CTX_THREAD_LOCAL"
|
_CTX_THREAD_LOCAL_NAME = "MISTRAL_APP_CTX_THREAD_LOCAL"
|
||||||
ALLOWED_WITHOUT_AUTH = ['/', '/v2/', '/workflowv2/', '/workflowv2/v2/']
|
ALLOWED_WITHOUT_AUTH = ['/', '/v2/', '/workflowv2/', '/workflowv2/v2/']
|
||||||
|
|
||||||
@ -249,6 +254,8 @@ class AuthHook(hooks.PecanHook):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
msg = "Failed to validate access token: %s" % str(e)
|
msg = "Failed to validate access token: %s" % str(e)
|
||||||
|
|
||||||
|
LOG.exception(msg)
|
||||||
|
|
||||||
pecan.abort(
|
pecan.abort(
|
||||||
status_code=401,
|
status_code=401,
|
||||||
detail=msg,
|
detail=msg,
|
||||||
|
@ -30,6 +30,18 @@ from mistral.services import periodic
|
|||||||
from mistral.tests.unit import base
|
from mistral.tests.unit import base
|
||||||
from mistral.tests.unit.mstrlfixtures import policy_fixtures
|
from mistral.tests.unit.mstrlfixtures import policy_fixtures
|
||||||
|
|
||||||
|
KEYCLOAK_JSON = {
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"kid": "FJ86GcF3jTbNLOco4NvZkUCIUmfYCqoqtOQeMfbhNlE",
|
||||||
|
"kty": "RSA",
|
||||||
|
"alg": "RS256",
|
||||||
|
"use": "sig",
|
||||||
|
"n": "q1awrk7QK24Gmcy9Yb4dMbS-ZnO6",
|
||||||
|
"e": "AQAB"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
WF_DEFINITION = """
|
WF_DEFINITION = """
|
||||||
---
|
---
|
||||||
@ -86,7 +98,6 @@ WWW_AUTHENTICATE_HEADER = {'WWW-Authenticate': 'unauthorized reason is ...'}
|
|||||||
|
|
||||||
|
|
||||||
class TestKeyCloakOIDCAuth(base.BaseTest):
|
class TestKeyCloakOIDCAuth(base.BaseTest):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super(TestKeyCloakOIDCAuth, self).setUp()
|
super(TestKeyCloakOIDCAuth, self).setUp()
|
||||||
|
|
||||||
@ -102,8 +113,14 @@ class TestKeyCloakOIDCAuth(base.BaseTest):
|
|||||||
|
|
||||||
return req
|
return req
|
||||||
|
|
||||||
@requests_mock.Mocker()
|
@mock.patch("requests.get")
|
||||||
def test_header_parsing(self, req_mock):
|
def test_header_parsing(self, mocked_get):
|
||||||
|
self.override_config(
|
||||||
|
'user_info_endpoint_url',
|
||||||
|
'https://127.0.0.1:9080',
|
||||||
|
'keycloak_oidc'
|
||||||
|
)
|
||||||
|
|
||||||
token = {
|
token = {
|
||||||
"iss": "http://localhost:8080/auth/realms/my_realm",
|
"iss": "http://localhost:8080/auth/realms/my_realm",
|
||||||
"realm_access": {
|
"realm_access": {
|
||||||
@ -111,8 +128,11 @@ class TestKeyCloakOIDCAuth(base.BaseTest):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Imitate successful response from KeyCloak with user claims.
|
mocked_resp = mock.Mock()
|
||||||
req_mock.get(USER_INFO_ENDPOINT, json=USER_CLAIMS)
|
mocked_resp.status_code = 200
|
||||||
|
mocked_resp.json.return_value = KEYCLOAK_JSON
|
||||||
|
|
||||||
|
mocked_get.return_value = mocked_resp
|
||||||
|
|
||||||
req = self._build_request(token)
|
req = self._build_request(token)
|
||||||
|
|
||||||
@ -122,7 +142,7 @@ class TestKeyCloakOIDCAuth(base.BaseTest):
|
|||||||
self.assertEqual("Confirmed", req.headers["X-Identity-Status"])
|
self.assertEqual("Confirmed", req.headers["X-Identity-Status"])
|
||||||
self.assertEqual("my_realm", req.headers["X-Project-Id"])
|
self.assertEqual("my_realm", req.headers["X-Project-Id"])
|
||||||
self.assertEqual("role1,role2", req.headers["X-Roles"])
|
self.assertEqual("role1,role2", req.headers["X-Roles"])
|
||||||
self.assertEqual(1, req_mock.call_count)
|
self.assertEqual(1, mocked_get.call_count)
|
||||||
|
|
||||||
def test_no_auth_token(self):
|
def test_no_auth_token(self):
|
||||||
req = webob.Request.blank("/")
|
req = webob.Request.blank("/")
|
||||||
@ -133,12 +153,19 @@ class TestKeyCloakOIDCAuth(base.BaseTest):
|
|||||||
req
|
req
|
||||||
)
|
)
|
||||||
|
|
||||||
@requests_mock.Mocker()
|
@mock.patch("requests.get")
|
||||||
def test_no_realm_roles(self, req_mock):
|
def test_no_realm_roles(self, mocked_get):
|
||||||
token = {"iss": "http://localhost:8080/auth/realms/my_realm"}
|
token = {
|
||||||
|
"aud": "openstack",
|
||||||
|
"iss": "http://localhost:8080/auth/realms/my_realm",
|
||||||
|
}
|
||||||
|
|
||||||
# Imitate successful response from KeyCloak with user claims.
|
mocked_resp = mock.Mock()
|
||||||
req_mock.get(USER_INFO_ENDPOINT, json=USER_CLAIMS)
|
|
||||||
|
mocked_resp.status_code = 200
|
||||||
|
mocked_resp.json.return_value = KEYCLOAK_JSON
|
||||||
|
|
||||||
|
mocked_get.return_value = mocked_resp
|
||||||
|
|
||||||
req = self._build_request(token)
|
req = self._build_request(token)
|
||||||
|
|
||||||
@ -160,13 +187,20 @@ class TestKeyCloakOIDCAuth(base.BaseTest):
|
|||||||
|
|
||||||
@requests_mock.Mocker()
|
@requests_mock.Mocker()
|
||||||
def test_server_unauthorized(self, req_mock):
|
def test_server_unauthorized(self, req_mock):
|
||||||
|
self.override_config(
|
||||||
|
'user_info_endpoint_url',
|
||||||
|
'https://127.0.0.1:9080',
|
||||||
|
'keycloak_oidc'
|
||||||
|
)
|
||||||
|
|
||||||
token = {
|
token = {
|
||||||
|
"aud": "openstack",
|
||||||
"iss": "http://localhost:8080/auth/realms/my_realm",
|
"iss": "http://localhost:8080/auth/realms/my_realm",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Imitate failure response from KeyCloak.
|
# Imitate failure response from KeyCloak.
|
||||||
req_mock.get(
|
req_mock.get(
|
||||||
USER_INFO_ENDPOINT,
|
'https://127.0.0.1:9080',
|
||||||
status_code=401,
|
status_code=401,
|
||||||
reason='Access token is invalid',
|
reason='Access token is invalid',
|
||||||
headers=WWW_AUTHENTICATE_HEADER
|
headers=WWW_AUTHENTICATE_HEADER
|
||||||
@ -186,15 +220,20 @@ class TestKeyCloakOIDCAuth(base.BaseTest):
|
|||||||
'unauthorized reason is ...',
|
'unauthorized reason is ...',
|
||||||
e.response.headers.get('WWW-Authenticate')
|
e.response.headers.get('WWW-Authenticate')
|
||||||
)
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
raise Exception("Test is broken")
|
raise Exception("Test is broken")
|
||||||
|
|
||||||
@requests_mock.Mocker()
|
@mock.patch("requests.get")
|
||||||
def test_connection_error(self, req_mock):
|
def test_connection_error(self, mocked_get):
|
||||||
token = {"iss": "http://localhost:8080/auth/realms/my_realm"}
|
token = {
|
||||||
|
"aud": "openstack",
|
||||||
|
"iss": "http://localhost:8080/auth/realms/my_realm",
|
||||||
|
"realm_access": {
|
||||||
|
"roles": ["role1", "role2"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
req_mock.get(USER_INFO_ENDPOINT, exc=requests.ConnectionError)
|
mocked_get.side_effect = requests.ConnectionError
|
||||||
|
|
||||||
req = self._build_request(token)
|
req = self._build_request(token)
|
||||||
|
|
||||||
@ -235,11 +274,15 @@ class TestKeyCloakOIDCAuthScenarios(base.DbTestCase):
|
|||||||
|
|
||||||
self.policy = self.useFixture(policy_fixtures.PolicyFixture())
|
self.policy = self.useFixture(policy_fixtures.PolicyFixture())
|
||||||
|
|
||||||
@requests_mock.Mocker()
|
@mock.patch("requests.get")
|
||||||
@mock.patch.object(db_api, 'get_workflow_definition', MOCK_WF)
|
@mock.patch.object(db_api, 'get_workflow_definition', MOCK_WF)
|
||||||
def test_get_workflow_success_auth(self, req_mock):
|
def test_get_workflow_success_auth(self, mocked_get):
|
||||||
# Imitate successful response from KeyCloak with user claims.
|
mocked_resp = mock.Mock()
|
||||||
req_mock.get(USER_INFO_ENDPOINT, json=USER_CLAIMS)
|
|
||||||
|
mocked_resp.status_code = 200
|
||||||
|
mocked_resp.json.return_value = KEYCLOAK_JSON
|
||||||
|
|
||||||
|
mocked_get.return_value = mocked_resp
|
||||||
|
|
||||||
token = {
|
token = {
|
||||||
"iss": "http://localhost:8080/auth/realms/%s" % REALM_NAME,
|
"iss": "http://localhost:8080/auth/realms/%s" % REALM_NAME,
|
||||||
@ -258,19 +301,13 @@ class TestKeyCloakOIDCAuthScenarios(base.DbTestCase):
|
|||||||
|
|
||||||
@mock.patch("requests.get")
|
@mock.patch("requests.get")
|
||||||
@mock.patch.object(db_api, 'get_workflow_definition', MOCK_WF)
|
@mock.patch.object(db_api, 'get_workflow_definition', MOCK_WF)
|
||||||
def test_get_workflow_invalid_token_format(self, req_mock):
|
def test_get_workflow_invalid_token_format(self, mocked_get):
|
||||||
# Imitate successful response from KeyCloak with user claims.
|
token = 'WRONG_FORMAT_TOKEN'
|
||||||
req_mock.get(USER_INFO_ENDPOINT, json=USER_CLAIMS)
|
|
||||||
|
|
||||||
token = {
|
|
||||||
"iss": "http://localhost:8080/auth/realms/%s" % REALM_NAME,
|
|
||||||
"realm_access": {
|
|
||||||
"roles": ["role1", "role2"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
headers = {'X-Auth-Token': str(token)}
|
headers = {'X-Auth-Token': str(token)}
|
||||||
|
|
||||||
|
# We don't mock jwt.decode so the test must fail.
|
||||||
|
|
||||||
resp = self.app.get(
|
resp = self.app.get(
|
||||||
'/v2/workflows/123',
|
'/v2/workflows/123',
|
||||||
headers=headers,
|
headers=headers,
|
||||||
@ -285,18 +322,18 @@ class TestKeyCloakOIDCAuthScenarios(base.DbTestCase):
|
|||||||
resp.text
|
resp.text
|
||||||
)
|
)
|
||||||
|
|
||||||
@requests_mock.Mocker()
|
@mock.patch("requests.get")
|
||||||
@mock.patch.object(db_api, 'get_workflow_definition', MOCK_WF)
|
@mock.patch.object(db_api, 'get_workflow_definition', MOCK_WF)
|
||||||
def test_get_workflow_failed_auth(self, req_mock):
|
def test_get_workflow_failed_auth(self, mocked_get):
|
||||||
# Imitate failure response from KeyCloak.
|
mocked_resp = mock.Mock()
|
||||||
req_mock.get(
|
|
||||||
USER_INFO_ENDPOINT,
|
|
||||||
status_code=401,
|
|
||||||
reason='Access token is invalid'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
mocked_resp.status_code = 200
|
||||||
|
mocked_resp.json.return_value = KEYCLOAK_JSON
|
||||||
|
|
||||||
|
mocked_get.return_value = mocked_resp
|
||||||
|
|
||||||
|
# A token without an issuer (iss).
|
||||||
token = {
|
token = {
|
||||||
"iss": "http://localhost:8080/auth/realms/%s" % REALM_NAME,
|
|
||||||
"realm_access": {
|
"realm_access": {
|
||||||
"roles": ["role1", "role2"]
|
"roles": ["role1", "role2"]
|
||||||
}
|
}
|
||||||
@ -313,12 +350,11 @@ class TestKeyCloakOIDCAuthScenarios(base.DbTestCase):
|
|||||||
|
|
||||||
self.assertEqual(401, resp.status_code)
|
self.assertEqual(401, resp.status_code)
|
||||||
self.assertEqual('401 Unauthorized', resp.status)
|
self.assertEqual('401 Unauthorized', resp.status)
|
||||||
self.assertIn('Failed to validate access token', resp.text)
|
self.assertIn("Failed to validate access token: 'iss'", resp.text)
|
||||||
self.assertIn('Access token is invalid', resp.text)
|
|
||||||
|
|
||||||
|
|
||||||
class TestKeyCloakOIDCAuthApp(base.DbTestCase):
|
class TestKeyCloakOIDCAuthApp(base.DbTestCase):
|
||||||
"""Test that Keycloak auth params were successfully passed to Context"""
|
"""Test that Keycloak auth params get passed to the security context."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super(TestKeyCloakOIDCAuthApp, self).setUp()
|
super(TestKeyCloakOIDCAuthApp, self).setUp()
|
||||||
@ -339,10 +375,15 @@ class TestKeyCloakOIDCAuthApp(base.DbTestCase):
|
|||||||
|
|
||||||
self.policy = self.useFixture(policy_fixtures.PolicyFixture())
|
self.policy = self.useFixture(policy_fixtures.PolicyFixture())
|
||||||
|
|
||||||
@requests_mock.Mocker()
|
@mock.patch("requests.get")
|
||||||
@mock.patch.object(db_api, 'get_workflow_definition', MOCK_WF)
|
@mock.patch.object(db_api, 'get_workflow_definition', MOCK_WF)
|
||||||
def test_params_transition(self, req_mock):
|
def test_params_transition(self, mocked_get):
|
||||||
req_mock.get(USER_INFO_ENDPOINT, json=USER_CLAIMS)
|
mocked_resp = mock.Mock()
|
||||||
|
|
||||||
|
mocked_resp.status_code = 200
|
||||||
|
mocked_resp.json.return_value = KEYCLOAK_JSON
|
||||||
|
|
||||||
|
mocked_get.return_value = mocked_resp
|
||||||
|
|
||||||
token = {
|
token = {
|
||||||
"iss": "http://localhost:8080/auth/realms/%s" % REALM_NAME,
|
"iss": "http://localhost:8080/auth/realms/%s" % REALM_NAME,
|
||||||
@ -351,22 +392,22 @@ class TestKeyCloakOIDCAuthApp(base.DbTestCase):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
headers = {
|
headers = {'X-Auth-Token': str(token)}
|
||||||
'X-Auth-Token': str(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
with mock.patch("jwt.decode", return_value=token):
|
with mock.patch("jwt.decode", return_value=token):
|
||||||
with mock.patch("mistral.context.set_ctx") as mocked_set_cxt:
|
with mock.patch("mistral.context.set_ctx") as mocked_set_cxt:
|
||||||
self.app.get('/v2/workflows/123', headers=headers)
|
self.app.get('/v2/workflows/123', headers=headers)
|
||||||
|
|
||||||
calls = mocked_set_cxt.call_args_list
|
calls = mocked_set_cxt.call_args_list
|
||||||
|
|
||||||
self.assertEqual(2, len(calls))
|
self.assertEqual(2, len(calls))
|
||||||
|
|
||||||
# First positional argument of the first call ('before')
|
# First positional argument of the first call ('before').
|
||||||
ctx = calls[0][0][0]
|
ctx = calls[0][0][0]
|
||||||
|
|
||||||
self.assertIsInstance(ctx, context.MistralContext)
|
self.assertIsInstance(ctx, context.MistralContext)
|
||||||
self.assertEqual('my_realm', ctx.project_id)
|
self.assertEqual('my_realm', ctx.project_id)
|
||||||
self.assertEqual(["role1", "role2"], ctx.roles)
|
self.assertEqual(["role1", "role2"], ctx.roles)
|
||||||
|
|
||||||
# Second call of set_ctx ('after'), where we reset the context
|
# Second call of set_ctx ('after'), where we reset the context.
|
||||||
self.assertIsNone(calls[1][0][0])
|
self.assertIsNone(calls[1][0][0])
|
||||||
|
@ -56,7 +56,7 @@ python-ironic-inspector-client>=1.5.0 # Apache-2.0
|
|||||||
python-vitrageclient>=2.0.0 # Apache-2.0
|
python-vitrageclient>=2.0.0 # Apache-2.0
|
||||||
python-zunclient>=3.4.0 # Apache-2.0
|
python-zunclient>=3.4.0 # Apache-2.0
|
||||||
python-qinlingclient>=1.0.0 # Apache-2.0
|
python-qinlingclient>=1.0.0 # Apache-2.0
|
||||||
PyJWT>=1.0.1 # MIT
|
PyJWT>=1.5 # MIT
|
||||||
PyYAML>=3.12 # MIT
|
PyYAML>=3.12 # MIT
|
||||||
requests>=2.14.2 # Apache-2.0
|
requests>=2.14.2 # Apache-2.0
|
||||||
tenacity>=5.0.1 # Apache-2.0
|
tenacity>=5.0.1 # Apache-2.0
|
||||||
|
Loading…
x
Reference in New Issue
Block a user