From a1023e9d7fb8a6fdfa2dbce3ea5091804f066074 Mon Sep 17 00:00:00 2001 From: Rohan Weeden Date: Wed, 8 Jan 2025 12:59:07 -0500 Subject: [PATCH] Remove legacy token handling --- tests/conftest.py | 20 +++++ tests/test_app.py | 174 +++++++++++++++++++++++++++-------------- thin_egress_app/app.py | 129 ++++++++++++------------------ 3 files changed, 184 insertions(+), 139 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 9cc145bf..4df04cf8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ import logging import os +import textwrap from pathlib import Path import boto3 @@ -26,6 +27,25 @@ def aws_credentials(): os.environ["AWS_DEFAULT_REGION"] = "us-east-1" +@pytest.fixture(scope="session") +def private_key(): + """Used for signing fake JWTs in unit tests""" + return textwrap.dedent( + """ + -----BEGIN PRIVATE KEY----- + MIIBVQIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEAzXKhtCOkUvA5POUW + ddN8G0wHTQfQg6wp7NXmID8AW0FMU5ZOhAl0l1dGWs9U83C4IA8Hqbpe/XbY8CuT + SOEWUwIDAQABAkAU5/5Wg238Vp+sd69ybAPsDy+LAimQzJszk4yoaWDS6EI1DcBV + npb7lFvGCcnUe57Lm6DhWD1EnDYhD451VrYRAiEA/8z3pXYDBSJoWA79xrsq2cze + 4oYwhTWtzueU8+Mlf0kCIQDNm55qR00BUbhBO/tTP2VCC2OQd/v9I+UIcwIL9Wg8 + uwIhAOpwUAe1QM9T2Y3bL3sTzxIOUbgKhC2SJNmcJUfgxl0BAiEAq+nbageV9m1q + v3i0qqWON8uoAyqfkshJf2gSJQebkXMCIFUVSih1R6FqkoP2HFOZvJiRQnfO6shL + jrhQOs2SXP05 + -----END PRIVATE KEY----- + """, + ) + + @pytest.fixture(scope="session") def data_path(): return Path(__file__).parent.joinpath("data").resolve() diff --git a/tests/test_app.py b/tests/test_app.py index bd882355..53ffb2d5 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,10 +1,13 @@ import base64 import contextlib import io +import json +import time from unittest import mock from urllib.error import HTTPError import chalice +import jwt import pytest import yaml from botocore.exceptions import ClientError @@ -41,6 +44,25 @@ def user_profile(): ) +@pytest.fixture +def bearer_token(private_key): + return jwt.encode( + { + "type": "User", + "uid": "test_user", + "exp": int(time.time()) + 50_000, + "iat": int(time.time()), + "iss": "https://uat.urs.earthdata.nasa.gov", + "identity_provider": "edl_uat", + "acr": "edl", + "assurance_level": 3, + }, + private_key, + headers={"origin": "Earthdata Login", "sig": "edlfakepubkey_uat"}, + algorithm="RS256", + ) + + @pytest.fixture def _clear_caches(): app.get_bc_config_client.cache.clear() @@ -134,11 +156,9 @@ def test_request_authorizer_no_headers(current_request, mock_get_urs_url): assert authorizer.get_success_response_headers() == {} -@mock.patch(f"{MODULE}.get_user_from_token", autospec=True) -@mock.patch(f"{MODULE}.get_new_token_and_profile", autospec=True) +@mock.patch(f"{MODULE}.get_profile_with_jwt_bearer", autospec=True) def test_request_authorizer_bearer_header( - mock_get_new_token_and_profile, - mock_get_user_from_token, + mock_get_profile_with_jwt_bearer, _clear_caches, current_request, ): @@ -147,20 +167,12 @@ def test_request_authorizer_bearer_header( "x-origin-request-id": "origin_request_id" } mock_user_profile = mock.Mock() - mock_get_new_token_and_profile.return_value = mock_user_profile - mock_get_user_from_token.return_value = "user_name" + mock_get_profile_with_jwt_bearer.return_value = mock_user_profile authorizer = app.RequestAuthorizer() assert authorizer.get_profile() == mock_user_profile - mock_get_new_token_and_profile.assert_called_once_with( - "user_name", - True, - aux_headers={ - "x-request-id": "request_1234", - "x-origin-request-id": "origin_request_id" - } - ) + mock_get_profile_with_jwt_bearer.assert_called_once_with("token") @mock.patch(f"{MODULE}.do_auth_and_return", autospec=True) @@ -182,17 +194,22 @@ def test_request_authorizer_basic_header( assert authorizer.get_error_response() == mock_response -@mock.patch(f"{MODULE}.get_user_from_token", autospec=True) +@mock.patch(f"{MODULE}.get_profile_with_jwt_bearer", autospec=True) def test_request_authorizer_bearer_header_eula_error( - mock_get_user_from_token, + mock_get_profile_with_jwt_bearer, _clear_caches, current_request, ): current_request.headers = {"Authorization": "Bearer token"} - mock_get_user_from_token.side_effect = EulaException( + mock_get_profile_with_jwt_bearer.side_effect = EulaException( HTTPError("", 403, "Forbidden", {}, io.StringIO()), - {}, - "", + { + "status_code": 403, + "error": "invalid_token", + "error_description": "EULA Acceptance Failure", + "resolution_url": "http://example", + }, + None, ) authorizer = app.RequestAuthorizer() @@ -204,9 +221,9 @@ def test_request_authorizer_bearer_header_eula_error( assert response.headers == {} -@mock.patch(f"{MODULE}.get_user_from_token", autospec=True) +@mock.patch(f"{MODULE}.get_profile_with_jwt_bearer", autospec=True) def test_request_authorizer_bearer_header_eula_error_browser( - mock_get_user_from_token, + mock_get_profile_with_jwt_bearer, mock_make_html_response, _clear_caches, current_request, @@ -220,7 +237,7 @@ def test_request_authorizer_bearer_header_eula_error_browser( "error_description": "EULA Acceptance Failure", "resolution_url": "http://resolution_url", } - mock_get_user_from_token.side_effect = EulaException( + mock_get_profile_with_jwt_bearer.side_effect = EulaException( HTTPError("", 403, "Forbidden", {}, None), msg, None, @@ -249,13 +266,11 @@ def test_request_authorizer_bearer_header_eula_error_browser( assert response.headers == {"Content-Type": "text/html"} -@mock.patch(f"{MODULE}.get_user_from_token", autospec=True) -@mock.patch(f"{MODULE}.get_new_token_and_profile", autospec=True) +@mock.patch(f"{MODULE}.get_profile_with_jwt_bearer", autospec=True) @mock.patch(f"{MODULE}.do_auth_and_return", autospec=True) -def test_request_authorizer_bearer_header_no_profile( +def test_request_authorizer_bearer_header_other_error( mock_do_auth_and_return, - mock_get_new_token_and_profile, - mock_get_user_from_token, + mock_get_profile_with_jwt_bearer, _clear_caches, current_request, ): @@ -265,29 +280,21 @@ def test_request_authorizer_bearer_header_no_profile( } mock_response = mock.Mock() mock_do_auth_and_return.return_value = mock_response - mock_get_new_token_and_profile.return_value = None - mock_get_user_from_token.return_value = "user_name" + mock_get_profile_with_jwt_bearer.side_effect = Exception("test exception") authorizer = app.RequestAuthorizer() assert authorizer.get_profile() is None assert authorizer.get_error_response() == mock_response - mock_get_new_token_and_profile.assert_called_once_with( - "user_name", - True, - aux_headers={ - "x-request-id": "request_1234", - "x-origin-request-id": "origin_request_id" - } - ) + mock_get_profile_with_jwt_bearer.assert_called_once_with("token") mock_do_auth_and_return.assert_called_once_with(current_request.context) -@mock.patch(f"{MODULE}.get_user_from_token", autospec=True) +@mock.patch(f"{MODULE}.get_profile_with_jwt_bearer", autospec=True) @mock.patch(f"{MODULE}.do_auth_and_return", autospec=True) def test_request_authorizer_bearer_header_no_user_id( mock_do_auth_and_return, - mock_get_user_from_token, + mock_get_profile_with_jwt_bearer, _clear_caches, current_request, ): @@ -297,7 +304,7 @@ def test_request_authorizer_bearer_header_no_user_id( } mock_response = mock.Mock() mock_do_auth_and_return.return_value = mock_response - mock_get_user_from_token.side_effect = EdlException(KeyError("uid"), {}, None) + mock_get_profile_with_jwt_bearer.side_effect = KeyError("uid") authorizer = app.RequestAuthorizer() @@ -341,37 +348,67 @@ def test_check_for_browser(): assert app.check_for_browser({"user-agent": "Not a valid user agent"}) is False -def test_get_user_from_token(mock_request, mock_get_urs_creds, current_request): +def test_get_profile_with_jwt_bearer( + mock_request, + mock_get_urs_creds, + bearer_token, + current_request, +): del current_request - payload = '{"uid": "user_name"}' + payload = json.dumps({ + "uid": "test_user", + "user_groups": [], + "first_name": "John", + "last_name": "Smith", + "email_address": "j.smith@email.com", + }) mock_response = mock.MagicMock() with mock_response as mock_f: mock_f.read.return_value = payload mock_response.code = 200 mock_request.urlopen.return_value = mock_response - assert app.get_user_from_token("token") == "user_name" + profile = app.get_profile_with_jwt_bearer(bearer_token) + assert profile.user_id == "test_user" + assert profile.token == bearer_token mock_get_urs_creds.assert_called_once() -def test_get_user_from_token_eula_error(mock_request, mock_get_urs_creds, current_request): +def test_get_profile_with_jwt_bearer_eula_error( + mock_request, + mock_get_urs_creds, + bearer_token, + current_request, +): del current_request payload = """{ "status_code": 403, + "error": "invalid_token", "error_description": "EULA Acceptance Failure", "resolution_url": "http://uat.urs.earthdata.nasa.gov/approve_app?client_id=asdf" } """ - mock_request.urlopen.side_effect = HTTPError("", 403, "Forbidden", {}, io.StringIO(payload)) + mock_request.urlopen.side_effect = HTTPError( + "", + 403, + "Forbidden", + {}, + io.StringIO(payload), + ) with pytest.raises(EulaException): - app.get_user_from_token("token") + app.get_profile_with_jwt_bearer(bearer_token) mock_get_urs_creds.assert_called_once() -def test_get_user_from_token_other_error(mock_request, mock_get_urs_creds, current_request): +def test_get_profile_with_jwt_bearer_other_error( + mock_request, + mock_get_urs_creds, + bearer_token, + current_request, +): del current_request payload = """{ @@ -380,21 +417,39 @@ def test_get_user_from_token_other_error(mock_request, mock_get_urs_creds, curre "error_description": "some error description" } """ - mock_request.urlopen.side_effect = HTTPError("", 401, "Bad Request", {}, io.StringIO(payload)) + mock_request.urlopen.side_effect = HTTPError( + "", + 401, + "Bad Request", + {}, + io.StringIO(payload), + ) with pytest.raises(EdlException): - assert app.get_user_from_token("token") + assert app.get_profile_with_jwt_bearer(bearer_token) mock_get_urs_creds.assert_called_once() @pytest.mark.parametrize("code", (200, 403, 500)) -def test_get_user_from_token_json_error(mock_request, mock_get_urs_creds, current_request, code): +def test_get_profile_with_jwt_bearer_json_error( + mock_request, + mock_get_urs_creds, + bearer_token, + current_request, + code, +): del current_request - mock_request.urlopen.side_effect = HTTPError("", code, "Message", {}, io.StringIO("not valid json")) + mock_request.urlopen.side_effect = HTTPError( + "", + code, + "Message", + {}, + io.StringIO("not valid json"), + ) with pytest.raises(EdlException): - assert app.get_user_from_token("token") + assert app.get_profile_with_jwt_bearer(bearer_token) is not None mock_get_urs_creds.assert_called_once() @@ -1553,8 +1608,7 @@ def test_dynamic_url_directory( @mock.patch(f"{MODULE}.get_yaml_file", autospec=True) @mock.patch(f"{MODULE}.get_api_request_uuid", autospec=True) @mock.patch(f"{MODULE}.try_download_from_bucket", autospec=True) -@mock.patch(f"{MODULE}.get_user_from_token", autospec=True) -@mock.patch(f"{MODULE}.get_new_token_and_profile", autospec=True) +@mock.patch(f"{MODULE}.get_profile_with_jwt_bearer", autospec=True) @mock.patch(f"{MODULE}.JwtManager.get_profile_from_headers", autospec=True) @mock.patch(f"{MODULE}.JwtManager.get_header_to_set_auth_cookie", autospec=True) @mock.patch(f"{MODULE}.JWT_COOKIE_NAME", "asf-cookie") @@ -1562,8 +1616,7 @@ def test_dynamic_url_directory( def test_dynamic_url_bearer_auth( mock_get_header_to_set_auth_cookie, mock_get_profile_from_headers, - mock_get_new_token_and_profile, - mock_get_user_from_token, + mock_get_profile_with_jwt_bearer, mock_try_download_from_bucket, mock_get_api_request_uuid, mock_get_yaml_file, @@ -1571,9 +1624,12 @@ def test_dynamic_url_bearer_auth( user_profile, current_request, ): - mock_try_download_from_bucket.return_value = chalice.Response(body="Mock response", headers={}, status_code=200) - mock_get_new_token_and_profile.return_value = user_profile - mock_get_user_from_token.return_value = user_profile.user_id + mock_try_download_from_bucket.return_value = chalice.Response( + body="Mock response", + headers={}, + status_code=200, + ) + mock_get_profile_with_jwt_bearer.return_value = user_profile mock_get_header_to_set_auth_cookie.return_value = {"SET-COOKIE": "cookie"} with open(data_path / "bucket_map_example.yaml") as f: mock_get_yaml_file.return_value = yaml.full_load(f) diff --git a/thin_egress_app/app.py b/thin_egress_app/app.py index 7fc2081e..26164bba 100644 --- a/thin_egress_app/app.py +++ b/thin_egress_app/app.py @@ -47,10 +47,9 @@ def inject(obj): from rain_api_core.timer import Timer from rain_api_core.urs_util import ( do_login, - get_new_token_and_profile, - get_profile, get_urs_creds, get_urs_url, + get_user_profile, user_in_group, ) from rain_api_core.view_util import TemplateManager @@ -214,36 +213,25 @@ def get_profile(self) -> Optional[UserProfile]: ) def _get_profile_and_response_from_bearer(self, token): """ - Will handle the output from get_user_from_token in context of a chalice - function. If user_id is determined, returns it. If user_id is not - determined returns data to be returned. + Get user profile from EDL using a bearer token. If the profile can't be + fetched, the response value will be set to a chalice Response object to + be returned by the route handler. :param token: - :return: action, data + :return: user_profile, response """ - profile = get_profile_with_jwt_bearer(token) - if profile is not None: - log.debug("Shortcut profile fetching by using the users bearer token directly") - return profile - user_profile = None response = None try: - user_id = get_user_from_token(token) - log_context(user_id=user_id) - - user_profile = get_new_token_and_profile( - user_id, - True, - aux_headers=get_aux_request_headers(), - ) + user_profile = get_profile_with_jwt_bearer(token) except EulaException as e: log.warning("user has not accepted EULA") + status_code = 403 # TODO(reweeden): changing the response based on user agent looks like a really bad idea... if check_for_browser(app.current_request.headers): template_vars = { "title": e.msg["error_description"], - "status_code": 403, + "status_code": status_code, "contentstring": ( f'Could not fetch data because "{e.msg["error_description"]}". Please accept EULA here: ' f'{e.msg["resolution_url"]} and try again.' @@ -251,11 +239,37 @@ def _get_profile_and_response_from_bearer(self, token): "requestid": get_request_id(), } - response = make_html_response(template_vars, {}, 403, "error.html") + response = make_html_response(template_vars, {}, status_code, "error.html") + else: + response = Response( + body={ + **e.msg, + "status_code": status_code, + }, + status_code=status_code, + headers={}, + ) + return None, response + except EdlException as e: + log.warning("EDL responded with %s", e) + status_code = e.inner.status_code + if check_for_browser(app.current_request.headers): + template_vars = { + "title": e.msg["error_description"], + "status_code": status_code, + "contentstring": ( + f'Could not fetch data because "{e.msg["error_description"]}".' + f"Full error: {e.msg}" + ), + "requestid": get_request_id(), + } + + response = make_html_response(template_vars, {}, status_code, "error.html") else: - response = Response(body=e.payload, status_code=403, headers={}) + response = Response(body=e.msg, status_code=status_code, headers={}) return None, response - except EdlException: + except Exception as e: + log.warning("Failed to get user profile %s: %s", e.__class__.__name__, e) user_profile = None if user_profile is None: @@ -319,69 +333,24 @@ def get_profile_with_jwt_bearer(token): # anyway in the call to `get_profile`. claims = jwt.decode(token, options={"verify_signature": False}) except jwt.DecodeError as e: - log.error("Unable to verify jwt bearer token: %s", e) - return None - - user_id = claims.get("uid") + log.error("Unable to decode jwt bearer token: %s", e) + raise - if user_id is None: - return None + user_id = claims["uid"] log_context(user_id=user_id) aux_headers = get_aux_request_headers() - params = { - "client_id": get_urs_creds()["UrsId"], - } - return get_profile(user_id, "fake-token", token, aux_headers, params) - - -@with_trace() -def get_user_from_token(token): - """ - This may be moved to rain-api-core.urs_util.py once things stabilize. - Will query URS for user ID of requesting user based on token sent with request - - :param token: token received in request for data - :return: user ID of requesting user. - """ - - urs_creds = get_urs_creds() - - params = { - "client_id": urs_creds["UrsId"], - # The client_id of the non SSO application you registered with Earthdata Login - "token": token - } - - authval = f"Basic {urs_creds['UrsAuth']}" - headers = {"Authorization": authval} - - # Tack on auxillary headers - headers.update(get_aux_request_headers()) + headers = {"Authorization": "Bearer " + token} + headers.update(aux_headers) client = EdlClient() - try: - msg = client.request( - "POST", - "/oauth/tokens/user", - params=params, - headers=headers, - ) - - user_id = msg.get("uid") - if user_id is None: - log.error("Problem with return from URS: msg: %s", msg) - raise EdlException(KeyError("uid"), msg, None) - return user_id - except EulaException: - raise - except EdlException as e: - log.error( - "Error getting URS userid from token: %s, response: %s", - e.inner, - e.payload, - ) - raise + user_profile = client.request( + "GET", + f"/api/users/{user_id}", + params={"client_id": get_urs_creds()["UrsId"]}, + headers=headers, + ) + return get_user_profile(user_profile, token) @with_trace()