From 599e10203eae845b2c7d4dd7c4e21338b00debe1 Mon Sep 17 00:00:00 2001 From: James Demery Date: Thu, 21 May 2026 16:25:17 -0400 Subject: [PATCH 1/4] BB2-4781: Revoke prior tokens on new auth flow --- ...t_beneficiary_demographic_scope_changes.py | 64 ++++---- apps/dot_ext/tests/test_utils.py | 100 ++++++++++++- apps/dot_ext/utils.py | 48 +++++- apps/dot_ext/views/authorization.py | 2 + requirements/requirements.dev.txt | 140 ++++++++++-------- 5 files changed, 260 insertions(+), 94 deletions(-) diff --git a/apps/dot_ext/tests/test_beneficiary_demographic_scope_changes.py b/apps/dot_ext/tests/test_beneficiary_demographic_scope_changes.py index ce6cc247d..c0280ffd5 100644 --- a/apps/dot_ext/tests/test_beneficiary_demographic_scope_changes.py +++ b/apps/dot_ext/tests/test_beneficiary_demographic_scope_changes.py @@ -1,18 +1,20 @@ import json -from apps.test import BaseApiTest -from django.core.management import call_command -from django.http import HttpRequest -from django.urls import reverse +from http import HTTPStatus +from unittest import mock # from oauth2_provider.compat import parse_qs, urlparse from urllib.parse import parse_qs, urlparse + +from django.core.management import call_command +from django.http import HttpRequest +from django.urls import reverse from oauth2_provider.models import AccessToken, RefreshToken from rest_framework.test import APIClient from waffle.testutils import override_switch -from apps.authorization.models import DataAccessGrant, ArchivedDataAccessGrant -from apps.dot_ext.models import ArchivedToken, Application -from http import HTTPStatus -from unittest import mock + +from apps.authorization.models import ArchivedDataAccessGrant, DataAccessGrant +from apps.dot_ext.models import Application, ArchivedToken +from apps.test import BaseApiTest class TestBeneficiaryDemographicScopesChanges(BaseApiTest): @@ -127,7 +129,7 @@ def test_bene_demo_scopes_change(self, mock_get_and_update): ) # Assert auth request was successful - self.assertEqual(status_code, 200) + self.assertEqual(status_code, HTTPStatus.OK) # Assert scope in response content self.assertEqual(response_scopes, sorted(APPLICATION_SCOPES_FULL)) @@ -138,7 +140,7 @@ def test_bene_demo_scopes_change(self, mock_get_and_update): # Assert access to userinfo end point? client.credentials(HTTP_AUTHORIZATION='Bearer ' + token_1.token) response = client.get('/v1/connect/userinfo') - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, HTTPStatus.OK) # ------ TEST #2: Test refresh of token_1 refresh_request_data = { @@ -152,7 +154,7 @@ def test_bene_demo_scopes_change(self, mock_get_and_update): content = json.loads(response.content.decode('utf-8')) # Assert successful - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, HTTPStatus.OK) # Assert response scopes response_scopes = sorted(content['scope'].split()) @@ -166,7 +168,7 @@ def test_bene_demo_scopes_change(self, mock_get_and_update): # Assert access to userinfo end point? client.credentials(HTTP_AUTHORIZATION='Bearer ' + token.token) response = client.get('/v1/connect/userinfo') - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, HTTPStatus.OK) # Verify token counts expected. self.assertEqual(AccessToken.objects.count(), 1) @@ -197,7 +199,7 @@ def test_bene_demo_scopes_change(self, mock_get_and_update): # Assert NO access to userinfo end point? client.credentials(HTTP_AUTHORIZATION='Bearer ' + token_3.token) response = client.get('/v1/connect/userinfo') - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) # Verify token counts expected. self.assertEqual(AccessToken.objects.count(), 1) @@ -215,7 +217,7 @@ def test_bene_demo_scopes_change(self, mock_get_and_update): # Test access to userinfo end point? NO ACCESS! response = client.get('/v1/connect/userinfo') content = json.loads(response.content) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED) self.assertEqual(content.get('detail', None), 'Authentication credentials were not provided.') # ------ TEST #5: Test token_1 from TEST #1 token refresh? NO ACCESS! @@ -241,7 +243,7 @@ def test_bene_demo_scopes_change(self, mock_get_and_update): ) # Assert auth request was successful - self.assertEqual(status_code, 200) + self.assertEqual(status_code, HTTPStatus.OK) # Assert scope in response content self.assertEqual(response_scopes, sorted(APPLICATION_SCOPES_FULL)) @@ -252,7 +254,7 @@ def test_bene_demo_scopes_change(self, mock_get_and_update): # Assert access to userinfo end point? client.credentials(HTTP_AUTHORIZATION='Bearer ' + token_6.token) response = client.get('/v1/connect/userinfo') - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, HTTPStatus.OK) # ------ TEST #7: Test token_3 from TEST #3 again. It should still have access, but no permission with status=403. @@ -262,8 +264,8 @@ def test_bene_demo_scopes_change(self, mock_get_and_update): # Test access to userinfo end point? response = client.get('/v1/connect/userinfo') content = json.loads(response.content) - self.assertEqual(response.status_code, 403) - self.assertEqual(content.get('detail', None), 'You do not have permission to perform this action.') + self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED) + self.assertEqual(content.get('detail', None), 'Authentication credentials were not provided.') # Verify token counts expected. self.assertEqual(AccessToken.objects.count(), 2) @@ -280,7 +282,7 @@ def test_bene_demo_scopes_change(self, mock_get_and_update): # Perform partial authorization request, with out application getting an access token. response = self.client.post(reverse('oauth2_provider:authorize'), data=payload) - self.assertEqual(response.status_code, 302) + self.assertEqual(response.status_code, HTTPStatus.FOUND) # Setup token_3 in APIClient from previous step. It should be removed now? client.credentials(HTTP_AUTHORIZATION='Bearer ' + token_3.token) @@ -288,7 +290,7 @@ def test_bene_demo_scopes_change(self, mock_get_and_update): # Test access to userinfo end point? response = client.get('/v1/connect/userinfo') content = json.loads(response.content) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED) self.assertEqual(content.get('detail', None), 'Authentication credentials were not provided.') # Verify token counts expected. @@ -309,7 +311,7 @@ def test_bene_demo_scopes_change(self, mock_get_and_update): ) # Assert auth request was successful - self.assertEqual(status_code, 200) + self.assertEqual(status_code, HTTPStatus.OK) # Verify token counts expected. self.assertEqual(AccessToken.objects.count(), 1) @@ -323,14 +325,14 @@ def test_bene_demo_scopes_change(self, mock_get_and_update): # Assert access to userinfo end point? client.credentials(HTTP_AUTHORIZATION='Bearer ' + token_9.token) response = client.get('/v1/connect/userinfo') - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, HTTPStatus.OK) # Beneficiary chooses the DENY button choice on consent page payload['allow'] = False # Perform partial authorization request, with out application getting an access token. response = self.client.post(reverse('oauth2_provider:authorize'), data=payload) - self.assertEqual(response.status_code, 302) + self.assertEqual(response.status_code, HTTPStatus.FOUND) # Verify token counts expected. self.assertEqual(AccessToken.objects.count(), 1) @@ -346,7 +348,7 @@ def test_bene_demo_scopes_change(self, mock_get_and_update): # when the allow parameter is false client.credentials(HTTP_AUTHORIZATION='Bearer ' + token_9.token) response = client.get('/v1/connect/userinfo') - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, HTTPStatus.OK) # BB2-4270: Remove prior active tokens so tests below are not looking for multiple active tokens # which is an impossible state @@ -360,12 +362,12 @@ def test_bene_demo_scopes_change(self, mock_get_and_update): payload['allow'] = True # Perform authorization request - token_10, refresh_token_10, status_code, response_scopes, access_token_scopes = self._authorize_and_request_token( - payload, application + token_10, refresh_token_10, status_code, response_scopes, access_token_scopes = ( + self._authorize_and_request_token(payload, application) ) # Assert auth request was successful - self.assertEqual(status_code, 200) + self.assertEqual(status_code, HTTPStatus.OK) # Verify token counts expected. self.assertEqual(AccessToken.objects.count(), 1) @@ -379,7 +381,7 @@ def test_bene_demo_scopes_change(self, mock_get_and_update): # Assert access to userinfo end point? client.credentials(HTTP_AUTHORIZATION='Bearer ' + token_10.token) response = client.get('/v1/connect/userinfo') - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, HTTPStatus.OK) # Application changes choice to require demographic scopes application.require_demographic_scopes = False @@ -387,7 +389,7 @@ def test_bene_demo_scopes_change(self, mock_get_and_update): # Perform partial authorization request, with out application getting an access token. response = self.client.post(reverse('oauth2_provider:authorize'), data=payload) - self.assertEqual(response.status_code, 302) + self.assertEqual(response.status_code, HTTPStatus.FOUND) # Verify token counts expected. self.assertEqual(AccessToken.objects.count(), 0) @@ -400,7 +402,7 @@ def test_bene_demo_scopes_change(self, mock_get_and_update): # Perform partial authorization request, with out application getting an access token. response = self.client.post(reverse('oauth2_provider:authorize'), data=payload) - self.assertEqual(response.status_code, 302) + self.assertEqual(response.status_code, HTTPStatus.FOUND) # Verify token counts expected. self.assertEqual(AccessToken.objects.count(), 0) @@ -414,5 +416,5 @@ def test_bene_demo_scopes_change(self, mock_get_and_update): # Assert access to userinfo end point? client.credentials(HTTP_AUTHORIZATION='Bearer ' + token_10.token) response = client.get('/v1/connect/userinfo') - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED) self.assertEqual(content.get('detail', None), 'Authentication credentials were not provided.') diff --git a/apps/dot_ext/tests/test_utils.py b/apps/dot_ext/tests/test_utils.py index c314a3dad..ee9762205 100644 --- a/apps/dot_ext/tests/test_utils.py +++ b/apps/dot_ext/tests/test_utils.py @@ -1,8 +1,16 @@ +from datetime import timedelta +from unittest.mock import MagicMock, patch + from django.test import TestCase +from django.utils import timezone -from apps.versions import VersionNotMatched from apps.dot_ext.constants import SUPPORTED_VERSION_TEST_CASES -from apps.dot_ext.utils import get_api_version_number_from_url, validate_latin_extended_string +from apps.dot_ext.utils import ( + get_api_version_number_from_url, + revoke_prior_tokens_for_user_and_app_if_they_exist, + validate_latin_extended_string, +) +from apps.versions import VersionNotMatched class TestDOTUtils(TestCase): @@ -33,3 +41,91 @@ def test_latin_extended_failure(self): for text in invalid_inputs: assert not validate_latin_extended_string(text) + + @patch('apps.dot_ext.utils.get_refresh_token_model') + @patch('apps.dot_ext.utils.get_access_token_model') + def test_revoke_prior_tokens_for_user_and_app_if_they_exist_no_prior_tokens( + self, mock_get_access_token, mock_get_refresh_token + ): + """Confirm that if only one access token is returned, that we never query for refresh tokens""" + mock_access_token_model = MagicMock() + mock_access_token_model.objects.filter.return_value.order_by.return_value = [MagicMock()] + mock_get_access_token.return_value = mock_access_token_model + + mock_refresh_token_model = MagicMock() + mock_get_refresh_token.return_value = mock_refresh_token_model + + revoke_prior_tokens_for_user_and_app_if_they_exist(1, 1) + + mock_get_refresh_token.objects.get.assert_not_called() + + @patch('apps.dot_ext.utils.timezone') + @patch('apps.dot_ext.utils.get_refresh_token_model') + @patch('apps.dot_ext.utils.get_access_token_model') + def test_revoke_prior_tokens_for_user_and_app_if_they_exist_prior_tokens_exit( + self, mock_get_access_token, mock_get_refresh_token, mock_timezone + ): + """Confirm that if there are multiple access tokens, the prior ones will have their expires value updated. + Also, any associated refresh tokens will have their access_token_id set to null, and their revoked value + set to the current time (UTC) + """ + mock_timezone.now.return_value = timezone.now() + + new_access_token = MagicMock( + id=1, user_id=1, expires=timezone.now() + timedelta(hours=2), created=timezone.now() + ) + prior_access_token = MagicMock( + id=2, user_id=1, expires=timezone.now() + timedelta(minutes=30), created=timezone.now() - timedelta(days=10) + ) + prior_access_token_two = MagicMock( + id=3, user_id=1, expires=timezone.now() - timedelta(days=20), created=timezone.now() - timedelta(days=20) + ) + + mock_access_token_model = MagicMock() + mock_access_token_model.objects.filter.return_value.order_by.return_value = [ + new_access_token, + prior_access_token, + prior_access_token_two, + ] + mock_get_access_token.return_value = mock_access_token_model + + refresh_token = MagicMock(access_token_id=2, revoked=None) + mock_refresh_token_model = MagicMock() + mock_refresh_token_model.objects.get.return_value = refresh_token + mock_get_refresh_token.return_value = mock_refresh_token_model + + revoke_prior_tokens_for_user_and_app_if_they_exist(1, 10) + + prior_access_token.save.assert_called_once() + prior_access_token_two.save.assert_not_called() + + assert refresh_token.revoked is not None + assert refresh_token.access_token_id is None + refresh_token.save.assert_called_once() + + @patch('apps.dot_ext.utils.timezone') + @patch('apps.dot_ext.utils.get_access_token_model') + def test_revoke_prior_tokens_for_user_and_app_if_they_exist_no_associated_refresh_token( + self, mock_get_access_token, mock_timezone + ): + """Confirm that even if there is no associated refresh_token for an access_token, that the access_token + still has its expires value updated. + """ + mock_timezone.now.return_value = timezone.now() + + new_access_token = MagicMock( + id=1, user_id=1, expires=timezone.now() + timedelta(hours=2), created=timezone.now() + ) + prior_access_token = MagicMock( + id=2, user_id=1, expires=timezone.now() + timedelta(minutes=30), created=timezone.now() - timedelta(days=10) + ) + + mock_access_token_model = MagicMock() + mock_access_token_model.objects.filter.return_value.order_by.return_value = [ + new_access_token, + prior_access_token, + ] + mock_get_access_token.return_value = mock_access_token_model + + revoke_prior_tokens_for_user_and_app_if_they_exist(1, 10) + prior_access_token.save.assert_called_once() diff --git a/apps/dot_ext/utils.py b/apps/dot_ext/utils.py index 134fcc26c..60274d7a5 100644 --- a/apps/dot_ext/utils.py +++ b/apps/dot_ext/utils.py @@ -8,7 +8,14 @@ from django.db import transaction from django.http import HttpRequest from django.http.response import JsonResponse -from oauth2_provider.models import AccessToken, RefreshToken, get_application_model +from django.utils import timezone +from oauth2_provider.models import ( + AccessToken, + RefreshToken, + get_access_token_model, + get_application_model, + get_refresh_token_model, +) from oauthlib.oauth2.rfc6749.errors import ( InvalidClientError, InvalidGrantError, @@ -326,3 +333,42 @@ def validate_latin_extended_string(text: str) -> bool: bool: if all strings are encoded less than U+017F (383) and it is not empty """ return all(ord(char) <= 383 for char in text) and bool(text) + + +def revoke_prior_tokens_for_user_and_app_if_they_exist(user_id: int, app_id: int) -> None: + """Revoke prior tokens for a user/app id pair to ensure that if a user has reauthorized + that prior tokens can't be used, in case any of those prior tokens have more scopes than + the newly created one + + Args: + user_id (int): ID for the user who just re-authorized an app they have authorized previously + app_id (int): ID for the application the user just re-authorized for + """ + AccessToken = get_access_token_model() + RefreshToken = get_refresh_token_model() + prior_access_tokens = list(AccessToken.objects.filter(user=user_id, application=app_id).order_by('-created')) + + # If there is only one access token for a user_id/app_id, we don't need to revoke any prior tokens + if len(prior_access_tokens) <= 1: + return + + prior_access_tokens.pop(0) + + for access_token in prior_access_tokens: + try: + refresh_token = get_refresh_token_model().objects.get(access_token=access_token.id) + + # Only update the access token expires value if it is in the future + if access_token.expires > timezone.now(): + access_token.expires = timezone.now() + access_token.save() + + if refresh_token.revoked is None: + refresh_token.revoked = timezone.now() + refresh_token.access_token_id = None + refresh_token.save() + + except RefreshToken.DoesNotExist: + # indicates it is a access token created via CAN flow, as it does not have an associated refresh token + access_token.expires = timezone.now() + access_token.save() diff --git a/apps/dot_ext/views/authorization.py b/apps/dot_ext/views/authorization.py index d4ad44367..543ac58f1 100644 --- a/apps/dot_ext/views/authorization.py +++ b/apps/dot_ext/views/authorization.py @@ -100,6 +100,7 @@ get_api_version_number_from_url, json_response_from_oauth2_error, remove_application_user_pair_tokens_data_access, + revoke_prior_tokens_for_user_and_app_if_they_exist, validate_app_is_active, validate_latin_extended_string, ) @@ -1107,6 +1108,7 @@ def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: access_token = body.get('access_token') if access_token: token = get_access_token_model().objects.get(token=access_token) + revoke_prior_tokens_for_user_and_app_if_they_exist(token.user_id, app.id) if grant_type == CLIENT_CREDENTIALS: token.user_id = user.id diff --git a/requirements/requirements.dev.txt b/requirements/requirements.dev.txt index b52831b19..b5cc7764e 100644 --- a/requirements/requirements.dev.txt +++ b/requirements/requirements.dev.txt @@ -672,66 +672,86 @@ geventhttpclient==2.3.9 \ --hash=sha256:f7cf60062d3aebd5e83f4d197a59609194effe25a25bcab01ae3775be18c877e \ --hash=sha256:f9d32fc9ba6d82c4f8586a6bd6e99c7e15a25404f94743dce00367aec826809d # via locust -greenlet==3.5.0 \ - --hash=sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846 \ - --hash=sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4 \ - --hash=sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662 \ - --hash=sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce \ - --hash=sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2 \ - --hash=sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588 \ - --hash=sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13 \ - --hash=sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e \ - --hash=sha256:29ea813b2e1f45fa9649a17853b2b5465c4072fbcb072e5af6cd3a288216574a \ - --hash=sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3 \ - --hash=sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b \ - --hash=sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033 \ - --hash=sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628 \ - --hash=sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136 \ - --hash=sha256:47422135b1d308c14b2c6e758beedb1acd33bb91679f5670edf77bf46244722b \ - --hash=sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d \ - --hash=sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2 \ - --hash=sha256:4b28037cb07768933c54d81bfe47a85f9f402f57d7d69743b991a713b63954eb \ - --hash=sha256:4d0eadc7e4d9ffb2af4247b606cae307be8e448911e5a0d0b16d72fc3d224cfd \ - --hash=sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b \ - --hash=sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1 \ - --hash=sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16 \ - --hash=sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d \ - --hash=sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106 \ - --hash=sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba \ - --hash=sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c \ - --hash=sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc \ - --hash=sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7 \ - --hash=sha256:6c18dfb59c70f5a94acd271c72e90128c3c776e41e5f07767908c8c1b74ad339 \ - --hash=sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b \ - --hash=sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae \ - --hash=sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8 \ - --hash=sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2 \ - --hash=sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5 \ - --hash=sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf \ - --hash=sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f \ - --hash=sha256:804a70b328e706b785c6ef16187051c394a63dd1a906d89be24b6ad77759f13f \ - --hash=sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2 \ - --hash=sha256:884f649de075b84739713d41dd4dfd41e2b910bfb769c4a3ea02ec1da52cd9bb \ - --hash=sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082 \ - --hash=sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7 \ - --hash=sha256:9c615f869163e14bb1ced20322d8038fb680b08236521ac3f30cd4c1288785a0 \ - --hash=sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c \ - --hash=sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853 \ - --hash=sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988 \ - --hash=sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3 \ - --hash=sha256:ba8f0bdc2fae6ce915dfd0c16d2d00bca7e4247c1eae4416e06430e522137858 \ - --hash=sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37 \ - --hash=sha256:cda05425526240807408156b6960a17a79a0c760b813573b67027823be760977 \ - --hash=sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4 \ - --hash=sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8 \ - --hash=sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86 \ - --hash=sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f \ - --hash=sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112 \ - --hash=sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e \ - --hash=sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2 \ - --hash=sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8 \ - --hash=sha256:f8c30c2225f40dd76c50790f0eb3b5c7c18431efb299e2782083e1981feed243 \ - --hash=sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564 +greenlet==3.5.1 \ + --hash=sha256:001775efe7b8e758861294c7a27c28af87f3f3f1c20468a2bc618c45b346c061 \ + --hash=sha256:00929c98ec525fd9bf075875d8c5f6a983a90906cdf78a66e6de2d8e466c2a19 \ + --hash=sha256:017a544f0385d441e88714160d089d6900ef46c9eff9d99b6715a5ef2d127747 \ + --hash=sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1 \ + --hash=sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10 \ + --hash=sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0 \ + --hash=sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9 \ + --hash=sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64 \ + --hash=sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659 \ + --hash=sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a \ + --hash=sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541 \ + --hash=sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e \ + --hash=sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c \ + --hash=sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d \ + --hash=sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b \ + --hash=sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986 \ + --hash=sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78 \ + --hash=sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2 \ + --hash=sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a \ + --hash=sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc \ + --hash=sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b \ + --hash=sha256:540dae7b956209af4d70a3be35927b4055f617763771e5e84a5255bea934d2f5 \ + --hash=sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829 \ + --hash=sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea \ + --hash=sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436 \ + --hash=sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c \ + --hash=sha256:6ebeb75c81211f5c702576cf81f315e77e23cfdb2c7c6fcb9dd143e6de35c360 \ + --hash=sha256:73f78f9b9f0a5c06e5c946ba1e8e36f5114923b6be109ee618c54f079c3ea14f \ + --hash=sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244 \ + --hash=sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283 \ + --hash=sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54 \ + --hash=sha256:7eacb17a9d41538a2bc4912eba5ef13823c83cb69e4d141d0813debe7163187f \ + --hash=sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2 \ + --hash=sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188 \ + --hash=sha256:88e300d136eac057b2397aa1cfd7328b4c87c7eb66a09c7bc6a1292234db474e \ + --hash=sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249 \ + --hash=sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3 \ + --hash=sha256:8a271fcd66c74615cda6a964fda3f304267a12e50a084472218a39bb0376f563 \ + --hash=sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de \ + --hash=sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6 \ + --hash=sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368 \ + --hash=sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26 \ + --hash=sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de \ + --hash=sha256:9d59e840387076a51016777a9328b3f2c427c6f9208a6e958bad251be50a648d \ + --hash=sha256:a0cbed8bb44e23c5b199f888f4e4ce096b45ad9f25ff74a7ad0213875e936bb2 \ + --hash=sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e \ + --hash=sha256:a203a8bd0acb0701653d3bbb26e404854a68674139ed5cbb778830f42b09bb33 \ + --hash=sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d \ + --hash=sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c \ + --hash=sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd \ + --hash=sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207 \ + --hash=sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed \ + --hash=sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b \ + --hash=sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62 \ + --hash=sha256:b9152fca4a6466e114aaec745ae61cba739903a109754a9d4e1262f01e9259b1 \ + --hash=sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0 \ + --hash=sha256:c3d35f87c7253b715d13d679e0783d845910144f282cb939fe1ba4ac8616269c \ + --hash=sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823 \ + --hash=sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab \ + --hash=sha256:cc6ab7e555c8a112ad3a76e368e86e12a2754bcae1652a5602e133ec7b635523 \ + --hash=sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd \ + --hash=sha256:d0932b81d72f552ded9d810d00021b64d89f2195a91ce115b893f943b7a4ab3c \ + --hash=sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce \ + --hash=sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c \ + --hash=sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07 \ + --hash=sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135 \ + --hash=sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e \ + --hash=sha256:ded7b068c7c31c1a8657d4fd42d886b3e051ae29f88b80c5ff9d502257b0f071 \ + --hash=sha256:e5cc9606aa5f4e0bde0d3bd502b44f743864c3ffa5cfa1011b1e30f5aa02366f \ + --hash=sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5 \ + --hash=sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee \ + --hash=sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e \ + --hash=sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f \ + --hash=sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad \ + --hash=sha256:ed8cdb691169715a9a492844a83246f090182247d1a5031dc78a403f68ba1e97 \ + --hash=sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc \ + --hash=sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e \ + --hash=sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2 \ + --hash=sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed # via gevent gunicorn==25.0.3 \ --hash=sha256:aca364c096c81ca11acd4cede0aaeea91ba76ca74e2c0d7f879154db9d890f35 \ From de1fa4d40eee78ebc20e2289595cb7e1dbc0ad11 Mon Sep 17 00:00:00 2001 From: James Demery Date: Fri, 22 May 2026 08:56:32 -0400 Subject: [PATCH 2/4] Ensure token revocation only happens on v3 auth flows --- .../test_beneficiary_demographic_scope_changes.py | 4 ++-- apps/dot_ext/views/authorization.py | 6 +++++- requirements/requirements.dev.txt | 12 ++++++------ 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/apps/dot_ext/tests/test_beneficiary_demographic_scope_changes.py b/apps/dot_ext/tests/test_beneficiary_demographic_scope_changes.py index c0280ffd5..241b1fc8f 100644 --- a/apps/dot_ext/tests/test_beneficiary_demographic_scope_changes.py +++ b/apps/dot_ext/tests/test_beneficiary_demographic_scope_changes.py @@ -264,8 +264,8 @@ def test_bene_demo_scopes_change(self, mock_get_and_update): # Test access to userinfo end point? response = client.get('/v1/connect/userinfo') content = json.loads(response.content) - self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED) - self.assertEqual(content.get('detail', None), 'Authentication credentials were not provided.') + self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) + self.assertEqual(content.get('detail', None), 'You do not have permission to perform this action.') # Verify token counts expected. self.assertEqual(AccessToken.objects.count(), 2) diff --git a/apps/dot_ext/views/authorization.py b/apps/dot_ext/views/authorization.py index 543ac58f1..63c272f25 100644 --- a/apps/dot_ext/views/authorization.py +++ b/apps/dot_ext/views/authorization.py @@ -1108,7 +1108,11 @@ def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: access_token = body.get('access_token') if access_token: token = get_access_token_model().objects.get(token=access_token) - revoke_prior_tokens_for_user_and_app_if_they_exist(token.user_id, app.id) + + # If it's version 3, we want to check for prior tokens to ensure they can't continue + # to be used + if version == Versions.V3: + revoke_prior_tokens_for_user_and_app_if_they_exist(token.user_id, app.id) if grant_type == CLIENT_CREDENTIALS: token.user_id = user.id diff --git a/requirements/requirements.dev.txt b/requirements/requirements.dev.txt index b5cc7764e..ba0a317a3 100644 --- a/requirements/requirements.dev.txt +++ b/requirements/requirements.dev.txt @@ -1490,15 +1490,15 @@ python-dotenv==1.2.2 \ --hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \ --hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3 # via -r /code/requirements/requirements.in -python-engineio==4.13.1 \ - --hash=sha256:0a853fcef52f5b345425d8c2b921ac85023a04dfcf75d7b74696c61e940fd066 \ - --hash=sha256:f32ad10589859c11053ad7d9bb3c9695cdf862113bfb0d20bc4d890198287399 +python-engineio==4.13.2 \ + --hash=sha256:8c101cd170e400dc4e970cd523325cde22df8fc25140953f379327055d701a6b \ + --hash=sha256:a7732e99cfb7db6ed1aee31f18d7f73bbae086a92f31dee019bc646155d9684e # via # locust # python-socketio -python-socketio[client]==5.16.1 \ - --hash=sha256:a3eb1702e92aa2f2b5d3ba00261b61f062cce51f1cfb6900bf3ab4d1934d2d35 \ - --hash=sha256:f863f98eacce81ceea2e742f6388e10ca3cdd0764be21d30d5196470edf5ea89 +python-socketio[client]==5.16.2 \ + --hash=sha256:ad88c228d921646efa436c0a0df217e364ef30ec072df4041484e54d49c15989 \ + --hash=sha256:bef2da3374fd533aed4297f57b4f6512b52aa51604cb0da2165f401291c5ca20 # via locust python-stdnum==1.18 \ --hash=sha256:bcc763d9c49ae23da5d2b7a686d5fd1deec9d9051341160a10d1ac723a26bec0 \ From 5a993e1909e20ec2683639589dc20ad6ff827281 Mon Sep 17 00:00:00 2001 From: James Demery Date: Thu, 28 May 2026 16:42:23 -0400 Subject: [PATCH 3/4] Move the revocation of prior tokens to form_valid of AuthorizationView from post of TokenView. Revoke all previous tokens as the newest one will not have been created yet at that point --- apps/dot_ext/utils.py | 2 -- apps/dot_ext/views/authorization.py | 9 +++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/dot_ext/utils.py b/apps/dot_ext/utils.py index 60274d7a5..3ed23fc8d 100644 --- a/apps/dot_ext/utils.py +++ b/apps/dot_ext/utils.py @@ -352,8 +352,6 @@ def revoke_prior_tokens_for_user_and_app_if_they_exist(user_id: int, app_id: int if len(prior_access_tokens) <= 1: return - prior_access_tokens.pop(0) - for access_token in prior_access_tokens: try: refresh_token = get_refresh_token_model().objects.get(access_token=access_token.id) diff --git a/apps/dot_ext/views/authorization.py b/apps/dot_ext/views/authorization.py index a84317d97..3e2c26d2b 100644 --- a/apps/dot_ext/views/authorization.py +++ b/apps/dot_ext/views/authorization.py @@ -502,6 +502,9 @@ def form_valid(self, form): # Update AuthFlowUuid instance with code. update_instance_auth_flow_trace_with_code(auth_dict, code) + # Check for prior tokens to ensure they can't continue to be used + revoke_prior_tokens_for_user_and_app_if_they_exist(self.request.user.id, application.id) + return self.redirect(self.success_url, application) @@ -1148,10 +1151,8 @@ def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: if access_token: token = get_access_token_model().objects.get(token=access_token) - # If it's version 3, we want to check for prior tokens to ensure they can't continue - # to be used - if version == Versions.V3: - revoke_prior_tokens_for_user_and_app_if_they_exist(token.user_id, app.id) + # # Check for prior tokens to ensure they can't continue to be used + # revoke_prior_tokens_for_user_and_app_if_they_exist(token.user_id, app.id) if grant_type == CLIENT_CREDENTIALS: token.user_id = user.id From f86556efa1d61bee496194860332a081f9e13f9d Mon Sep 17 00:00:00 2001 From: James Demery Date: Fri, 29 May 2026 08:55:39 -0400 Subject: [PATCH 4/4] Address PR feedback, remove no longer needed test --- ...st_beneficiary_demographic_scope_changes.py | 4 ++-- apps/dot_ext/tests/test_utils.py | 17 ----------------- apps/dot_ext/utils.py | 18 +++++++----------- apps/dot_ext/views/authorization.py | 7 ++++--- 4 files changed, 13 insertions(+), 33 deletions(-) diff --git a/apps/dot_ext/tests/test_beneficiary_demographic_scope_changes.py b/apps/dot_ext/tests/test_beneficiary_demographic_scope_changes.py index 241b1fc8f..c0280ffd5 100644 --- a/apps/dot_ext/tests/test_beneficiary_demographic_scope_changes.py +++ b/apps/dot_ext/tests/test_beneficiary_demographic_scope_changes.py @@ -264,8 +264,8 @@ def test_bene_demo_scopes_change(self, mock_get_and_update): # Test access to userinfo end point? response = client.get('/v1/connect/userinfo') content = json.loads(response.content) - self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) - self.assertEqual(content.get('detail', None), 'You do not have permission to perform this action.') + self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED) + self.assertEqual(content.get('detail', None), 'Authentication credentials were not provided.') # Verify token counts expected. self.assertEqual(AccessToken.objects.count(), 2) diff --git a/apps/dot_ext/tests/test_utils.py b/apps/dot_ext/tests/test_utils.py index ee9762205..3c4052813 100644 --- a/apps/dot_ext/tests/test_utils.py +++ b/apps/dot_ext/tests/test_utils.py @@ -42,23 +42,6 @@ def test_latin_extended_failure(self): for text in invalid_inputs: assert not validate_latin_extended_string(text) - @patch('apps.dot_ext.utils.get_refresh_token_model') - @patch('apps.dot_ext.utils.get_access_token_model') - def test_revoke_prior_tokens_for_user_and_app_if_they_exist_no_prior_tokens( - self, mock_get_access_token, mock_get_refresh_token - ): - """Confirm that if only one access token is returned, that we never query for refresh tokens""" - mock_access_token_model = MagicMock() - mock_access_token_model.objects.filter.return_value.order_by.return_value = [MagicMock()] - mock_get_access_token.return_value = mock_access_token_model - - mock_refresh_token_model = MagicMock() - mock_get_refresh_token.return_value = mock_refresh_token_model - - revoke_prior_tokens_for_user_and_app_if_they_exist(1, 1) - - mock_get_refresh_token.objects.get.assert_not_called() - @patch('apps.dot_ext.utils.timezone') @patch('apps.dot_ext.utils.get_refresh_token_model') @patch('apps.dot_ext.utils.get_access_token_model') diff --git a/apps/dot_ext/utils.py b/apps/dot_ext/utils.py index 3ed23fc8d..b3348d4a7 100644 --- a/apps/dot_ext/utils.py +++ b/apps/dot_ext/utils.py @@ -348,18 +348,9 @@ def revoke_prior_tokens_for_user_and_app_if_they_exist(user_id: int, app_id: int RefreshToken = get_refresh_token_model() prior_access_tokens = list(AccessToken.objects.filter(user=user_id, application=app_id).order_by('-created')) - # If there is only one access token for a user_id/app_id, we don't need to revoke any prior tokens - if len(prior_access_tokens) <= 1: - return - for access_token in prior_access_tokens: try: - refresh_token = get_refresh_token_model().objects.get(access_token=access_token.id) - - # Only update the access token expires value if it is in the future - if access_token.expires > timezone.now(): - access_token.expires = timezone.now() - access_token.save() + refresh_token = RefreshToken.objects.get(access_token=access_token.id) if refresh_token.revoked is None: refresh_token.revoked = timezone.now() @@ -367,6 +358,11 @@ def revoke_prior_tokens_for_user_and_app_if_they_exist(user_id: int, app_id: int refresh_token.save() except RefreshToken.DoesNotExist: - # indicates it is a access token created via CAN flow, as it does not have an associated refresh token + # indicates it is an access token created via CAN flow, as it does not have an associated refresh token + # no action needed + pass + + # Only update the access token expires value if it is in the future + if access_token.expires > timezone.now(): access_token.expires = timezone.now() access_token.save() diff --git a/apps/dot_ext/views/authorization.py b/apps/dot_ext/views/authorization.py index 3e2c26d2b..bd432db1b 100644 --- a/apps/dot_ext/views/authorization.py +++ b/apps/dot_ext/views/authorization.py @@ -377,6 +377,10 @@ def validate_v3_authorization_request(self): try: application_user = get_user_model().objects.get(id=self.application.user_id) + # If the v3_early_adopter does not exist in the database, a WaffleFlag object is returned, + # but the id is None. In that case, we want to return and leave it up to the v3_endpoints switch + # as to whether v3 calls can be made. If the flag does exist, then the id will not be None + # and we will check to see if the flag is active for the application if flag.id is None or flag.is_active_for_user(application_user): # Update the class variable to ensure subsequent calls to dispatch don't call this function # more times than is needed @@ -1151,9 +1155,6 @@ def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: if access_token: token = get_access_token_model().objects.get(token=access_token) - # # Check for prior tokens to ensure they can't continue to be used - # revoke_prior_tokens_for_user_and_app_if_they_exist(token.user_id, app.id) - if grant_type == CLIENT_CREDENTIALS: token.user_id = user.id token.save()