Repository: cal-itp/benefits

Issue:
Re-configure OAuth `scope` setting for age verification
## Background

In #598 we simplified the developer setup experience and runtime configuration by creating better defaults in `settings.py` and only using the environment / `.env` file for specific overrides.

Part of that effort saw the OAuth `scope` parameter move from an environment setting to be hard-coded in `settings.py`, which was fine for the basic IAL1 Single Sign-On use-case of #363.

We need to be able to send additional scopes for IAL2 / #635. So we need this setting to be configurable and not explicitly hard-coded to only the single `openid` scope.

## Additional context

The scope we need to send looks like `age_verification` or `age_verification:is_senior` for the specific Senior use-case. So in total we'll send two scopes (they are space separated):

```
openid age_verification:is_senior
```

## Thoughts

The additional scope is basically a stand-in for our `EligibilityType` in the [Eligibility Verification API Request](https://docs.calitp.org/eligibility-api/specification/#request). One approach could be to model needed scope(s) on the `EligibilityType` and inject into the OAuth request from the configured agency/verification type(s).

## Decision

* Use verifier's scope field when configuring the OAuth client
* See #678 for the definition of the scope field
* See #641 for how we'll verify eligibility


Discussion:
My thoughts here are focused on how to send the `scope` value and exactly where that value should come from.

### Simplest change: environment variable

Let's consider the smallest change that would make this work -- extracting out the hard-coded value as an environment variable. This would basically be to undo the [change from #598 that removed the `DJANGO_OAUTH_SCOPE` environment variable](https://github.com/cal-itp/benefits/pull/598/files#diff-d71405aa44f1e899a9cd920e5187aa1d02affdbd0da472eca95f814b0a8c1210L178).

The downside with this is it implies that this `scope` value, whatever it is, applies to any `EligibilityType` for any pair of `TransitAgency` and `EligibilityVerifier` that requires authentication.

This actually would not necessarily be wrong for the use case we're currently hoping to deploy - we would indeed have one `TransitAgency` with one `EligibilityVerifier` that requires authentication, and their intersection of supported `EligibilityType`s would be one type.

However, as soon as a new `scope` is required, we would have to scrap this. Should we assume that this is likely? If so, let's try to hedge for that.

### Hedge for needing more `scope`s in the future: model relationship between `scope` and `EligibilityType` and `EligibilityVerifier`

For context, notice that we currently read the OAuth client configuration and create the client at the top of `oauth/views.py`: https://github.com/cal-itp/benefits/blob/dev/benefits/oauth/views.py#L21. This will occur one time. If we model `scope` as a property related to `EligibilityType` and `EligibilityVerifier`, we would need to either 
 - be able to modify the `scope` on this client   
 or 
 - stop creating the client upfront and instead create one with the correct `scope` when we need it.

Assuming we can do that, I think a `scope` is unique to the combination of an `EligibilityType` and `EligibilityVerifier`.
**<details><summary>Explanation**
</summary>

 - It is not unique to _only_ an `EligibilityType` because multiple `EligibilityVerifier`s could reference a given `EligibilityType`. 
    - As a concrete example, let's say we had a `"senior"` type which has a `scope` of `"age_verification:is_senior`, and two `EligibilityVerifier`s -- one using Login.gov for authentication and the other using let's say Twitter. The scope would only make sense for the first verifier.
 - It is not unique to _only_ an `EligibilityVerifier` because if the `EligibilityVerifier` supports many `EligibiliityType`s, we wouldn't know which type the `scope` is associated to.
    - Concrete example: let's say we have a verifier that uses Login.gov for authentication and supports verifying `"senior"` and `"youth"`. If `scope` is on the verifier, we don't know which type it goes with.
</details>

I think one way we could model this is to introduce an `AuthenticationScope` model with a one-to-one relationship to `EligibilityType` and a one-to-one relationship to an `EligibilityVerifier`. In `oauth/views.py`, when we send the token request, we'll want to read the type and verifier from the session to figure out which scope to use.

### How to consume the user attribute that comes back

I'll write out my thoughts for this on #641.
It would actually be really helpful to have the refactored OAuth views from #668 for working on this change.

In that PR, the code that creates an OAuth Client was moved into a helper module/function, which should make the dynamic scope configuration here a little easier.

Resolving patch:
diff --git a/benefits/oauth/client.py b/benefits/oauth/client.py
--- a/benefits/oauth/client.py
+++ b/benefits/oauth/client.py
@@ -5,25 +5,32 @@
 from authlib.integrations.django_client import OAuth
 
 
-_OAUTH_CLIENT = None
+logger = logging.getLogger(__name__)
 
 
-logger = logging.getLogger(__name__)
+_OPENID_SCOPE = "openid"
 
 
-def instance():
+def instance(scope=None):
     """
-    Get the OAuth client instance using the OAUTH_CLIENT_NAME setting.
+    Get an OAuth client instance using the OAUTH_CLIENT_NAME setting.
+
+    Optionally configure with a (space-separated) scope.
     """
-    global _OAUTH_CLIENT
-    if not _OAUTH_CLIENT:
-        if settings.OAUTH_CLIENT_NAME:
-            logger.debug(f"Using OAuth client configuration: {settings.OAUTH_CLIENT_NAME}")
-
-            _oauth = OAuth()
-            _oauth.register(settings.OAUTH_CLIENT_NAME)
-            _OAUTH_CLIENT = _oauth.create_client(settings.OAUTH_CLIENT_NAME)
-        else:
-            raise Exception("OAUTH_CLIENT_NAME is not configured")
-
-    return _OAUTH_CLIENT
+    if settings.OAUTH_CLIENT_NAME:
+        logger.debug(f"Using OAuth client configuration: {settings.OAUTH_CLIENT_NAME}")
+
+        oauth = OAuth()
+        oauth.register(settings.OAUTH_CLIENT_NAME)
+        client = oauth.create_client(settings.OAUTH_CLIENT_NAME)
+    else:
+        raise Exception("OAUTH_CLIENT_NAME is not configured")
+
+    scopes = [_OPENID_SCOPE]
+
+    if scope:
+        scopes.append(scope)
+
+    client.client_kwargs["scope"] = " ".join(scopes)
+
+    return client
diff --git a/benefits/oauth/views.py b/benefits/oauth/views.py
--- a/benefits/oauth/views.py
+++ b/benefits/oauth/views.py
@@ -2,8 +2,10 @@
 
 from django.shortcuts import redirect
 from django.urls import reverse
+from django.utils.decorators import decorator_from_middleware
 
 from benefits.core import session
+from benefits.core.middleware import VerifierSessionRequired
 from . import analytics, client, redirects
 
 
@@ -16,17 +18,19 @@
 ROUTE_POST_LOGOUT = "oauth:post_logout"
 
 
+@decorator_from_middleware(VerifierSessionRequired)
 def login(request):
     """View implementing OIDC authorize_redirect."""
-    oauth_client = client.instance()
-
-    analytics.started_sign_in(request)
+    verifier = session.verifier(request)
+    oauth_client = client.instance(verifier.auth_scope)
 
     route = reverse(ROUTE_AUTH)
     redirect_uri = redirects.generate_redirect_uri(request, route)
 
     logger.debug(f"OAuth authorize_redirect with redirect_uri: {redirect_uri}")
 
+    analytics.started_sign_in(request)
+
     return oauth_client.authorize_redirect(request, redirect_uri)
 
 
diff --git a/benefits/settings.py b/benefits/settings.py
--- a/benefits/settings.py
+++ b/benefits/settings.py
@@ -177,7 +177,7 @@ def _filter_empty(ls):
         OAUTH_CLIENT_NAME: {
             "client_id": OAUTH_CLIENT_ID,
             "server_metadata_url": f"{OAUTH_AUTHORITY}/.well-known/openid-configuration",
-            "client_kwargs": {"code_challenge_method": "S256", "scope": "openid"},
+            "client_kwargs": {"code_challenge_method": "S256"},
         }
     }
 


Test patch:
diff --git a/tests/pytest/oauth/conftest.py b/tests/pytest/oauth/conftest.py
--- a/tests/pytest/oauth/conftest.py
+++ b/tests/pytest/oauth/conftest.py
@@ -5,6 +5,9 @@
 
 @pytest.fixture
 def mocked_oauth_client(mocker):
-    mock_client = mocker.Mock(spec=DjangoOAuth2App)
-    mocker.patch("benefits.oauth.client.instance", return_value=mock_client)
-    return mock_client
+    return mocker.Mock(spec=DjangoOAuth2App)
+
+
+@pytest.fixture
+def mocked_oauth_client_instance(mocker, mocked_oauth_client):
+    return mocker.patch("benefits.oauth.client.instance", return_value=mocked_oauth_client)
diff --git a/tests/pytest/oauth/test_client.py b/tests/pytest/oauth/test_client.py
--- a/tests/pytest/oauth/test_client.py
+++ b/tests/pytest/oauth/test_client.py
@@ -16,14 +16,37 @@ def test_instance_no_oauth_client_name():
 
 
 @pytest.mark.django_db
-def test_instance_oauth_client_name():
-    assert not client._OAUTH_CLIENT
-
+def test_instance():
     oauth_client = client.instance()
+    oauth_client2 = client.instance()
 
     assert oauth_client
-    assert client._OAUTH_CLIENT is oauth_client
+    assert oauth_client2
+    assert oauth_client is not oauth_client2
 
-    oauth_client2 = client.instance()
 
-    assert oauth_client is oauth_client2
+@pytest.mark.django_db
+def test_instance_scope():
+    scope1 = "scope1"
+    oauth_client = client.instance(scope1)
+    client_scope = oauth_client.client_kwargs["scope"]
+
+    assert scope1 in client_scope
+    assert client._OPENID_SCOPE in client_scope
+
+    scope2 = "scope2"
+    oauth_client2 = client.instance(scope2)
+    client_scope = oauth_client2.client_kwargs["scope"]
+
+    assert scope1 not in client_scope
+    assert scope2 in client_scope
+    assert client._OPENID_SCOPE in client_scope
+
+    scope3 = " ".join((scope1, scope2))
+    oauth_client3 = client.instance(scope3)
+    client_scope = oauth_client3.client_kwargs["scope"]
+
+    assert scope1 in client_scope
+    assert scope2 in client_scope
+    assert scope3 in client_scope
+    assert client._OPENID_SCOPE in client_scope
diff --git a/tests/pytest/oauth/test_redirects.py b/tests/pytest/oauth/test_redirects.py
--- a/tests/pytest/oauth/test_redirects.py
+++ b/tests/pytest/oauth/test_redirects.py
@@ -1,7 +1,8 @@
 from benefits.oauth.redirects import deauthorize_redirect, generate_redirect_uri
 
 
-def test_deauthorize_redirect(mocked_oauth_client):
+def test_deauthorize_redirect(mocked_oauth_client_instance):
+    mocked_oauth_client = mocked_oauth_client_instance.return_value
     mocked_oauth_client.load_server_metadata.return_value = {"end_session_endpoint": "https://server/endsession"}
 
     result = deauthorize_redirect("token", "https://localhost/redirect_uri")
diff --git a/tests/pytest/oauth/test_views.py b/tests/pytest/oauth/test_views.py
--- a/tests/pytest/oauth/test_views.py
+++ b/tests/pytest/oauth/test_views.py
@@ -15,9 +15,12 @@ def mocked_analytics_module(mocked_analytics_module):
     return mocked_analytics_module(benefits.oauth.views)
 
 
-def test_login(mocked_oauth_client, mocked_analytics_module, app_request):
+@pytest.mark.django_db
+@pytest.mark.usefixtures("mocked_session_verifier_auth_required")
+def test_login(mocked_oauth_client_instance, mocked_analytics_module, app_request):
     assert not session.logged_in(app_request)
 
+    mocked_oauth_client = mocked_oauth_client_instance.return_value
     mocked_oauth_client.authorize_redirect.return_value = HttpResponse("authorize redirect")
 
     login(app_request)
@@ -27,7 +30,21 @@ def test_login(mocked_oauth_client, mocked_analytics_module, app_request):
     assert not session.logged_in(app_request)
 
 
-def test_authorize_fail(mocked_oauth_client, app_request):
+@pytest.mark.django_db
+def test_login_scope(mocked_oauth_client_instance, mocked_session_verifier_auth_required, app_request):
+    mocked_oauth_client = mocked_oauth_client_instance.return_value
+    mocked_oauth_client.authorize_redirect.return_value = HttpResponse("authorize redirect")
+
+    mocked_verifier = mocked_session_verifier_auth_required.return_value
+    mocked_verifier.auth_scope = "scope"
+
+    login(app_request)
+
+    mocked_oauth_client_instance.assert_called_once_with(mocked_verifier.auth_scope)
+
+
+def test_authorize_fail(mocked_oauth_client_instance, app_request):
+    mocked_oauth_client = mocked_oauth_client_instance.return_value
     mocked_oauth_client.authorize_access_token.return_value = None
 
     assert not session.logged_in(app_request)
@@ -40,7 +57,8 @@ def test_authorize_fail(mocked_oauth_client, app_request):
     assert result.url == reverse(ROUTE_START)
 
 
-def test_authorize_success(mocked_oauth_client, mocked_analytics_module, app_request):
+def test_authorize_success(mocked_oauth_client_instance, mocked_analytics_module, app_request):
+    mocked_oauth_client = mocked_oauth_client_instance.return_value
     mocked_oauth_client.authorize_access_token.return_value = {"id_token": "token"}
 
     result = authorize(app_request)