Skip to content
Merged
7 changes: 1 addition & 6 deletions benefits/core/admin/transit.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,7 @@

from benefits.core import models

from .mixins import StaffPermissionMixin, SuperuserPermissionMixin


@admin.register(models.EligibilityApiConfig)
class EligibilityApiConfigAdmin(SuperuserPermissionMixin, admin.ModelAdmin):
pass
from .mixins import StaffPermissionMixin


@admin.register(models.TransitAgency)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Generated by Django 5.2.7 on 2026-01-15 20:50

import django.db.models.deletion
from django.db import migrations, models


def migrate_eligibility_api_config_data(apps, schema_editor):
EligibilityApiConfig = apps.get_model("core", "EligibilityApiConfig")
EligibilityApiVerificationRequest = apps.get_model("core", "EligibilityApiVerificationRequest")

eligibility_api_config = EligibilityApiConfig.objects.first()

for request in EligibilityApiVerificationRequest.objects.all():
request.client_private_key = eligibility_api_config.api_private_key
request.client_public_key = eligibility_api_config.api_public_key
request.save()


class Migration(migrations.Migration):

dependencies = [
("core", "0071_remove_enrollmentflow_selection_label_template_override"),
]

operations = [
migrations.AddField(
model_name="eligibilityapiverificationrequest",
name="client_private_key",
field=models.ForeignKey(
default=None,
help_text="Private key used to sign Eligibility API tokens created on behalf of the Benefits client.",
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="+",
to="core.pemdata",
),
),
migrations.AddField(
model_name="eligibilityapiverificationrequest",
name="client_public_key",
field=models.ForeignKey(
default=None,
help_text=(
"Public key corresponding to the Benefits client's private key, used by "
"Eligibility Verification servers to encrypt responses."
),
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="+",
to="core.pemdata",
),
),
migrations.RunPython(migrate_eligibility_api_config_data),
migrations.RemoveField(
model_name="transitagency",
name="eligibility_api_config",
),
migrations.DeleteModel(
name="EligibilityApiConfig",
),
]
12 changes: 2 additions & 10 deletions benefits/core/migrations/local_fixtures.json
Original file line number Diff line number Diff line change
Expand Up @@ -142,15 +142,6 @@
"private_key": 6
}
},
{
"model": "core.eligibilityapiconfig",
"pk": 1,
"fields": {
"api_id": "cst",
"api_private_key": 2,
"api_public_key": 3
}
},
{
"model": "core.transitagency",
"pk": 1,
Expand All @@ -161,7 +152,6 @@
"long_name": "California State Transit (local)",
"info_url": "https://www.agency-website.com",
"phone": "1-800-555-5555",
"eligibility_api_config": 1,
"customer_service_group": 2,
"logo": "agencies/cst.png"
}
Expand Down Expand Up @@ -224,6 +214,8 @@
"api_url": "http://server:8000/verify",
"api_auth_header": "X-Server-API-Key",
"api_auth_key_secret_name": "agency-card-flow-api-auth-key",
"client_private_key": 2,
"client_public_key": 3,
"api_public_key": 1,
"api_jwe_cek_enc": "A256CBC-HS512",
"api_jwe_encryption_alg": "RSA-OAEP",
Expand Down
3 changes: 1 addition & 2 deletions benefits/core/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from .common import Environment, PemData, SecretNameField, template_path
from .enrollment import EligibilityApiVerificationRequest, EnrollmentEvent, EnrollmentFlow, EnrollmentGroup, EnrollmentMethods
from .transit import CardSchemes, EligibilityApiConfig, TransitAgency, TransitProcessorConfig, agency_logo
from .transit import CardSchemes, TransitAgency, TransitProcessorConfig, agency_logo

__all__ = [
"agency_logo",
Expand All @@ -14,7 +14,6 @@
"EnrollmentEvent",
"PemData",
"SecretNameField",
"EligibilityApiConfig",
"TransitAgency",
"TransitProcessorConfig",
]
26 changes: 26 additions & 0 deletions benefits/core/models/enrollment.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,22 @@ class EligibilityApiVerificationRequest(models.Model):
api_auth_key_secret_name = SecretNameField(
help_text="The name of a secret containing the value of the auth header to send in Eligibility API requests.",
)
client_private_key = models.ForeignKey(
PemData,
related_name="+",
on_delete=models.PROTECT,
default=None,
null=True,
help_text="Private key used to sign Eligibility API tokens created on behalf of the Benefits client.",
)
client_public_key = models.ForeignKey(
PemData,
related_name="+",
on_delete=models.PROTECT,
default=None,
null=True,
help_text="Public key corresponding to the Benefits client's private key, used by Eligibility Verification servers to encrypt responses.", # noqa: E501
)
api_public_key = models.ForeignKey(
PemData,
related_name="+",
Expand Down Expand Up @@ -72,6 +88,16 @@ def api_auth_key(self):
secret_field = self._meta.get_field("api_auth_key_secret_name")
return secret_field.secret_value(self)

@property
def client_private_key_data(self):
"""The private key used to sign Eligibility API tokens created by the Benefits client as a string."""
return self.client_private_key.data

@property
def client_public_key_data(self):
"""The public key corresponding to the Benefits client's private key as a string."""
return self.client_public_key.data

@property
def api_public_key_data(self):
"""The Eligibility API public key as a string."""
Expand Down
48 changes: 1 addition & 47 deletions benefits/core/models/transit.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from benefits.core import context as core_context
from benefits.routes import routes

from .common import Environment, PemData
from .common import Environment

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -62,30 +62,6 @@ def __str__(self):
return f"({environment_label}) {agency_slug}"


class EligibilityApiConfig(models.Model):
"""Per-agency configuration for Eligibility Server integrations via the Eligibility API."""

id = models.AutoField(primary_key=True)
api_id = models.SlugField(
help_text="The identifier for this agency used in Eligibility API calls.",
)
api_private_key = models.ForeignKey(
PemData,
related_name="+",
on_delete=models.PROTECT,
help_text="Private key used to sign Eligibility API tokens created on behalf of this Agency.",
)
api_public_key = models.ForeignKey(
PemData,
related_name="+",
on_delete=models.PROTECT,
help_text="Public key corresponding to the agency's private key, used by Eligibility Verification servers to encrypt responses.", # noqa: E501
)

def __str__(self):
return self.api_id


class TransitAgency(models.Model):
"""An agency offering transit service."""

Expand Down Expand Up @@ -119,14 +95,6 @@ class Meta:
default=[CardSchemes.VISA, CardSchemes.MASTERCARD],
help_text="The contactless card schemes this agency supports.",
)
eligibility_api_config = models.ForeignKey(
EligibilityApiConfig,
on_delete=models.PROTECT,
null=True,
blank=True,
default=None,
help_text="The Eligibility API configuration for this transit agency.",
)
sso_domain = models.TextField(
blank=True,
default="",
Expand Down Expand Up @@ -165,20 +133,6 @@ def eligibility_index_url(self):
"""Public facing URL to the TransitAgency's eligibility page."""
return reverse(routes.AGENCY_ELIGIBILITY_INDEX, args=[self.slug])

@property
def eligibility_api_private_key_data(self):
"""This Agency's private key as a string."""
if self.eligibility_api_config:
return self.eligibility_api_config.api_private_key.data
return None

@property
def eligibility_api_public_key_data(self):
"""This Agency's public key as a string."""
if self.eligibility_api_config:
return self.eligibility_api_config.api_public_key.data
return None

@property
def littlepay_config(self):
if hasattr(self, "transitprocessorconfig") and hasattr(self.transitprocessorconfig, "littlepayconfig"):
Expand Down
7 changes: 5 additions & 2 deletions benefits/core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from benefits.core import models, session
from benefits.core.forms import ChooseAgencyForm
from benefits.core.middleware import pageview_decorator, user_error
from benefits.core.models.enrollment import EligibilityApiVerificationRequest
from benefits.routes import routes


Expand Down Expand Up @@ -93,8 +94,10 @@ class AgencyPublicKeyView(View):

@method_decorator(pageview_decorator)
def get(self, request, *args, **kwargs):
agency = kwargs.get("agency")
return HttpResponse(agency.eligibility_api_public_key_data, content_type="text/plain")
# in the URL, a TransitAgency argument is required, but we just need to return the single
# EligibilityApiVerificationRequest client public key that is shared across all agencies
eligibility_api_public_key_data = EligibilityApiVerificationRequest.objects.first().client_public_key_data
return HttpResponse(eligibility_api_public_key_data, content_type="text/plain")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's interesting how the change in this PR affects this view/URL.

First, as seen from this code, eligibility_api_public_key_data is not agency-related, so the URL <agency:agency>/publickey does not really need the agency argument anymore. I didn't remove it (yet) because I wanted to double check if there are any other systems (like eligibility server GH Actions, for example) that would still expect to include the agency argument. If there are none, the URL can be simplified.

The second thing, I think that this indirectly also shows that the api_client_public_key (and api_client_private_key) fields can even be removed from EligibilityApiVerificationRequest because they don't depend on the EV server instance Benefits communicates with, these 2 fields are used by the whole Benefits application, so maybe we can just use the PemData model directly? If it makes sense, it could be a followup refactor.

@thekaveman thekaveman Jan 20, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are both great points and I think we can simplify this significantly. I'd argue a follow-up issue/PR probably makes the most sense but open to doing it here as well.

We can see in the config files on the MST and SBMTD EV servers this is a simple path:

# In MST's case
CLIENT_KEY_PATH = "https://benefits.calitp.org/mst/publickey"

# In SBMTD's case
CLIENT_KEY_PATH = "https://benefits.calitp.org/sbmtd/publickey"

And going to a simpler top-level approach seems pretty easy to do, and also reduces code

CLIENT_KEY_PATH = "https://benefits.calitp.org/publickey"

Since we don't have an "app configuration" model (something we have kicked around in the past, but not totally necessary at least for this) we'll just want to be careful with how the query for PemData works, to ensure we always get exactly just the public key version.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this feels more related to the merging of those 2 models into EligibilityApiVerificationRequest it definitely feels in scope to me to do it as part of this PR. FWIW.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for talking through these points with me @thekaveman. We agreed that addressing the two observations from the comment above (#3441 (comment)) wouldn't do much good at this point, we can keep the organization as-is.

On observation 1, it's not wrong to keep the agency argument in the URL. Conceptually this is still correct, it's just that the client public key is shared among all agencies, so it's ok to request a key to a particular agency, even if the view associated with the URL does not end up using the agency argument. A small clarification was added as an in-line comment on this point.

On observation 2, an "app configuration" model is not necessary at this point since it would only hold 2 fields, and it seems to add more complication and not much benefit. Without this extra model, we couldn't identify a way to select the correct PemData instance corresponding to the encryption key we need to fetch. Because of this, we should keep the client API encryption keys in EligibilityApiVerificationRequest along with the EV server's public key. They are neatly organized under the same model.



class HelpView(TemplateView):
Expand Down
4 changes: 2 additions & 2 deletions benefits/eligibility/verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ def eligibility_from_api(flow: models.EnrollmentFlow, form, agency: models.Trans
verify_url=flow.api_request.api_url,
headers={flow.api_request.api_auth_header: flow.api_request.api_auth_key},
issuer=settings.ALLOWED_HOSTS[0],
agency=agency.eligibility_api_config.api_id,
agency=agency.slug,
jws_signing_alg=flow.api_request.api_jws_signing_alg,
client_private_key=agency.eligibility_api_private_key_data,
client_private_key=flow.api_request.client_private_key_data,
jwe_encryption_alg=flow.api_request.api_jwe_encryption_alg,
jwe_cek_enc=flow.api_request.api_jwe_cek_enc,
server_public_key=flow.api_request.api_public_key_data,
Expand Down
21 changes: 4 additions & 17 deletions tests/pytest/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,7 @@
from pytest_socket import disable_socket

from benefits.core import session
from benefits.core.models import (
EligibilityApiVerificationRequest,
EnrollmentFlow,
Environment,
PemData,
TransitAgency,
)
from benefits.core.models.transit import EligibilityApiConfig
from benefits.core.models import EligibilityApiVerificationRequest, EnrollmentFlow, Environment, PemData, TransitAgency
from benefits.enrollment_littlepay.models import LittlepayConfig, LittlepayGroup
from benefits.enrollment_switchio.models import SwitchioConfig, SwitchioGroup

Expand Down Expand Up @@ -112,6 +105,8 @@ def model_EligibilityApiVerificationRequest(model_PemData):
api_jwe_cek_enc="cek-enc",
api_jwe_encryption_alg="alg",
api_jws_signing_alg="alg",
client_private_key=model_PemData,
client_public_key=model_PemData,
api_public_key=model_PemData,
api_url="https://example.com/verify",
)
Expand Down Expand Up @@ -232,22 +227,14 @@ def model_SwitchioConfig(model_PemData, model_TransitAgency):


@pytest.fixture
def model_EligibilityApiConfig(model_PemData):
config = EligibilityApiConfig.objects.create(api_id="test123", api_private_key=model_PemData, api_public_key=model_PemData)

return config


@pytest.fixture
def model_TransitAgency(model_EligibilityApiConfig):
def model_TransitAgency():
agency = TransitAgency.objects.create(
slug="cst",
short_name="TEST",
long_name="Test Transit Agency",
info_url="https://example.com/test-agency",
phone="800-555-5555",
active=True,
eligibility_api_config=model_EligibilityApiConfig,
logo="agencies/cst.png",
)

Expand Down
14 changes: 2 additions & 12 deletions tests/pytest/core/admin/test_transit.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,8 @@
from django.contrib.auth.models import Group

from benefits.core import models
from benefits.core.admin.mixins import StaffPermissionMixin, SuperuserPermissionMixin
from benefits.core.admin.transit import EligibilityApiConfigAdmin, TransitAgencyAdmin


@pytest.mark.django_db
class TestEligibilityApiConfigAdmin:
@pytest.fixture(autouse=True)
def init(self):
self.model_admin = EligibilityApiConfigAdmin(models.EligibilityApiConfig, admin.site)

def test_permissions_mixin(self):
assert isinstance(self.model_admin, SuperuserPermissionMixin)
from benefits.core.admin.mixins import StaffPermissionMixin
from benefits.core.admin.transit import TransitAgencyAdmin


@pytest.mark.django_db
Expand Down
27 changes: 25 additions & 2 deletions tests/pytest/core/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from benefits.core import views
from benefits.core.middleware import TEMPLATE_USER_ERROR
from benefits.core.models import EnrollmentFlow
from benefits.core.models.common import PemData
from benefits.core.models.enrollment import EligibilityApiVerificationRequest
from benefits.routes import routes


Expand Down Expand Up @@ -155,14 +157,35 @@ def view(self, app_request, model_TransitAgency):
v.setup(app_request, agency=model_TransitAgency)
return v

def test_get(self, view, app_request):
def test_get(self, view, app_request, model_EligibilityApiVerificationRequest):
agency = view.kwargs["agency"]
# recreate the condition of the live view, where the agency kwarg is passed to the get() call
response = view.get(app_request, agency=agency)

assert response.status_code == 200
assert response.headers["Content-Type"] == "text/plain"
assert response.content.decode("utf-8") == agency.eligibility_api_public_key_data
assert response.content.decode("utf-8") == model_EligibilityApiVerificationRequest.client_public_key_data

def test_get_select_first_instance(self, view, app_request, model_EligibilityApiVerificationRequest):
"""
Ensures that if multiple EligibilityApiVerificationRequest objects exist,
the view returns the public key from the first one.
"""
# Create a second verification request instance with different data
public_key = PemData.objects.create(label="Test public key 2", text_secret_name="pem-secret-data-2")
EligibilityApiVerificationRequest.objects.create(client_public_key=public_key, api_public_key=public_key)

# Ensure we have more than one object in the DB
assert EligibilityApiVerificationRequest.objects.count() > 1

# The 'first' instance should be the one from the fixture
expected_key = model_EligibilityApiVerificationRequest.client_public_key_data

agency = view.kwargs["agency"]
response = view.get(app_request, agency=agency)

assert response.status_code == 200
assert response.content.decode("utf-8") == expected_key


@pytest.mark.django_db
Expand Down
Loading