Repository: cisagov/manage.get.gov

Issue:
Update Login.gov config to use biometric configuration
### Issue description

Login.gov is doing their biometric beta program and we want to participate. For new users, they should go through this new identity verification program, but old users should go through

### Acceptance criteria

- [ ] "All New Users Require Biometric Flow" configurations changes are made to our oidc setup, thus requiring new users to go through the biometric flow and previous users to go through current idv options
- [ ] update unit tests
- [ ] remove step_up_acr_value (and any references)

### Additional context

[slack thread](https://cisa-corp.slack.com/archives/C05BGB4L5NF/p1715248591837839) for reference

See the [documentation for Biometric configuration](file:///Users/broddrick/Downloads/Login.gov%20-%20Biometric%20Comparison%20Limited%20Pilot.pdf) and specifically page 4 where it mentions "All New Users Require Biometric Flow"

Notes for dev:
settings.py sets the OIDC_PROVIDERS values which is what controls our login.gov config for both identity sandbox and production. By looking at the documentation it looks like we can leave our ial1 logic alone and then update our logic for step logic. For ial2 we will remove the step up acr value which is no longer needed per their documentation and add vtr and vtm verification.

settings fields
![image](https://github.com/cisagov/manage.get.gov/assets/109625347/d6976237-5179-42fe-8397-571e38215feb)

ial2 setup where acr values need to change
![image](https://github.com/cisagov/manage.get.gov/assets/109625347/4d9bb877-5466-4b3c-8b4d-2805b086ee5f)



### Links to other issues

_No response_


Discussion:
This ticket was added to sprint 45 on Day 4. Product deemed it a priority ticket. The agreed compromise is to move the WHOIS ticket (https://github.com/cisagov/get.gov/issues/267) to a later sprint.

Resolving patch:
diff --git a/src/djangooidc/oidc.py b/src/djangooidc/oidc.py
--- a/src/djangooidc/oidc.py
+++ b/src/djangooidc/oidc.py
@@ -15,6 +15,7 @@
 from oic.utils.authn.client import CLIENT_AUTHN_METHOD
 from oic.utils import keyio
 
+
 from . import exceptions as o_e
 
 __author__ = "roland"
@@ -84,6 +85,7 @@ def __init__(self, op):
     def create_authn_request(
         self,
         session,
+        do_step_up_auth=False,
         extra_args=None,
     ):
         """Step 2: Construct a login URL at OP's domain and send the user to it."""
@@ -100,10 +102,11 @@ def create_authn_request(
                 "state": session["state"],
                 "nonce": session["nonce"],
                 "redirect_uri": self.registration_response["redirect_uris"][0],
-                # acr_value may be passed in session if overriding, as in the case
-                # of step up auth, otherwise get from settings.py
-                "acr_values": session.get("acr_value") or self.behaviour.get("acr_value"),
             }
+            if do_step_up_auth:
+                self._set_args_for_biometric_auth_request(session, request_args)
+            else:
+                request_args["acr_values"] = self.behaviour.get("acr_value")
 
             if extra_args is not None:
                 request_args.update(extra_args)
@@ -114,6 +117,35 @@ def create_authn_request(
 
         logger.debug("request args: %s" % request_args)
 
+        url, headers = self._prepare_authn_request(request_args, state)  # C901 too complex
+
+        try:
+            # create the redirect object
+            response = HttpResponseRedirect(str(url))
+            # add headers to the object, if any
+            if headers:
+                for key, value in headers.items():
+                    response[key] = value
+
+        except Exception as err:
+            logger.error(err)
+            logger.error("Failed to create redirect object for %s" % state)
+            raise o_e.InternalError(locator=state)
+
+        return response
+
+    def _set_args_for_biometric_auth_request(self, session, request_args):
+        if "acr_value" in session:
+            session.pop("acr_value")
+        request_args["vtr"] = session.get("vtr")
+        request_args["vtm"] = session.get("vtm")
+
+    def _prepare_authn_request(self, request_args, state):
+        """
+        Constructs an authorization request. Then, assembles the url, body, headers, and cis.
+
+        Returns the assembled url and associated header information: `(url, headers)`
+        """
         try:
             # prepare the request for sending
             cis = self.construct_AuthorizationRequest(request_args=request_args)
@@ -126,6 +158,7 @@ def create_authn_request(
                 method="GET",
                 request_args=request_args,
             )
+
             logger.debug("body: %s" % body)
             logger.debug("URL: %s" % url)
             logger.debug("headers: %s" % headers)
@@ -134,19 +167,7 @@ def create_authn_request(
             logger.error("Failed to prepare request for %s" % state)
             raise o_e.InternalError(locator=state)
 
-        try:
-            # create the redirect object
-            response = HttpResponseRedirect(str(url))
-            # add headers to the object, if any
-            if headers:
-                for key, value in headers.items():
-                    response[key] = value
-        except Exception as err:
-            logger.error(err)
-            logger.error("Failed to create redirect object for %s" % state)
-            raise o_e.InternalError(locator=state)
-
-        return response
+        return (url, headers)
 
     def callback(self, unparsed_response, session):
         """Step 3: Receive OP's response, request an access token, and user info."""
@@ -224,9 +245,18 @@ def _get_user_info(self, state, session):
         if isinstance(info_response, ErrorResponse):
             logger.error("Unable to get user info (%s) for %s" % (info_response.get("error", ""), state))
             raise o_e.AuthenticationFailed(locator=state)
+        info_response_dict = info_response.to_dict()
 
-        logger.debug("user info: %s" % info_response)
-        return info_response.to_dict()
+        # Define vtm/vtr information on the user dictionary so we can track this in one location.
+        # If a user has this information, then they are bumped up in terms of verification level.
+        if session.get("needs_step_up_auth") is True:
+            if "ial" in info_response_dict:
+                info_response_dict.pop("ial")
+            info_response_dict["vtm"] = session.get("vtm", "")
+            info_response_dict["vtr"] = session.get("vtr", "")
+
+        logger.debug("user info: %s" % info_response_dict)
+        return info_response_dict
 
     def _request_token(self, state, code, session):
         """Request a token from OP to allow us to then request user info."""
@@ -285,14 +315,20 @@ def store_response(self, resp, info):
         super(Client, self).store_response(resp, info)
 
     def get_default_acr_value(self):
-        """returns the acr_value from settings
-        this helper function is called from djangooidc views"""
+        """Returns the acr_value from settings.
+        This helper function is called from djangooidc views."""
         return self.behaviour.get("acr_value")
 
-    def get_step_up_acr_value(self):
-        """returns the step_up_acr_value from settings
-        this helper function is called from djangooidc views"""
-        return self.behaviour.get("step_up_acr_value")
+    def get_vtm_value(self):
+        """Returns the vtm value from settings.
+        This helper function is called from djangooidc views."""
+        return self.behaviour.get("vtm")
+
+    def get_vtr_value(self, cleaned=True):
+        """Returns the vtr value from settings.
+        This helper function is called from djangooidc views."""
+        vtr = self.behaviour.get("vtr")
+        return json.dumps(vtr) if cleaned else vtr
 
     def __repr__(self):
         return "Client {} {} {}".format(
diff --git a/src/djangooidc/views.py b/src/djangooidc/views.py
--- a/src/djangooidc/views.py
+++ b/src/djangooidc/views.py
@@ -91,12 +91,21 @@ def login_callback(request):
             _initialize_client()
         query = parse_qs(request.GET.urlencode())
         userinfo = CLIENT.callback(query, request.session)
+
         # test for need for identity verification and if it is satisfied
-        # if not satisfied, redirect user to login with stepped up acr_value
-        if _requires_step_up_auth(userinfo):
-            # add acr_value to request.session
-            request.session["acr_value"] = CLIENT.get_step_up_acr_value()
-            return CLIENT.create_authn_request(request.session)
+        # if not satisfied, redirect user to login requiring biometric auth
+
+        # Tests for the presence of the vtm/vtr values in the userinfo object.
+        # If they are there, then we can set a flag in our session for tracking purposes.
+        needs_step_up_auth = _requires_step_up_auth(userinfo)
+        request.session["needs_step_up_auth"] = needs_step_up_auth
+
+        # Return a redirect request to a new auth url that does biometric validation
+        if needs_step_up_auth:
+            request.session["vtm"] = CLIENT.get_vtm_value()
+            request.session["vtr"] = CLIENT.get_vtr_value()
+            return CLIENT.create_authn_request(request.session, do_step_up_auth=True)
+
         user = authenticate(request=request, **userinfo)
         if user:
 
@@ -138,14 +147,27 @@ def login_callback(request):
         return error_page(request, err)
 
 
-def _requires_step_up_auth(userinfo):
-    """if User.needs_identity_verification and step_up_acr_value not in
-    ial returned from callback, return True"""
-    step_up_acr_value = CLIENT.get_step_up_acr_value()
-    acr_value = userinfo.get("ial", "")
+def _requires_step_up_auth(userinfo) -> bool:
+    """
+    Checks for the presence of the key 'vtm' and 'vtr' in the provided `userinfo` object.
+
+    If they are not found, then we call `User.needs_identity_verification()`.
+
+    Args:
+        userinfo (dict): A dictionary of data from the returned user object.
+
+    Return Conditions:
+        If the provided user does not exist in any tables which would preclude them from doing
+        biometric authentication, then we return True. Otherwise, we return False.
+
+        Alternatively, if 'vtm' and 'vtr' already exist on `userinfo`, then we return False.
+
+    """
     uuid = userinfo.get("sub", "")
     email = userinfo.get("email", "")
-    if acr_value != step_up_acr_value:
+    # This value is returned after successful auth
+    user_verified = userinfo.get("vot", "")
+    if not userinfo.get("vtm") or not userinfo.get("vtr") or not user_verified:
         # The acr of this attempt is not at the highest level
         # so check if the user needs the higher level
         return User.needs_identity_verification(email, uuid)
diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py
--- a/src/registrar/config/settings.py
+++ b/src/registrar/config/settings.py
@@ -562,7 +562,11 @@
             "scope": ["email", "profile:name", "phone"],
             "user_info_request": ["email", "first_name", "last_name", "phone"],
             "acr_value": "http://idmanagement.gov/ns/assurance/ial/1",
-            "step_up_acr_value": "http://idmanagement.gov/ns/assurance/ial/2",
+            # "P1" is the current IdV option; "Pb" stands for 'biometric'
+            "vtr": ["Pb", "P1"],
+            # The url that biometric authentication takes place at.
+            # A similar analog is the url for acr_value.
+            "vtm": "https://developer.login.gov/vot-trust-framework",
         },
         "client_registration": {
             "client_id": "cisa_dotgov_registrar",
@@ -580,7 +584,11 @@
             "scope": ["email", "profile:name", "phone"],
             "user_info_request": ["email", "first_name", "last_name", "phone"],
             "acr_value": "http://idmanagement.gov/ns/assurance/ial/1",
-            "step_up_acr_value": "http://idmanagement.gov/ns/assurance/ial/2",
+            # "P1" is the current IdV option; "Pb" stands for 'biometric'
+            "vtr": ["Pb", "P1"],
+            # The url that biometric authentication takes place at.
+            # A similar analog is the url for acr_value.
+            "vtm": "https://developer.login.gov/vot-trust-framework",
         },
         "client_registration": {
             "client_id": ("urn:gov:cisa:openidconnect.profiles:sp:sso:cisa:dotgov_registrar"),


Test patch:
diff --git a/src/djangooidc/tests/test_views.py b/src/djangooidc/tests/test_views.py
--- a/src/djangooidc/tests/test_views.py
+++ b/src/djangooidc/tests/test_views.py
@@ -397,25 +397,31 @@ def test_login_callback_requires_step_up_auth(self, mock_client):
         """Invoke login_callback passing it a request when _requires_step_up_auth returns True
         and assert that session is updated and create_authn_request (mock) is called."""
         with less_console_noise():
-            # MOCK
-            # Configure the mock to return an expected value for get_step_up_acr_value
-            mock_client.return_value.get_step_up_acr_value.return_value = "step_up_acr_value"
             # Create a mock request
             request = self.factory.get("/some-url")
             request.session = {"acr_value": ""}
+
             # Ensure that the CLIENT instance used in login_callback is the mock
             # patch _requires_step_up_auth to return True
             with patch("djangooidc.views._requires_step_up_auth", return_value=True), patch(
-                "djangooidc.views.CLIENT.create_authn_request", return_value=MagicMock()
+                "djangooidc.views.CLIENT.create_authn_request"
             ) as mock_create_authn_request:
-                # TEST
-                # test the login callback
-                login_callback(request)
+                with patch("djangooidc.views.CLIENT.get_vtm_value") as mock_get_vtm_value, patch(
+                    "djangooidc.views.CLIENT.get_vtr_value"
+                ) as mock_get_vtr_value:
+                    mock_get_vtm_value.return_value = "test_vtm"
+                    mock_get_vtr_value.return_value = "test_vtr"
+                    # TEST
+                    # test the login callback
+                    login_callback(request)
+
             # ASSERTIONS
-            # create_authn_request only gets called when _requires_step_up_auth is True
-            # and it changes this acr_value in request.session
-            # Assert that acr_value is no longer empty string
-            self.assertNotEqual(request.session["acr_value"], "")
+            # create_authn_request only gets called when _requires_step_up_auth is True.
+            # The acr_value should be blank here
+            self.assertEqual(request.session["acr_value"], "")
+            self.assertEqual(request.session["vtm"], "test_vtm")
+            self.assertEqual(request.session["vtr"], "test_vtr")
+
             # And create_authn_request was called again
             mock_create_authn_request.assert_called_once()