Browse Source

[raz] Add JWT support for Hue-Raz Integration (#3289)

- Instead of directly asking RAZ for delegation token via Kerberos credentials, we are now fetching JWT from Knox host (this setup can be in HA mode) and then serve that JWT to RAZ to get back the delegation token.
- The later flow using the delegation token remains the same.

- Updated existing UTs and added new UTs.
- Tested in a live cluster - Hue is successfully able to fetch JWT from Knox and serve it to RAZ. This was tested for both HA and non-HA cluster.
- However, RAZ is not configured to return delegation token for JWT scenario. So we cannot fully test it E2E yet. This will be tested when RAZ changes are done from their side.
Harsh Gupta 2 năm trước cách đây
mục cha
commit
29b4cb1789

+ 1 - 1
desktop/conf.dist/hue.ini

@@ -963,7 +963,7 @@ tls=no
 ## Endpoint to contact
 ## Endpoint to contact
 # api_url=https://localhost:8080
 # api_url=https://localhost:8080
 
 
-## How to authenticate against: KERBEROS or JWT (not supported yet)
+## How to authenticate against: KERBEROS or JWT
 # api_authentication=KERBEROS
 # api_authentication=KERBEROS
 
 
 ## Autocreate the user home directory in the remote home storage path.
 ## Autocreate the user home directory in the remote home storage path.

+ 1 - 1
desktop/conf/pseudo-distributed.ini.tmpl

@@ -946,7 +946,7 @@
   ## Endpoint to contact
   ## Endpoint to contact
   # api_url=https://localhost:8080
   # api_url=https://localhost:8080
 
 
-  ## How to authenticate against: KERBEROS or JWT (not supported yet)
+  ## How to authenticate against: KERBEROS or JWT
   # api_authentication=KERBEROS
   # api_authentication=KERBEROS
 
 
   ## Autocreate the user home directory in the remote home storage path.
   ## Autocreate the user home directory in the remote home storage path.

+ 24 - 2
desktop/core/src/desktop/conf.py

@@ -2174,6 +2174,28 @@ def has_raz_url():
   return bool(_get_raz_url())
   return bool(_get_raz_url())
 
 
 
 
+SDXAAS = ConfigSection(
+  key='sdxaas',
+  help=_("""Configuration for SDXaaS JWT support."""),
+  members=dict(
+    TOKEN_URL=Config(
+      key='token_url',
+      help=_('Comma separated host URLs to fetch token from.'),
+      type=str,
+      default='',
+    )
+  )
+)
+
+def is_sdxaas_jwt_enabled():
+  """Check if SDXaaS token url is configured"""
+  return bool(SDXAAS.TOKEN_URL.get())
+
+def handle_raz_api_auth():
+  """Return RAZ authentication type from JWT (if SDXaaS token URL is set) or KERBEROS"""
+  return 'jwt' if is_sdxaas_jwt_enabled() else 'kerberos'
+
+
 RAZ = ConfigSection(
 RAZ = ConfigSection(
   key='raz',
   key='raz',
   help=_("""Configuration for RAZ service integration"""),
   help=_("""Configuration for RAZ service integration"""),
@@ -2192,9 +2214,9 @@ RAZ = ConfigSection(
     ),
     ),
     API_AUTHENTICATION=Config(
     API_AUTHENTICATION=Config(
         key='api_authentication',
         key='api_authentication',
-        help=_('How to authenticate against: KERBEROS or JWT (not supported yet)'),
+        help=_('How to authenticate against: KERBEROS or JWT'),
         type=coerce_str_lowercase,
         type=coerce_str_lowercase,
-        default='kerberos',
+        dynamic_default=handle_raz_api_auth,
     ),
     ),
     AUTOCREATE_USER_DIR=Config(
     AUTOCREATE_USER_DIR=Config(
       key='autocreate_user_dir',
       key='autocreate_user_dir',

+ 22 - 7
desktop/core/src/desktop/lib/raz/raz_client.py

@@ -30,6 +30,8 @@ from datetime import datetime, timedelta
 
 
 from desktop.conf import AUTH_USERNAME
 from desktop.conf import AUTH_USERNAME
 from desktop.lib.exceptions_renderable import PopupException
 from desktop.lib.exceptions_renderable import PopupException
+from desktop.lib.sdxaas.knox_jwt import fetch_jwt
+
 import desktop.lib.raz.signer_protos_pb2 as raz_signer
 import desktop.lib.raz.signer_protos_pb2 as raz_signer
 
 
 if sys.version_info[0] > 2:
 if sys.version_info[0] > 2:
@@ -44,17 +46,20 @@ LOG = logging.getLogger(__name__)
 
 
 class RazToken:
 class RazToken:
 
 
-  def __init__(self, raz_url, auth_handler):
+  def __init__(self, raz_url, auth_type):
     self.raz_url = raz_url
     self.raz_url = raz_url
-    self.auth_handler = auth_handler
+    self.auth_handler = requests_kerberos.HTTPKerberosAuth(mutual_authentication=requests_kerberos.OPTIONAL)
     self.init_time = datetime.now()
     self.init_time = datetime.now()
     self.raz_token = None
     self.raz_token = None
+    self.auth_type = auth_type
+
     o = lib_urlparse(self.raz_url)
     o = lib_urlparse(self.raz_url)
     if not o.netloc:
     if not o.netloc:
       raise PopupException('Could not parse the host of the Raz server %s' % self.raz_url)
       raise PopupException('Could not parse the host of the Raz server %s' % self.raz_url)
     self.raz_hostname, self.raz_port = o.netloc.split(':')
     self.raz_hostname, self.raz_port = o.netloc.split(':')
     self.scheme = o.scheme
     self.scheme = o.scheme
 
 
+
   def get_delegation_token(self, user):
   def get_delegation_token(self, user):
     ip_address = socket.gethostbyname(self.raz_hostname)
     ip_address = socket.gethostbyname(self.raz_hostname)
     GET_PARAMS = {
     GET_PARAMS = {
@@ -63,10 +68,23 @@ class RazToken:
       "renewer": AUTH_USERNAME.get(),
       "renewer": AUTH_USERNAME.get(),
       "doAs": user
       "doAs": user
     }
     }
-    r = requests.get(self.raz_url, GET_PARAMS, auth=self.auth_handler, verify=False)
+
+    if self.auth_type == 'kerberos':
+      r = requests.get(self.raz_url, GET_PARAMS, auth=self.auth_handler, verify=False)
+    elif self.auth_type == 'jwt':
+      jwt_token = fetch_jwt()
+      if jwt_token is None:
+        raise PopupException('Knox JWT is not available to send to RAZ.')
+
+      _headers = {'Authorization': 'Bearer %s' % (jwt_token)}
+      r = requests.get(self.raz_url, GET_PARAMS, headers=_headers, verify=False)
+
     self.raz_token = json.loads(r.text)['Token']['urlString']
     self.raz_token = json.loads(r.text)['Token']['urlString']
+    LOG.debug('Raz token: %s' % self.raz_token)
+
     return self.raz_token
     return self.raz_token
 
 
+
   def renew_delegation_token(self, user):
   def renew_delegation_token(self, user):
     if self.raz_token is None:
     if self.raz_token is None:
       self.raz_token = self.get_delegation_token(user=user)
       self.raz_token = self.get_delegation_token(user=user)
@@ -296,10 +314,7 @@ def get_raz_client(raz_url, username, auth='kerberos', service='s3', service_nam
   if not username:
   if not username:
     raise PopupException('No username set.')
     raise PopupException('No username set.')
 
 
-  if auth == 'kerberos' or True:  # True until JWT option
-    auth_handler = requests_kerberos.HTTPKerberosAuth(mutual_authentication=requests_kerberos.OPTIONAL)
-
-  raz = RazToken(raz_url, auth_handler)
+  raz = RazToken(raz_url, auth)
   raz_token = raz.get_delegation_token(user=username)
   raz_token = raz.get_delegation_token(user=username)
 
 
   return RazClient(raz_url, raz_token, username, service=service, service_name=service_name, cluster_name=cluster_name)
   return RazClient(raz_url, raz_token, username, service=service, service_name=service_name, cluster_name=cluster_name)

+ 64 - 62
desktop/core/src/desktop/lib/raz/raz_client_test.py

@@ -19,10 +19,11 @@ import sys
 import unittest
 import unittest
 
 
 from datetime import timedelta
 from datetime import timedelta
-from nose.tools import assert_equal, assert_false, assert_true, assert_raises
+from nose.tools import assert_equal, assert_true, assert_raises
 
 
 from desktop.conf import RAZ
 from desktop.conf import RAZ
 from desktop.lib.raz.raz_client import RazToken, RazClient, get_raz_client
 from desktop.lib.raz.raz_client import RazToken, RazClient, get_raz_client
+from desktop.lib.exceptions_renderable import PopupException
 
 
 if sys.version_info[0] > 2:
 if sys.version_info[0] > 2:
   from unittest.mock import patch, Mock
   from unittest.mock import patch, Mock
@@ -36,55 +37,60 @@ class RazTokenTest(unittest.TestCase):
     self.username = 'gethue'
     self.username = 'gethue'
 
 
   def test_create(self):
   def test_create(self):
-    kerb_auth = Mock()
+    with patch('desktop.lib.raz.raz_client.requests_kerberos.HTTPKerberosAuth') as HTTPKerberosAuth:
+      token = RazToken(raz_url='https://raz.gethue.com:8080', auth_type='kerberos')
 
 
-    token = RazToken(raz_url='https://raz.gethue.com:8080', auth_handler=kerb_auth)
+      assert_equal('raz.gethue.com', token.raz_hostname)
+      assert_equal('8080', token.raz_port)
+      assert_equal('https', token.scheme)
+      assert_equal('kerberos', token.auth_type)
 
 
-    assert_equal('raz.gethue.com', token.raz_hostname)
-    assert_equal('8080', token.raz_port)
-    assert_equal('https', token.scheme)
 
 
   def test_get_delegation_token(self):
   def test_get_delegation_token(self):
-    kerb_auth = Mock()
+    with patch('desktop.lib.raz.raz_client.requests_kerberos.HTTPKerberosAuth') as HTTPKerberosAuth:
+      with patch('desktop.lib.raz.raz_client.requests.get') as requests_get:
+        with patch('desktop.lib.raz.raz_client.socket.gethostbyname') as gethostbyname:
+          with patch('desktop.lib.raz.raz_client.fetch_jwt') as fetch_jwt:
+            
+            gethostbyname.return_value = '128.0.0.1'
+            requests_get.return_value = Mock(
+              text='{"Token":{"urlString":"f3VLQVkuBCfGSyOLzI9PoxqHTjANUzMgZGVsZWdhdGlvbhExMC44MC4xNjQuMzc6NjA4Mg"}}'
+            )
 
 
-    with patch('desktop.lib.raz.raz_client.requests.get') as requests_get:
-      with patch('desktop.lib.raz.raz_client.socket.gethostbyname') as gethostbyname:
-        requests_get.return_value = Mock(
-          text='{"Token":{"urlString":"https://gethue-test.s3.amazonaws.com/gethue/data/customer.csv?' + \
-                'AWSAccessKeyId=AKIA23E77ZX2HVY76YGL' + \
-                '&Signature=3lhK%2BwtQ9Q2u5VDIqb4MEpoY3X4%3D&Expires=1617207304"}}'
-        )
-        gethostbyname.return_value = '128.0.0.1'
+            # When auth type is Kerberos
+            token = RazToken(raz_url='https://raz.gethue.com:8080', auth_type='kerberos')
+            t = token.get_delegation_token(user=self.username)
 
 
-        token = RazToken(raz_url='https://raz.gethue.com:8080', auth_handler=kerb_auth)
+            fetch_jwt.assert_not_called()
+            assert_equal('f3VLQVkuBCfGSyOLzI9PoxqHTjANUzMgZGVsZWdhdGlvbhExMC44MC4xNjQuMzc6NjA4Mg', t)
 
 
-        t = token.get_delegation_token(user=self.username)
+            # When auth type is JWT
+            fetch_jwt.return_value = 'test_jwt_token'
 
 
-        assert_equal(
-          'https://gethue-test.s3.amazonaws.com/gethue/data/customer.csv?AWSAccessKeyId=AKIA23E77ZX2HVY76YGL&'
-          'Signature=3lhK%2BwtQ9Q2u5VDIqb4MEpoY3X4%3D&Expires=1617207304',
-          t
-        )
+            token = RazToken(raz_url='https://raz.gethue.com:8080', auth_type='jwt')
+            t = token.get_delegation_token(user=self.username)
 
 
-  def test_renew_delegation_token(self):
-    kerb_auth = Mock()
+            fetch_jwt.assert_called()
+            assert_equal('f3VLQVkuBCfGSyOLzI9PoxqHTjANUzMgZGVsZWdhdGlvbhExMC44MC4xNjQuMzc6NjA4Mg', t)
+
+            fetch_jwt.return_value = None # Should raise PopupException
 
 
+            token = RazToken(raz_url='https://raz.gethue.com:8080', auth_type='jwt')
+            assert_raises(PopupException, token.get_delegation_token, user=self.username)
+
+
+  def test_renew_delegation_token(self):
     with patch('desktop.lib.raz.raz_client.requests.get') as requests_get:
     with patch('desktop.lib.raz.raz_client.requests.get') as requests_get:
       with patch('desktop.lib.raz.raz_client.socket.gethostbyname') as gethostbyname:
       with patch('desktop.lib.raz.raz_client.socket.gethostbyname') as gethostbyname:
         requests_get.return_value = Mock(
         requests_get.return_value = Mock(
-          text='{"Token":{"urlString":"https://gethue-test.s3.amazonaws.com/gethue/data/customer.csv?' + \
-                'AWSAccessKeyId=AKIA23E77ZX2HVY76YGL' + \
-                '&Signature=3lhK%2BwtQ9Q2u5VDIqb4MEpoY3X4%3D&Expires=1617207304"}}'
+          text='{"Token":{"urlString":"f3VLQVkuBCfGSyOLzI9PoxqHTjANUzMgZGVsZWdhdGlvbhExMC44MC4xNjQuMzc6NjA4Mg"}}'
         )
         )
         gethostbyname.return_value = '128.0.0.1'
         gethostbyname.return_value = '128.0.0.1'
-        token = RazToken(raz_url='https://raz.gethue.com:8080', auth_handler=kerb_auth)
+        token = RazToken(raz_url='https://raz.gethue.com:8080', auth_type='kerberos')
 
 
         t = token.renew_delegation_token(user=self.username)
         t = token.renew_delegation_token(user=self.username)
 
 
-        assert_equal(t,
-          'https://gethue-test.s3.amazonaws.com/gethue/data/customer.csv?AWSAccessKeyId=AKIA23E77ZX2HVY76YGL&'
-          'Signature=3lhK%2BwtQ9Q2u5VDIqb4MEpoY3X4%3D&Expires=1617207304'
-        )
+        assert_equal(t, 'f3VLQVkuBCfGSyOLzI9PoxqHTjANUzMgZGVsZWdhdGlvbhExMC44MC4xNjQuMzc6NjA4Mg')
 
 
         with patch('desktop.lib.raz.raz_client.requests.put') as requests_put:
         with patch('desktop.lib.raz.raz_client.requests.put') as requests_put:
           token.init_time += timedelta(hours=9)
           token.init_time += timedelta(hours=9)
@@ -107,22 +113,20 @@ class RazClientTest(unittest.TestCase):
 
 
   def test_get_raz_client_adls(self):
   def test_get_raz_client_adls(self):
     with patch('desktop.lib.raz.raz_client.RazToken') as RazToken:
     with patch('desktop.lib.raz.raz_client.RazToken') as RazToken:
-      with patch('desktop.lib.raz.raz_client.requests_kerberos.HTTPKerberosAuth') as HTTPKerberosAuth:
-        client = get_raz_client(
-          raz_url=self.raz_url,
-          username=self.username,
-          auth='kerberos',
-          service='adls',
-          service_name='gethue_adls',
-          cluster_name='gethueCluster'
-        )
+      client = get_raz_client(
+        raz_url=self.raz_url,
+        username=self.username,
+        auth='kerberos',
+        service='adls',
+        service_name='gethue_adls',
+        cluster_name='gethueCluster'
+      )
 
 
-        assert_true(isinstance(client, RazClient))
+      assert_true(isinstance(client, RazClient))
 
 
-        HTTPKerberosAuth.assert_called()
-        assert_equal(client.raz_url, self.raz_url)
-        assert_equal(client.service_name, 'gethue_adls')
-        assert_equal(client.cluster_name, 'gethueCluster')
+      assert_equal(client.raz_url, self.raz_url)
+      assert_equal(client.service_name, 'gethue_adls')
+      assert_equal(client.cluster_name, 'gethueCluster')
 
 
 
 
   def test_check_access_adls(self):
   def test_check_access_adls(self):
@@ -307,22 +311,20 @@ class RazClientTest(unittest.TestCase):
 
 
   def test_get_raz_client_s3(self):
   def test_get_raz_client_s3(self):
     with patch('desktop.lib.raz.raz_client.RazToken') as RazToken:
     with patch('desktop.lib.raz.raz_client.RazToken') as RazToken:
-      with patch('desktop.lib.raz.raz_client.requests_kerberos.HTTPKerberosAuth') as HTTPKerberosAuth:
-        client = get_raz_client(
-          raz_url=self.raz_url,
-          username=self.username,
-          auth='kerberos',
-          service='s3',
-          service_name='gethue_s3',
-          cluster_name='gethueCluster'
-        )
-
-        assert_true(isinstance(client, RazClient))
-
-        HTTPKerberosAuth.assert_called()
-        assert_equal(client.raz_url, self.raz_url)
-        assert_equal(client.service_name, 'gethue_s3')
-        assert_equal(client.cluster_name, 'gethueCluster')
+      client = get_raz_client(
+        raz_url=self.raz_url,
+        username=self.username,
+        auth='kerberos',
+        service='s3',
+        service_name='gethue_s3',
+        cluster_name='gethueCluster'
+      )
+
+      assert_true(isinstance(client, RazClient))
+
+      assert_equal(client.raz_url, self.raz_url)
+      assert_equal(client.service_name, 'gethue_s3')
+      assert_equal(client.cluster_name, 'gethueCluster')
 
 
 
 
   def test_check_access_s3(self):
   def test_check_access_s3(self):

+ 15 - 0
desktop/core/src/desktop/lib/sdxaas/__init__.py

@@ -0,0 +1,15 @@
+# Licensed to Cloudera, Inc. under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  Cloudera, Inc. licenses this file
+# to you 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.

+ 75 - 0
desktop/core/src/desktop/lib/sdxaas/knox_jwt.py

@@ -0,0 +1,75 @@
+# Licensed to Cloudera, Inc. under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  Cloudera, Inc. licenses this file
+# to you 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
+import logging
+import requests
+import requests_kerberos
+
+from desktop.conf import SDXAAS
+from desktop.lib.exceptions_renderable import PopupException
+
+LOG = logging.getLogger(__name__)
+
+_KNOX_TOKEN_API = '/knoxtoken/api/v1/token'
+_KNOX_TOKEN_GET_PARAM_STRING = '?knox.token.include.groups=true'
+
+
+def handle_knox_ha():
+  knox_urls = SDXAAS.TOKEN_URL.get()
+  auth_handler = requests_kerberos.HTTPKerberosAuth(mutual_authentication=requests_kerberos.OPTIONAL)
+  res = None
+
+  if knox_urls:
+    # Config format is "['url1', 'url2']" for HA, so we need to clean up and split correctly in list.
+    # For non-HA, its normal url string.
+    knox_urls_list = knox_urls.replace("%20", "").replace("['", "").replace("']", "").replace("'", "").split(',')
+
+    for k_url in knox_urls_list:
+      try:
+        res = requests.get(k_url.rstrip('/') + _KNOX_TOKEN_API, auth=auth_handler, verify=False)
+      except Exception as e:
+        if 'Name or service not known' in str(e):
+          LOG.warning('Knox URL %s is not available.' % k_url)
+
+      # Check response for None and if response code is successful (200) or authentication needed (401), use that host URL.
+      if (res is not None) and (res.status_code in (200, 401)):
+        return k_url
+
+
+def fetch_jwt():
+  '''
+  Return JWT fetched from healthy Knox host.
+  '''
+  knox_url = handle_knox_ha()
+  if not knox_url:
+    raise PopupException('Knox URL not available to fetch JWT.')
+
+  auth_handler = requests_kerberos.HTTPKerberosAuth(mutual_authentication=requests_kerberos.OPTIONAL)
+  knox_response = None
+
+  try:
+    LOG.debug('Fetching Knox JWT from URL: %s' % knox_url)
+    knox_response = requests.get(knox_url.rstrip('/') + _KNOX_TOKEN_API + _KNOX_TOKEN_GET_PARAM_STRING, auth=auth_handler, verify=False)
+  except Exception as e:
+    raise Exception('Error fetching JWT from Knox URL %s with exception: %s' % (knox_url, str(e)))
+
+  jwt_token = None
+  if knox_response:
+    jwt_token = json.loads(knox_response.text)['access_token']
+    LOG.debug('Retrieved Knox JWT: %s' % jwt_token)
+
+  return jwt_token

+ 88 - 0
desktop/core/src/desktop/lib/sdxaas/knox_jwt_test.py

@@ -0,0 +1,88 @@
+# Licensed to Cloudera, Inc. under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  Cloudera, Inc. licenses this file
+# to you 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 sys
+import unittest
+
+from nose.tools import assert_equal, assert_raises
+
+from desktop.conf import SDXAAS
+from desktop.lib.sdxaas.knox_jwt import handle_knox_ha, fetch_jwt
+from desktop.lib.exceptions_renderable import PopupException
+
+if sys.version_info[0] > 2:
+  from unittest.mock import patch, Mock
+else:
+  from mock import patch, Mock
+
+
+def test_handle_knox_ha():
+  with patch('desktop.lib.sdxaas.knox_jwt.requests_kerberos.HTTPKerberosAuth') as HTTPKerberosAuth:
+    with patch('desktop.lib.sdxaas.knox_jwt.requests.get') as requests_get:
+
+      requests_get.return_value = Mock(status_code=200)
+
+      # Non-HA mode
+      reset = SDXAAS.TOKEN_URL.set_for_testing('https://knox-gateway0.gethue.com:8443/dl-name/kt-kerberos/')
+
+      try:
+        knox_url = handle_knox_ha()
+        assert_equal(knox_url, 'https://knox-gateway0.gethue.com:8443/dl-name/kt-kerberos/')
+      finally:
+        reset()
+
+      # HA mode - where first URL sends 200 status code
+      reset = SDXAAS.TOKEN_URL.set_for_testing(
+        'https://knox-gateway0.gethue.com:8443/dl-name/kt-kerberos/, https://knox-gateway1.gethue.com:8443/dl-name/kt-kerberos/')
+
+      try:
+        knox_url = handle_knox_ha()
+        assert_equal(knox_url, 'https://knox-gateway0.gethue.com:8443/dl-name/kt-kerberos/')
+      finally:
+        reset()
+
+      # When no Knox URL is healthy
+      requests_get.return_value = Mock(status_code=404)
+      reset = SDXAAS.TOKEN_URL.set_for_testing(
+        'https://knox-gateway0.gethue.com:8443/dl-name/kt-kerberos/, https://knox-gateway1.gethue.com:8443/dl-name/kt-kerberos/')
+
+      try:
+        knox_url = handle_knox_ha()
+        assert_equal(knox_url, None)
+      finally:
+        reset()
+
+
+def test_fetch_jwt():
+  with patch('desktop.lib.sdxaas.knox_jwt.requests_kerberos.HTTPKerberosAuth') as HTTPKerberosAuth:
+    with patch('desktop.lib.sdxaas.knox_jwt.requests.get') as requests_get:
+      with patch('desktop.lib.sdxaas.knox_jwt.handle_knox_ha') as handle_knox_ha:
+
+        handle_knox_ha.return_value = 'https://knox-gateway.gethue.com:8443/dl-name/kt-kerberos/'
+        requests_get.return_value = Mock(text='{"access_token":"test_jwt_token"}')
+
+        jwt_token = fetch_jwt()
+
+        requests_get.assert_called_with(
+          'https://knox-gateway.gethue.com:8443/dl-name/kt-kerberos/knoxtoken/api/v1/token?knox.token.include.groups=true', 
+          auth=HTTPKerberosAuth(), 
+          verify=False
+        )
+        assert_equal(jwt_token, "test_jwt_token")
+
+        # Raises PopupException when knox_url is not available
+        handle_knox_ha.return_value = None
+        assert_raises(PopupException, fetch_jwt)