From 201142729d5d76c50b69684ce0986b709f837a0e Mon Sep 17 00:00:00 2001 From: Lyndsey Jane Moulds <2042238+Apophenia@users.noreply.github.com> Date: Mon, 18 Dec 2023 08:33:38 -0500 Subject: [PATCH 1/3] Add fulfill route and tests --- api/app.py | 3 +- api/blueprints/__init__.py | 1 + api/blueprints/drbFulfill.py | 62 ++++++++++++++++++++++ config/sample-compose.yaml | 6 +++ tests/unit/test_api_fulfill_blueprint.py | 65 ++++++++++++++++++++++++ 5 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 api/blueprints/drbFulfill.py create mode 100644 tests/unit/test_api_fulfill_blueprint.py diff --git a/api/app.py b/api/app.py index a8a3ebbff3..e4a958a57d 100644 --- a/api/app.py +++ b/api/app.py @@ -9,7 +9,7 @@ from logger import createLog from .blueprints import ( - search, work, info, edition, utils, link, opds, collection, citation + search, work, info, edition, utils, link, opds, collection, citation, fulfill ) from .utils import APIUtils @@ -36,6 +36,7 @@ def __init__(self, dbEngine, redisClient): self.app.register_blueprint(opds) self.app.register_blueprint(collection) self.app.register_blueprint(citation) + self.app.register_blueprint(fulfill) def run(self): if 'local-compose' in os.environ['ENVIRONMENT'] or 'sample-compose' in os.environ['ENVIRONMENT']: diff --git a/api/blueprints/__init__.py b/api/blueprints/__init__.py index b7f4565309..7b73dfbe08 100644 --- a/api/blueprints/__init__.py +++ b/api/blueprints/__init__.py @@ -7,3 +7,4 @@ from .drbSearch import search from .drbUtils import utils from .drbWork import work +from .drbFulfill import fulfill diff --git a/api/blueprints/drbFulfill.py b/api/blueprints/drbFulfill.py new file mode 100644 index 0000000000..d00aa0cab2 --- /dev/null +++ b/api/blueprints/drbFulfill.py @@ -0,0 +1,62 @@ +import json +import os + +import jwt + +from flask import Blueprint, request +from ..utils import APIUtils +from logger import createLog + +JWT_ALGORITHM = '' +logger = createLog(__name__) + +fulfill = Blueprint('fulfill', __name__, url_prefix='/fulfill') + +@fulfill.route('/', methods=['GET']) +def workFulfill(uuid): + logger.info('Checking if authorization is needed for work {}'.format(uuid)) + + requires_authorization = True + + if requires_authorization: + try: + bearer = request.headers.get('Authorization') + token = bearer.split()[1] + + jwt_secret = os.environ['NYPL_API_CLIENT_PUBLIC_KEY'] + decoded_token =(jwt.decode(token, jwt_secret, 'RS256', + audience="app_myaccount")) + if json.loads(json.dumps(decoded_token))['iss'] == "https://www.nypl.org": + statusCode = 200 + responseBody = uuid + else: + statusCode = 401 + responseBody = 'Invalid access token' + + except jwt.exceptions.ExpiredSignatureError: + statusCode = 401 + responseBody = 'Expired access token' + except (jwt.exceptions.DecodeError, UnicodeDecodeError, IndexError, AttributeError): + statusCode = 401 + responseBody = 'Invalid access token' + except ValueError: + logger.warning("Could not deserialize NYPL-issued public key") + statusCode = 500 + responseBody = 'Server error' + + else: + # TODO: In the future, this could record an analytics timestamp + # and redirect to URL of a work if authentication is not required. + # For now, only use /fulfill endpoint in response if authentication is required. + statusCode = 400 + responseBody = "Bad Request" + + response = APIUtils.formatResponseObject( + statusCode, 'fulfill', responseBody + ) + + if statusCode == 401: + response[0].headers['WWW-Authenticate'] = 'Bearer' + + return response + diff --git a/config/sample-compose.yaml b/config/sample-compose.yaml index b459562241..28c810c893 100644 --- a/config/sample-compose.yaml +++ b/config/sample-compose.yaml @@ -45,6 +45,12 @@ HATHI_API_ROOT: https://babel.hathitrust.org/cgi/htd OCLC_QUERY_LIMIT: '390000' #OCLC_API_KEY: +# NYPL AUTH CONFIGURATION +NYPL_API_CLIENT_PUBLIC_KEY: > + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA44ilHg/PxcJYsISHMRyoxsmez178qZpkJVXg7rOMVTLZuf05an7Pl+lX4nw/rqcvGQDXyrimciLgLkWu00xhm6h6klTeJSNq2DgseF8OMw2olfuBKq1NBQ/vC8U0l5NJu34oSN4/iipgpovqAHHBGV4zDt0EWSXE5xpnBWi+w1NMAX/muB2QRfRxkkhueDkAmwKvz5MXJPay7FB/WRjf+7r2EN78x5iQKyCw0tpEZ5hpBX831SEnVULCnpFOcJWMPLdg0Ff6tBmgDxKQBVFIQ9RrzMLTqxKnVVn2+hVpk4F/8tMsGCdd4s/AJqEQBy5lsq7ji1B63XYqi5fc1SnJEQIDAQAB + -----END PUBLIC KEY----- + # Bardo CCE API URL BARDO_CCE_API: http://sfr-bardo-copyright-development.us-east-1.elasticbeanstalk.com/search diff --git a/tests/unit/test_api_fulfill_blueprint.py b/tests/unit/test_api_fulfill_blueprint.py new file mode 100644 index 0000000000..a9852a4341 --- /dev/null +++ b/tests/unit/test_api_fulfill_blueprint.py @@ -0,0 +1,65 @@ +from flask import Flask +import pytest + +import jwt + +from api.blueprints.drbFulfill import workFulfill +from api.utils import APIUtils + + +class TestSearchBlueprint: + @pytest.fixture + def mockUtils(self, mocker): + return mocker.patch.multiple( + APIUtils, + formatResponseObject=mocker.DEFAULT + ) + + @pytest.fixture + def testApp(self): + flaskApp = Flask('test') + flaskApp.config['DB_CLIENT'] = 'testDBClient' + flaskApp.config['READER_VERSION'] = 'test' + + return flaskApp + + def test_workFulfill_expired_token(self, testApp, mockUtils, monkeypatch): + monkeypatch.setenv('NYPL_API_CLIENT_PUBLIC_KEY', "cat") + expiredKey = "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE3MDAwMDAwMDB9.u_NAqhUTudJP2vdZZWZ8fMZchyb65H1wOnYWQ98mWHCb8Q2IZXX4Siglh90JtAWTn1zkSygrpij6dDNWlwtQ-RP9eOOwe7-vAXrpEg1BJVM0Q08WCATJArQoygpjK7mLgTz6LLsb4D3QsXr1J7w9tWI_L5ybc8Z4d-vsp4-vCOptB7x21Q_3ZreDnQouPwv7tfQvpqlgYPCPRf7ekmeNAmHsfo1z0IXdlIhuWlw4fYZ5NZJNWjy0dSbn9CsOGALgKi5cbYxBu9bPMGAnEfEXzc-cYu5FDkT53FDLFypjTjR7L4QQRmykVIBBD2d5ZUGv0Jr_-GCnhMIh66WD4Pmhv4jIoh8MgcxjY18PMcP0IxSq1eJOx7O_Td-t4-8S1a_az1Qi15LH7ThQ8K63SUYA3L4EfXU5Uqw_xZmq8E5zceyzUOEdsPrSU3wyL2dpRVokN0dBBofnoem6Dne0HASQg9BHzclF5I7VByla88efgeS0ovoseA4kn3w1wBu7T-069RxGxTMwR5ddlaJc-UVp-hy3N_38Z0pUqZUXsJDmaoDyaWNSM5odhAaWrDUxlZy7r9CGHnr_PAZy2c46sx-An7cQa62Ir2Q-8I13W4CeMvYJcgC6IHVlmf10IugdN7WnVp2vzWC77vO08RCYLUMA8ekMcaKGs8r1bx7OtY4_5ss" + with testApp.test_request_context('/fulfill/12345', + headers={'Authorization': expiredKey}): + workFulfill('12345') + mockUtils['formatResponseObject'].assert_called_once_with( + 401, 'fulfill', 'Expired access token' + ) + + def test_workFulfill_invalid_token(self, testApp, mockUtils, monkeypatch): + with testApp.test_request_context('/fulfill/12345', + headers={'Authorization': 'Bearer Whatever'}): + monkeypatch.setenv('NYPL_API_CLIENT_PUBLIC_KEY', "SomeKeyValue") + workFulfill('12345') + mockUtils['formatResponseObject'].assert_called_once_with( + 401, 'fulfill', 'Invalid access token') + + def test_workFulfill_no_bearer_auth(self, testApp, mockUtils): + with testApp.test_request_context('/fulfill/12345', + headers={'Authorization': 'Whatever'}): + workFulfill('12345') + mockUtils['formatResponseObject'].assert_called_once_with( + 401, 'fulfill', 'Invalid access token') + + def test_workFulfill_empty_token(self, testApp, mockUtils): + with testApp.test_request_context('/fulfill/12345', + headers={'Authorization': ''}): + workFulfill('12345') + mockUtils['formatResponseObject'].assert_called_once_with( + 401, 'fulfill', 'Invalid access token') + + def test_workFulfill_no_header(self, testApp, mockUtils): + with testApp.test_request_context('/fulfill/12345'): + workFulfill('12345') + mockUtils['formatResponseObject'].assert_called_once_with( + 401, 'fulfill', 'Invalid access token') + + + \ No newline at end of file From b97582f622015507714647641ca0c785dcf8aeb5 Mon Sep 17 00:00:00 2001 From: Lyndsey Jane Moulds <2042238+Apophenia@users.noreply.github.com> Date: Mon, 18 Dec 2023 08:39:32 -0500 Subject: [PATCH 2/3] Remove broken test for expired token --- tests/unit/test_api_fulfill_blueprint.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/tests/unit/test_api_fulfill_blueprint.py b/tests/unit/test_api_fulfill_blueprint.py index a9852a4341..dc61dfcdab 100644 --- a/tests/unit/test_api_fulfill_blueprint.py +++ b/tests/unit/test_api_fulfill_blueprint.py @@ -22,16 +22,6 @@ def testApp(self): flaskApp.config['READER_VERSION'] = 'test' return flaskApp - - def test_workFulfill_expired_token(self, testApp, mockUtils, monkeypatch): - monkeypatch.setenv('NYPL_API_CLIENT_PUBLIC_KEY', "cat") - expiredKey = "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE3MDAwMDAwMDB9.u_NAqhUTudJP2vdZZWZ8fMZchyb65H1wOnYWQ98mWHCb8Q2IZXX4Siglh90JtAWTn1zkSygrpij6dDNWlwtQ-RP9eOOwe7-vAXrpEg1BJVM0Q08WCATJArQoygpjK7mLgTz6LLsb4D3QsXr1J7w9tWI_L5ybc8Z4d-vsp4-vCOptB7x21Q_3ZreDnQouPwv7tfQvpqlgYPCPRf7ekmeNAmHsfo1z0IXdlIhuWlw4fYZ5NZJNWjy0dSbn9CsOGALgKi5cbYxBu9bPMGAnEfEXzc-cYu5FDkT53FDLFypjTjR7L4QQRmykVIBBD2d5ZUGv0Jr_-GCnhMIh66WD4Pmhv4jIoh8MgcxjY18PMcP0IxSq1eJOx7O_Td-t4-8S1a_az1Qi15LH7ThQ8K63SUYA3L4EfXU5Uqw_xZmq8E5zceyzUOEdsPrSU3wyL2dpRVokN0dBBofnoem6Dne0HASQg9BHzclF5I7VByla88efgeS0ovoseA4kn3w1wBu7T-069RxGxTMwR5ddlaJc-UVp-hy3N_38Z0pUqZUXsJDmaoDyaWNSM5odhAaWrDUxlZy7r9CGHnr_PAZy2c46sx-An7cQa62Ir2Q-8I13W4CeMvYJcgC6IHVlmf10IugdN7WnVp2vzWC77vO08RCYLUMA8ekMcaKGs8r1bx7OtY4_5ss" - with testApp.test_request_context('/fulfill/12345', - headers={'Authorization': expiredKey}): - workFulfill('12345') - mockUtils['formatResponseObject'].assert_called_once_with( - 401, 'fulfill', 'Expired access token' - ) def test_workFulfill_invalid_token(self, testApp, mockUtils, monkeypatch): with testApp.test_request_context('/fulfill/12345', From dfa365a1a69a88a403945beebff5fd4d2eaa4520 Mon Sep 17 00:00:00 2001 From: Lyndsey Jane Moulds <2042238+Apophenia@users.noreply.github.com> Date: Mon, 18 Dec 2023 08:45:20 -0500 Subject: [PATCH 3/3] Update changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ddaafcd72..00ffb2432e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,7 @@ # CHANGELOG +## unreleased version -- v0.12.4 +## Added +New /fulfill endpoint with ability to check for NYPL login in Bearer authorization header ## 2023-09-05 version -- v0.12.3 ## Removed