From e5c47eb2c9f671a03bc5387785c8e75e1106c6dc Mon Sep 17 00:00:00 2001 From: Rohan Weeden Date: Thu, 26 Dec 2024 11:48:22 -0900 Subject: [PATCH 1/2] Shortcut EDL bearer token authentication of token is a JWT --- requirements/requirements.in | 3 ++- requirements/requirements.txt | 2 +- thin_egress_app/app.py | 41 +++++++++++++++++++++++++++++++++-- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/requirements/requirements.in b/requirements/requirements.in index 0da9b612..f28037cd 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -2,5 +2,6 @@ cachetools cfnresponse chalice -git+https://github.com/asfadmin/rain-api-core.git@f5186c00c8e9d576f710eac62e6ca1e51516d6d7 +git+https://github.com/asfadmin/rain-api-core.git@rew/pr-3242-reduce-edl-calls netaddr +pyjwt diff --git a/requirements/requirements.txt b/requirements/requirements.txt index ab8a9f7f..e806ca86 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -48,7 +48,7 @@ pyyaml==6.0.2 # via # chalice # rain-api-core -rain-api-core @ git+https://github.com/asfadmin/rain-api-core.git@f5186c00c8e9d576f710eac62e6ca1e51516d6d7 +rain-api-core @ git+https://github.com/asfadmin/rain-api-core.git@rew/pr-3242-reduce-edl-calls # via -r requirements/requirements.in readchar==4.2.1 # via inquirer diff --git a/thin_egress_app/app.py b/thin_egress_app/app.py index d314b447..d1db3468 100644 --- a/thin_egress_app/app.py +++ b/thin_egress_app/app.py @@ -13,6 +13,7 @@ import boto3 import cachetools import chalice +import jwt from botocore.config import Config as bc_Config from botocore.exceptions import ClientError from cachetools.func import ttl_cache @@ -48,6 +49,7 @@ def inject(obj): from rain_api_core.urs_util import ( do_login, get_new_token_and_profile, + get_profile, get_urs_creds, get_urs_url, user_in_group, @@ -185,6 +187,9 @@ def get_profile(self) -> Optional[UserProfile]: self._response = do_auth_and_return(app.current_request.context) return None + # TODO(reweeden): Causes internal server error if auth header set + # withouth the 'method portion' such as + # Authorization: method, token, *_ = authorization.split() method = method.lower() @@ -215,12 +220,18 @@ 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 + 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. :param token: :return: action, data """ + 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: @@ -300,6 +311,32 @@ def check_for_browser(hdrs): return "user-agent" in hdrs and hdrs["user-agent"].lower().startswith("mozilla") +@with_trace() +def get_profile_with_jwt_bearer(token): + try: + # TODO(reweeden): We could verify with the EDL pub key here to + # potentially save an extra call to EDL on expired or invalid tokens. + + # We don't need to verify the signature as EDL will do this for us + # 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") + + if user_id is None: + return None + + 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): """ From 1b039b2d67cfdba859adf932c8f43ec5a5d92e10 Mon Sep 17 00:00:00 2001 From: Rohan Weeden Date: Wed, 8 Jan 2025 12:59:07 -0500 Subject: [PATCH 2/2] Remove legacy token handling --- thin_egress_app/app.py | 174 ++++++++++++----------------------------- 1 file changed, 51 insertions(+), 123 deletions(-) diff --git a/thin_egress_app/app.py b/thin_egress_app/app.py index d1db3468..5346a055 100644 --- a/thin_egress_app/app.py +++ b/thin_egress_app/app.py @@ -6,9 +6,7 @@ import urllib.request from functools import wraps from typing import Optional -from urllib import request -from urllib.error import HTTPError -from urllib.parse import quote_plus, urlencode, urlparse +from urllib.parse import quote_plus, urlparse import boto3 import cachetools @@ -38,6 +36,7 @@ def inject(obj): retrieve_secret, ) from rain_api_core.bucket_map import BucketMap +from rain_api_core.edl import EdlClient, EdlException, EulaException from rain_api_core.egress_util import get_bucket_name_prefix, get_presigned_url from rain_api_core.general_util import ( duration, @@ -48,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 @@ -166,11 +164,6 @@ class TeaException(Exception): """ base exception for TEA """ -class EulaException(TeaException): - def __init__(self, payload: dict): - self.payload = payload - - class RequestAuthorizer: def __init__(self): self._response = None @@ -220,45 +213,63 @@ 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) + 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.payload["error_description"], - "status_code": 403, + "title": e.msg["error_description"], + "status_code": status_code, "contentstring": ( - f'Could not fetch data because "{e.payload["error_description"]}". Please accept EULA here: ' - f'{e.payload["resolution_url"]} and try again.' + f'Could not fetch data because "{e.msg["error_description"]}". Please accept EULA here: ' + f'{e.msg["resolution_url"]} and try again.' ), "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.payload, status_code=403, headers={}) + response = Response( + body={ + "error": e.msg["error"], + "status_code": status_code, + "error_description": e.msg["error_description"], + "resolution_url": e.msg["resolution_url"], + }, + 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(), + } - if user_id: - log_context(user_id=user_id) - aux_headers = get_aux_request_headers() - user_profile = get_new_token_and_profile(user_id, True, aux_headers=aux_headers) + response = make_html_response(template_vars, {}, status_code, "error.html") + else: + response = Response(body=e.msg, status_code=status_code, headers={}) + return None, response if user_profile is None: response = do_auth_and_return(app.current_request.context) @@ -321,7 +332,7 @@ 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) + log.error("Unable to decode jwt bearer token: %s", e) return None user_id = claims.get("uid") @@ -331,100 +342,17 @@ def get_profile_with_jwt_bearer(token): 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 + headers = {"Authorization": "Bearer " + token} + headers.update(aux_headers) - :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 - } - - url = "{}/oauth/tokens/user?{}".format( - os.getenv("AUTH_BASE_URL", "https://urs.earthdata.nasa.gov"), - urlencode(params) + client = EdlClient() + user_profile = client.request( + "GET", + f"/api/users/{user_id}", + params={"client_id": get_urs_creds()["UrsId"]}, + headers=headers, ) - - authval = f"Basic {urs_creds['UrsAuth']}" - headers = {"Authorization": authval} - - # Tack on auxillary headers - headers.update(get_aux_request_headers()) - log.debug("headers: %s, params: %s", headers, params) - - _time = time.time() - - req = request.Request(url, headers=headers, method="POST") - try: - response = request.urlopen(req) - except HTTPError as e: - response = e - log.debug("%s", e) - - payload = response.read() - log.info(return_timing_object(service="EDL", endpoint=url, method="POST", duration=duration(_time))) - - try: - msg = json.loads(payload) - except json.JSONDecodeError: - log.error("could not get json message from payload: %s", payload) - msg = {} - - log.debug("raw payload: %s", payload) - log.debug("json loads: %s", msg) - log.debug("code: %s", response.code) - - if response.code == 200: - try: - return msg["uid"] - except KeyError as e: - log.error( - "Problem with return from URS: e: %s, url: %s, params: %s, response payload: %s", - e, - url, - params, - payload, - ) - return None - elif response.code == 403: - if "error_description" in msg and "eula" in msg["error_description"].lower(): - # sample json in this case: - # `{"status_code": 403, "error_description": "EULA Acceptance Failure", - # "resolution_url": "http://uat.urs.earthdata.nasa.gov/approve_app?client_id=LqWhtVpLmwaD4VqHeoN7ww"}` - log.warning("user needs to sign the EULA") - raise EulaException(msg) - # Probably an expired token if here - log.warning("403 error from URS: %s", msg) - else: - if "error" in msg: - errtxt = msg["error"] - else: - errtxt = "" - if "error_description" in msg: - errtxt = errtxt + " " + msg["error_description"] - - log.error( - "Error getting URS userid from token: %s with code %s", - errtxt, - response.code, - ) - log.debug("url: %s, params: %s", url, params) - return None + return get_user_profile(user_profile, token) @with_trace()