Repository: jazzband/django-oauth-toolkit

Issue:
Expired ID tokens are not removed
**Describe the bug**

When running `django-admin cleartokens`, access tokens and refresh tokens are cleared as documented, but expired ID tokens stick around in the DB until they are removed manually.

**To Reproduce**

1. Set  `OIDC_ENABLED = True`
2. Set `REFRESH_TOKEN_EXPIRE_SECONDS` to a low value
3. Set `ACCESS_TOKEN_EXPIRE_SECONDS` to a low value
4. Set `ID_TOKEN_EXPIRE_SECONDS` to a low value
6. Get a set of tokens
7. Wait until all of the tokens expired
8. Run `django-admin cleartokens`
9. The access and refresh token will be removed, the expired ID token is still in the DB

**Expected behavior**

I'd expect the ID token to be removed alongside the access token which holds the reference OR the ID token to be removed from the database when it expired.

**Version**

Tested with version 2.1.0.

- [ ] I have tested with the latest published release and it's still a problem.
- [ ] I have tested with the master branch and it's still a problem.

**Additional context**

More generally, I wonder if revoking an access token should clear the ID token (if there is one), similar to how revoking a refresh token clears the associated access token?

I'd be willing to contribute a test and patch to the issue if a patch would be welcome for this issue (please let me know if that's the case).

Thanks for your consideration!


Discussion:
@Pankrat Thanks for noticing this. Since OIDC was a late addition to DOT, I suspect the requisite changes to cleartokens was missed -- it's got a very short commit history.

Regarding the automatic clearing of an ID_token when an access_token is revoked, that "feels" right. If the access_token was revoked I would think `validate_jwt_bearer_token` should fail to validate the ID token based on that access token.

Please do submit a PR! Or maybe two, in order to separate the two concerns.


Resolving patch:
diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py
--- a/oauth2_provider/models.py
+++ b/oauth2_provider/models.py
@@ -663,6 +663,7 @@ def batch_delete(queryset, query):
     refresh_expire_at = None
     access_token_model = get_access_token_model()
     refresh_token_model = get_refresh_token_model()
+    id_token_model = get_id_token_model()
     grant_model = get_grant_model()
     REFRESH_TOKEN_EXPIRE_SECONDS = oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS
 
@@ -696,6 +697,12 @@ def batch_delete(queryset, query):
     access_tokens_delete_no = batch_delete(access_tokens, access_token_query)
     logger.info("%s Expired access tokens deleted", access_tokens_delete_no)
 
+    id_token_query = models.Q(access_token__isnull=True, expires__lt=now)
+    id_tokens = id_token_model.objects.filter(id_token_query)
+
+    id_tokens_delete_no = batch_delete(id_tokens, id_token_query)
+    logger.info("%s Expired ID tokens deleted", id_tokens_delete_no)
+
     grants_query = models.Q(expires__lt=now)
     grants = grant_model.objects.filter(grants_query)
 


Test patch:
diff --git a/tests/models.py b/tests/models.py
--- a/tests/models.py
+++ b/tests/models.py
@@ -32,6 +32,13 @@ class SampleAccessToken(AbstractAccessToken):
         null=True,
         related_name="s_refreshed_access_token",
     )
+    id_token = models.OneToOneField(
+        oauth2_settings.ID_TOKEN_MODEL,
+        on_delete=models.CASCADE,
+        blank=True,
+        null=True,
+        related_name="s_access_token",
+    )
 
 
 class SampleRefreshToken(AbstractRefreshToken):
diff --git a/tests/presets.py b/tests/presets.py
--- a/tests/presets.py
+++ b/tests/presets.py
@@ -20,6 +20,7 @@
     },
     "DEFAULT_SCOPES": ["read", "write"],
     "PKCE_REQUIRED": False,
+    "REFRESH_TOKEN_EXPIRE_SECONDS": 3600,
 }
 OIDC_SETTINGS_RO = deepcopy(OIDC_SETTINGS_RW)
 OIDC_SETTINGS_RO["DEFAULT_SCOPES"] = ["read"]
diff --git a/tests/test_models.py b/tests/test_models.py
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -462,6 +462,45 @@ def test_id_token_methods(oidc_tokens, rf):
     assert IDToken.objects.filter(jti=id_token.jti).count() == 0
 
 
+@pytest.mark.django_db
+@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW)
+def test_clear_expired_id_tokens(oauth2_settings, oidc_tokens, rf):
+    id_token = IDToken.objects.get()
+    access_token = id_token.access_token
+
+    # All tokens still valid
+    clear_expired()
+
+    assert IDToken.objects.filter(jti=id_token.jti).exists()
+
+    earlier = timezone.now() - timedelta(minutes=1)
+    id_token.expires = earlier
+    id_token.save()
+
+    # ID token should be preserved until the access token is deleted
+    clear_expired()
+
+    assert IDToken.objects.filter(jti=id_token.jti).exists()
+
+    access_token.expires = earlier
+    access_token.save()
+
+    # ID and access tokens are expired but refresh token is still valid
+    clear_expired()
+
+    assert IDToken.objects.filter(jti=id_token.jti).exists()
+
+    # Mark refresh token as expired
+    delta = timedelta(seconds=oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS + 60)
+    access_token.expires = timezone.now() - delta
+    access_token.save()
+
+    # With the refresh token expired, the ID token should be deleted
+    clear_expired()
+
+    assert not IDToken.objects.filter(jti=id_token.jti).exists()
+
+
 @pytest.mark.django_db
 @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW)
 def test_application_key(oauth2_settings, application):