Repository: cal-itp/benefits

Issue:
Consume `eligibility-api` package instead of internal code
The scope of this issue is to replace usage of the internal `eligibility/api.py` code with code from the extracted [eligibility-api](https://github.com/cal-itp/eligibility-api) package. Since that package has not been published to PyPI yet (see https://github.com/cal-itp/eligibility-api/issues/22), it is ok to point at the GitHub repo for now.


Discussion:

Resolving patch:
diff --git a/benefits/eligibility/api.py b/benefits/eligibility/api.py
deleted file mode 100644
--- a/benefits/eligibility/api.py
+++ /dev/null
@@ -1,166 +0,0 @@
-"""
-The eligibility application: Eligibility Verification API implementation.
-"""
-import datetime
-import json
-import logging
-import uuid
-
-from django.conf import settings
-from jwcrypto import common as jwcrypto, jwe, jws, jwt
-import requests
-
-
-logger = logging.getLogger(__name__)
-
-
-class ApiError(Exception):
-    """Error calling the Eligibility Verification API."""
-
-    pass
-
-
-class TokenError(Exception):
-    """Error with API request/response token."""
-
-    pass
-
-
-class RequestToken:
-    """Eligibility Verification API request token."""
-
-    def __init__(self, agency, verifier, sub, name):
-        logger.info("Initialize new request token")
-
-        # send the eligibility type names
-        types = list(map(lambda t: t.name, agency.types_to_verify(verifier)))
-
-        # craft the main token payload
-        payload = dict(
-            jti=str(uuid.uuid4()),
-            iss=settings.ALLOWED_HOSTS[0],
-            iat=int(datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc).timestamp()),
-            agency=agency.agency_id,
-            eligibility=types,
-            sub=sub,
-            name=name,
-        )
-
-        logger.debug("Sign token payload with agency's private key")
-        header = {"typ": "JWS", "alg": agency.jws_signing_alg}
-        signed_token = jwt.JWT(header=header, claims=payload)
-        signed_token.make_signed_token(agency.private_jwk)
-        signed_payload = signed_token.serialize()
-
-        logger.debug("Encrypt signed token payload with verifier's public key")
-        header = {"typ": "JWE", "alg": verifier.jwe_encryption_alg, "enc": verifier.jwe_cek_enc}
-        encrypted_token = jwt.JWT(header=header, claims=signed_payload)
-        encrypted_token.make_encrypted_token(verifier.public_jwk)
-
-        logger.info("Signed and encrypted request token initialized")
-        self._jwe = encrypted_token
-
-    def __repr__(self):
-        return str(self)
-
-    def __str__(self):
-        return self._jwe.serialize()
-
-
-class ResponseToken:
-    """Eligibility Verification API response token."""
-
-    def __init__(self, response, agency, verifier):
-        logger.info("Read encrypted token from response")
-
-        try:
-            encrypted_signed_token = response.text
-            if not encrypted_signed_token:
-                raise ValueError()
-            # strip extra spaces and wrapping quote chars
-            encrypted_signed_token = encrypted_signed_token.strip("'\n\"")
-        except ValueError:
-            raise TokenError("Invalid response format")
-
-        logger.debug("Decrypt response token using agency's private key")
-        allowed_algs = [verifier.jwe_encryption_alg, verifier.jwe_cek_enc]
-        decrypted_token = jwe.JWE(algs=allowed_algs)
-        try:
-            decrypted_token.deserialize(encrypted_signed_token, key=agency.private_jwk)
-        except jwe.InvalidJWEData:
-            raise TokenError("Invalid JWE token")
-        except jwe.InvalidJWEOperation:
-            raise TokenError("JWE token decryption failed")
-
-        decrypted_payload = str(decrypted_token.payload, "utf-8")
-
-        logger.debug("Verify decrypted response token's signature using verifier's public key")
-        signed_token = jws.JWS()
-        try:
-            signed_token.deserialize(decrypted_payload, key=verifier.public_jwk, alg=agency.jws_signing_alg)
-        except jws.InvalidJWSObject:
-            raise TokenError("Invalid JWS token")
-        except jws.InvalidJWSSignature:
-            raise TokenError("JWS token signature verification failed")
-
-        logger.info("Response token decrypted and signature verified")
-
-        payload = json.loads(str(signed_token.payload, "utf-8"))
-        self.eligibility = list(payload.get("eligibility", []))
-        self.error = payload.get("error", None)
-
-
-class Client:
-    """Eligibility Verification API HTTP client."""
-
-    def __init__(self, agency, verifier):
-        logger.debug(f"Initialize client for agency: {agency.short_name}, verifier: {verifier.name}")
-        self.agency = agency
-        self.verifier = verifier
-
-    def _tokenize_request(self, sub, name):
-        """Create a request token."""
-        return RequestToken(self.agency, self.verifier, sub, name)
-
-    def _tokenize_response(self, response):
-        """Parse a response token."""
-        return ResponseToken(response, self.agency, self.verifier)
-
-    def _auth_headers(self, token):
-        """Create headers for the request with the token and verifier API keys"""
-        headers = dict(Authorization=f"Bearer {token}")
-        headers[self.verifier.api_auth_header] = self.verifier.api_auth_key
-        return headers
-
-    def _request(self, sub, name):
-        """Make an API request for eligibility verification."""
-        logger.debug("Start new eligibility verification request")
-
-        try:
-            token = self._tokenize_request(sub, name)
-        except jwcrypto.JWException:
-            raise TokenError("Failed to tokenize form values")
-
-        try:
-            logger.debug(f"GET request to {self.verifier.api_url}")
-            r = requests.get(self.verifier.api_url, headers=self._auth_headers(token))
-        except requests.ConnectionError:
-            raise ApiError("Connection to verification server failed")
-        except requests.Timeout:
-            raise ApiError("Connection to verification server timed out")
-        except requests.TooManyRedirects:
-            raise ApiError("Too many redirects to verification server")
-        except requests.HTTPError as e:
-            raise ApiError(e)
-
-        expected_status_codes = {200, 400}
-        if r.status_code in expected_status_codes:
-            logger.debug("Process eligiblity verification response")
-            return self._tokenize_response(r)
-        else:
-            logger.warning(f"Unexpected eligibility verification response status code: {r.status_code}")
-            raise ApiError("Unexpected eligibility verification response")
-
-    def verify(self, sub, name):
-        """Check eligibility for the subject and name."""
-        return self._request(sub, name)
diff --git a/benefits/eligibility/views.py b/benefits/eligibility/views.py
--- a/benefits/eligibility/views.py
+++ b/benefits/eligibility/views.py
@@ -9,11 +9,13 @@
 from django.utils.decorators import decorator_from_middleware
 from django.utils.translation import pgettext, gettext as _
 
+from eligibility_api.client import Client
+
 from benefits.core import recaptcha, session, viewmodels
 from benefits.core.middleware import AgencySessionRequired, LoginRequired, RateLimit, VerifierSessionRequired
 from benefits.core.models import EligibilityVerifier
 from benefits.core.views import PageTemplateResponse
-from . import analytics, api, forms
+from . import analytics, forms
 
 
 @decorator_from_middleware(AgencySessionRequired)
@@ -184,9 +186,23 @@ def _verify(request, form):
 
     agency = session.agency(request)
     verifier = session.verifier(request)
-    client = api.Client(agency, verifier)
 
-    response = client.verify(sub, name)
+    client = Client(
+        verify_url=verifier.api_url,
+        headers={verifier.api_auth_header: verifier.api_auth_key},
+        issuer=settings.ALLOWED_HOSTS[0],
+        agency=agency.agency_id,
+        jws_signing_alg=agency.jws_signing_alg,
+        client_private_jwk=agency.private_jwk,
+        jwe_encryption_alg=verifier.jwe_encryption_alg,
+        jwe_cek_enc=verifier.jwe_cek_enc,
+        server_public_jwk=verifier.public_jwk,
+    )
+
+    # get the eligibility type names
+    types = list(map(lambda t: t.name, agency.types_to_verify(verifier)))
+
+    response = client.verify(sub, name, types)
 
     if response.error and any(response.error):
         form.add_api_errors(response.error)


Test patch:
diff --git a/tests/pytest/eligibility/test_views.py b/tests/pytest/eligibility/test_views.py
--- a/tests/pytest/eligibility/test_views.py
+++ b/tests/pytest/eligibility/test_views.py
@@ -8,12 +8,12 @@
 import uuid
 
 from pathlib import Path
-from jwcrypto import jwk, jwt
-from typing import Tuple
+from jwcrypto import jwk
 
 from benefits.core import session
-from benefits.core.models import TransitAgency, EligibilityVerifier
-from benefits.eligibility.api import ApiError, TokenError
+from benefits.core.models import TransitAgency
+from eligibility_api.client import ApiError, TokenError
+from eligibility_api.server import make_token
 from benefits.eligibility.views import confirm
 from tests.pytest.conftest import with_agency, initialize_request
 
@@ -25,7 +25,7 @@ def set_agency(mocker):
     return agency
 
 
-def set_verifier(mocker) -> Tuple[TransitAgency, EligibilityVerifier]:
+def set_verifier(mocker):
     agency = set_agency(mocker)
 
     mock = mocker.patch("benefits.core.session.verifier", autospec=True)
@@ -80,7 +80,7 @@ def test_confirm_success(mocker, rf):
         httpretty.GET,
         "http://localhost/verify",
         status=200,
-        body=_make_token(
+        body=make_token(
             {
                 "jti": str(uuid.uuid4()),
                 "iss": "test-server",
@@ -118,21 +118,6 @@ def _get_jwk(filename):
     return key
 
 
-# copied and pasted from eligibility-server code for now - replace with eligibility-api function when available
-def _make_token(payload, jws_signing_alg, server_private_key, jwe_encryption_alg, jwe_cek_enc, client_public_key):
-    """Wrap payload in a signed and encrypted JWT for response."""
-    # sign the payload with server's private key
-    header = {"typ": "JWS", "alg": jws_signing_alg}
-    signed_token = jwt.JWT(header=header, claims=payload)
-    signed_token.make_signed_token(server_private_key)
-    signed_payload = signed_token.serialize()
-    # encrypt the signed payload with client's public key
-    header = {"typ": "JWE", "alg": jwe_encryption_alg, "enc": jwe_cek_enc}
-    encrypted_token = jwt.JWT(header=header, claims=signed_payload)
-    encrypted_token.make_encrypted_token(client_public_key)
-    return encrypted_token.serialize()
-
-
 @httpretty.activate(verbose=True, allow_net_connect=False)
 @pytest.mark.django_db
 @pytest.mark.parametrize(
@@ -197,7 +182,7 @@ def _tokenize_response_error_scenarios():
         pytest.param(lambda verifier: "", id='TokenError("Invalid response format")'),
         pytest.param(lambda verifier: "invalid token", id='TokenError("Invalid JWE token")'),
         pytest.param(
-            lambda verifier: _make_token(
+            lambda verifier: make_token(
                 {
                     "jti": str(uuid.uuid4()),
                     "iss": "test-server",