Skip to content

Commit 6359dbc

Browse files
authored
Merge pull request #2814 from ASFHyP3/develop
Release v10.9.0
2 parents 1a0cca5 + cbd5800 commit 6359dbc

File tree

8 files changed

+140
-54
lines changed

8 files changed

+140
-54
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [10.9.0]
8+
9+
### Added
10+
- Support for API authentication via [Earthdata Login bearer tokens](https://urs.earthdata.nasa.gov/documentation/for_users/user_token)
11+
712
## [10.8.0]
813

914
### Added

README.md

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -400,30 +400,22 @@ To delete a HyP3 deployment, delete any of the resources created above that are
400400
Before deleting the HyP3 CloudFormation stack,
401401
you should manually empty and delete the `contentbucket` and `logbucket` for the deployment via the S3 console.
402402
403-
## Running the API Locally
403+
## Running the API locally
404404
405-
The API can be run locally to verify changes, but must be connected to a set of existing DynamoDB tables.
405+
The API can be run locally for testing and development purposes:
406406
407-
1. Set up AWS credentials in your environment as described
407+
1. Choose an existing HyP3 deployment that you want to connect to.
408+
2. Set up credentials for the corresponding AWS account as described
408409
[here](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/quickstart.html#configuration).
409-
Also see our [wiki page](https://github.com/ASFHyP3/.github-private/wiki/AWS-Access#aws-access-keys).
410-
2. Edit `tests/cfg.env` to specify the names of existing DynamoDB tables from a particular HyP3 deployment.
410+
Also see our [developer docs article](https://github.com/ASFHyP3/.github-private/blob/main/docs/AWS-Access.md#aws-access-keys).
411+
3. If you haven't already, follow the [Developer Setup](#developer-setup) section to clone this repo and activate your conda environment.
412+
4. Edit your local copy of [`tests/cfg.env`](./tests/cfg.env) to specify the names of the DynamoDB tables from the HyP3 deployment.
411413
Delete all of the `AWS_*` variables.
412-
3. Run the API (replace `<profile>` with the AWS config profile that corresponds to the HyP3 deployment):
414+
5. Run the following command, replacing `<profile>` with the AWS config profile that corresponds to the HyP3 deployment:
413415
```sh
414416
AWS_PROFILE=<profile> make run
415417
```
416-
4. You should see something like `Running on http://127.0.0.1:8080` in the output. Open the host URL in your browser.
417-
You should see the Swagger UI for the locally running API.
418-
5. In Chrome or Chromium, from the Swagger UI tab, open Developer tools, select the Application tab, then select
419-
the host URL under Cookies. In Firefox, open Web Developer Tools, select the Storage tab, then select
420-
the host URL under Cookies. Add a new cookie with the following Name:
421-
```
422-
asf-urs
423-
```
424-
And the following Value:
425-
```
426-
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1cnMtdXNlci1pZCI6InVzZXIiLCJleHAiOjIxNTk1Mzc0OTYyLCJ1cnMtZ3JvdXBzIjpbeyJuYW1lIjoiYXV0aC1ncm91cCIsImFwcF91aWQiOiJhdXRoLXVpZCJ9XX0.hMtgDTqS5wxDPCzK9MlXB-3j6MAcGYeSZjGf4SYvq9Y
427-
```
428-
And `/` for Path.
429-
6. To verify access, query the `GET /user` endpoint and verify that the response includes `"user_id": "user"`.
418+
6. You should see something like `Running on http://127.0.0.1:8080` in the output.
419+
Open the URL in your browser and verify that you see the Swagger UI for the locally running API.
420+
7. Click the "Authorize" button in the upper right and input your [Earthdata Login bearer token](https://urs.earthdata.nasa.gov/documentation/for_users/user_token).
421+
8. To verify access, query the `GET /user` endpoint and verify that it returns the correct information for your username.

apps/api/src/hyp3_api/api-spec/openapi-spec.yml.j2

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ info:
55
version: {{ api_version }}
66

77
security:
8+
- BearerAuth: []
89
- EarthDataLogin: []
910

1011
{% if security_environment == 'EDC' %}
@@ -477,6 +478,13 @@ components:
477478
example: 50
478479

479480
securitySchemes:
481+
BearerAuth:
482+
description: |-
483+
Authentication via Earthdata Login Bearer Tokens; https://urs.earthdata.nasa.gov/documentation/for_users/user_token
484+
type: http
485+
scheme: bearer
486+
bearerFormat: JWT
487+
480488
EarthDataLogin:
481489
description: |-
482490
Authentication requires the user to have an account at urs.earthdata.nasa.gov and log in at auth.asf.alaska.edu

apps/api/src/hyp3_api/auth.py

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,28 @@
1-
import time
21
from os import environ
32

43
import jwt
54

65

7-
def decode_token(token: str | None) -> dict | None:
8-
if token is None:
9-
return None
6+
class InvalidTokenException(Exception):
7+
"""Raised when authorization token cannot be decoded."""
8+
9+
10+
def decode_edl_bearer_token(token: str, jwks_client: jwt.PyJWKClient) -> tuple[str, str]:
11+
signing_key = jwks_client.get_signing_key('edljwtpubkey_ops')
1012
try:
11-
return jwt.decode(token, environ['AUTH_PUBLIC_KEY'], algorithms=environ['AUTH_ALGORITHM'])
12-
except (jwt.ExpiredSignatureError, jwt.DecodeError):
13-
return None
14-
15-
16-
def get_mock_jwt_cookie(user: str, lifetime_in_seconds: int, access_token: str) -> str:
17-
payload = {
18-
'urs-user-id': user,
19-
'urs-access-token': access_token,
20-
'exp': int(time.time()) + lifetime_in_seconds,
21-
}
22-
value = jwt.encode(
23-
payload=payload,
24-
key=environ['AUTH_PUBLIC_KEY'],
25-
algorithm=environ['AUTH_ALGORITHM'],
26-
)
27-
return value
13+
payload = jwt.decode(token, signing_key, algorithms=['RS256'])
14+
except jwt.exceptions.InvalidTokenError as e:
15+
raise InvalidTokenException(f'Invalid authorization token provided: {str(e)}')
16+
return payload['uid'], token
17+
18+
19+
def decode_asf_cookie(cookie: str) -> tuple[str, str]:
20+
try:
21+
payload = jwt.decode(cookie, environ['AUTH_PUBLIC_KEY'], algorithms=environ['AUTH_ALGORITHM'])
22+
except jwt.exceptions.InvalidTokenError as e:
23+
raise InvalidTokenException(f'Invalid authorization cookie provided: {str(e)}')
24+
return payload['urs-user-id'], payload['urs-access-token']
25+
26+
27+
def get_jwks_client() -> jwt.PyJWKClient:
28+
return jwt.PyJWKClient('https://urs.earthdata.nasa.gov/.well-known/edl_ops_jwks.json')

apps/api/src/hyp3_api/routes.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
api_spec = OpenAPI.from_dict(api_spec_dict)
2525
CORS(app, origins=r'https?://([-\w]+\.)*asf\.alaska\.edu', supports_credentials=True)
2626

27+
28+
JWKS_CLIENT = auth.get_jwks_client()
2729
AUTHENTICATED_ROUTES = ['/jobs', '/user']
2830

2931

@@ -33,19 +35,22 @@ def check_system_available() -> Response | None:
3335
message = 'HyP3 is currently unavailable. Please try again later.'
3436
error = {'detail': message, 'status': 503, 'title': 'Service Unavailable', 'type': 'about:blank'}
3537
return make_response(jsonify(error), 503)
38+
3639
return None
3740

3841

3942
@app.before_request
4043
def authenticate_user() -> None:
41-
cookie = request.cookies.get('asf-urs')
42-
payload = auth.decode_token(cookie)
43-
if payload is not None:
44-
g.user = payload['urs-user-id']
45-
g.edl_access_token = payload['urs-access-token']
46-
else:
47-
if any([request.path.startswith(route) for route in AUTHENTICATED_ROUTES]) and request.method != 'OPTIONS':
48-
abort(handlers.problem_format(401, 'No authorization token provided'))
44+
if any([request.path.startswith(route) for route in AUTHENTICATED_ROUTES]) and request.method != 'OPTIONS':
45+
try:
46+
if request.authorization and request.authorization.type == 'bearer':
47+
g.user, g.edl_access_token = auth.decode_edl_bearer_token(str(request.authorization.token), JWKS_CLIENT)
48+
elif 'asf-urs' in request.cookies:
49+
g.user, g.edl_access_token = auth.decode_asf_cookie(request.cookies['asf-urs'])
50+
else:
51+
abort(handlers.problem_format(401, 'No authorization token provided'))
52+
except auth.InvalidTokenException as e:
53+
abort(handlers.problem_format(401, str(e)))
4954

5055

5156
@app.route('/')

tests/test_api/conftest.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import json
2+
import os
23
import re
4+
import time
35
from datetime import date, timedelta
46

7+
import jwt
58
import pytest
69
import responses
710

8-
from hyp3_api import CMR_URL, app, auth
11+
from hyp3_api import CMR_URL, app
912

1013

1114
AUTH_COOKIE = 'asf-urs'
@@ -30,7 +33,7 @@ def login(client, username=DEFAULT_USERNAME, access_token=DEFAULT_ACCESS_TOKEN):
3033
client.set_cookie(
3134
domain='localhost',
3235
key=AUTH_COOKIE,
33-
value=auth.get_mock_jwt_cookie(username, lifetime_in_seconds=10_000, access_token=access_token),
36+
value=get_mock_jwt_cookie(username, lifetime_in_seconds=10_000, access_token=access_token),
3437
)
3538

3639

@@ -81,3 +84,17 @@ def setup_mock_cmr_response_for_polygons(granule_polygon_pairs):
8184
}
8285
}
8386
responses.add(responses.POST, CMR_URL_RE, json.dumps(cmr_response))
87+
88+
89+
def get_mock_jwt_cookie(user: str, lifetime_in_seconds: int, access_token: str) -> str:
90+
payload = {
91+
'urs-user-id': user,
92+
'urs-access-token': access_token,
93+
'exp': int(time.time()) + lifetime_in_seconds,
94+
}
95+
value = jwt.encode(
96+
payload=payload,
97+
key=os.environ['AUTH_PUBLIC_KEY'],
98+
algorithm=os.environ['AUTH_ALGORITHM'],
99+
)
100+
return value

tests/test_api/test_api_spec.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from http import HTTPStatus
22

3-
from hyp3_api import auth
4-
from test_api.conftest import AUTH_COOKIE, JOBS_URI, USER_URI, login
3+
from test_api.conftest import AUTH_COOKIE, JOBS_URI, USER_URI, get_mock_jwt_cookie, login
54

65

76
ENDPOINTS = {
@@ -41,14 +40,22 @@ def test_invalid_cookie(client):
4140
client.set_cookie(domain='localhost', key=AUTH_COOKIE, value='garbage I say!!! GARGBAGE!!!')
4241
response = client.get(uri)
4342
assert response.status_code == HTTPStatus.UNAUTHORIZED
43+
assert 'Invalid authorization cookie provided' in response.json['detail']
44+
45+
46+
def test_invalid_bearer(client):
47+
for uri in ENDPOINTS:
48+
response = client.get(uri, headers={'Authorization': 'Bearer BAD TOKEN'})
49+
assert response.status_code == HTTPStatus.UNAUTHORIZED
50+
assert 'Invalid authorization token provided' in response.json['detail']
4451

4552

4653
def test_expired_cookie(client):
4754
for uri in ENDPOINTS:
4855
client.set_cookie(
4956
domain='localhost',
5057
key=AUTH_COOKIE,
51-
value=auth.get_mock_jwt_cookie('user', lifetime_in_seconds=-1, access_token='token'),
58+
value=get_mock_jwt_cookie('user', lifetime_in_seconds=-1, access_token='token'),
5259
)
5360
response = client.get(uri)
5461
assert response.status_code == HTTPStatus.UNAUTHORIZED

tests/test_api/test_auth.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from unittest.mock import patch
2+
3+
import jwt
4+
import pytest
5+
6+
from hyp3_api import auth
7+
from test_api import conftest
8+
9+
10+
def test_decode_asf_cookie():
11+
cookie = conftest.get_mock_jwt_cookie('user', lifetime_in_seconds=100, access_token='token')
12+
13+
user, token = auth.decode_asf_cookie(cookie)
14+
assert user == 'user'
15+
assert token == 'token'
16+
17+
18+
@pytest.mark.network
19+
def test_decode_edl_bearer_token(jwks_client):
20+
with patch('hyp3_api.auth.jwt.decode', return_value={'uid': 'test-user'}) as mock_decode:
21+
user, token = auth.decode_edl_bearer_token('test-token', jwks_client)
22+
23+
mock_decode.assert_called_once()
24+
assert len(mock_decode.call_args.args) == 2
25+
assert mock_decode.call_args.args[0] == 'test-token'
26+
assert isinstance(mock_decode.call_args.args[1], jwt.api_jwk.PyJWK)
27+
assert mock_decode.call_args.kwargs == {'algorithms': ['RS256']}
28+
29+
assert user == 'test-user'
30+
assert token == 'test-token'
31+
32+
33+
@pytest.mark.network
34+
def test_get_jwks_client(jwks_client):
35+
assert 'urs.earthdata.nasa.gov' in jwks_client.uri
36+
37+
38+
@pytest.mark.network
39+
def test_bad_bearer_token(jwks_client):
40+
with pytest.raises(auth.InvalidTokenException, match=r'^Invalid authorization token provided'):
41+
auth.decode_edl_bearer_token('bad token', jwks_client)
42+
43+
44+
def test_bad_asf_cookie():
45+
with pytest.raises(auth.InvalidTokenException, match=r'^Invalid authorization cookie provided'):
46+
auth.decode_asf_cookie('bad token')
47+
48+
49+
@pytest.fixture(scope='session')
50+
def jwks_client():
51+
return auth.get_jwks_client()

0 commit comments

Comments
 (0)