Skip to content
/ django Public

Fixed #27775 -- Added support for custom expiry in signed cookies.#19191

Open
abe-101 wants to merge 1 commit intodjango:mainfrom
abe-101:ticket_27775
Open

Fixed #27775 -- Added support for custom expiry in signed cookies.#19191
abe-101 wants to merge 1 commit intodjango:mainfrom
abe-101:ticket_27775

Conversation

@abe-101
Copy link

@abe-101 abe-101 commented Feb 19, 2025

Trac ticket number

ticket-27775

Branch description

  • Added support for extracting expiration times from signed data via a configurable expiration_key.
  • Added expiration_key support to signed_cookies session backend.

Checklist

  • This PR targets the main branch.
  • The commit message is written in past tense, mentions the ticket number, and ends with a period.
  • I have checked the "Has patch" ticket flag in the Trac system.
  • I have added or updated relevant tests.
  • I have added or updated relevant docs, including release notes if applicable.
  • n/a I have attached screenshots in both light and dark modes for any UI changes.

Copy link

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

Hello! Thank you for your contribution 💪

As it's your first contribution be sure to check out the patch review checklist.

If you're fixing a ticket from Trac make sure to set the "Has patch" flag and include a link to this PR in the ticket!

If you have any design or process questions then you can ask in the Django forum.

Welcome aboard ⛵️!

@jacobtylerwalls
Copy link
Member

Hi @abe-101, thanks for picking this up!

There are a few issues on the previous PR that aren't yet addressed, for instance this one. Would you like to start with those? When you're caught up on that set of comments, please unset the "Needs ..." flags on the Trac ticket. Thanks.

Copy link
Member

Choose a reason for hiding this comment

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

value here is allowed to be a "complex data structure (e.g. list, tuple, or dictionary)". Should support be added for non-dictionary types (perhaps supporting indexing?). Alternatively, should the error message be nicer if it is of the wrong type?

Copy link
Author

Choose a reason for hiding this comment

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

Thanks for raising this point! You're right that we should handle non-dictionary types more gracefully.

Here's what I propose adding:

if not isinstance(value, dict):
    raise TypeError(
        f"expiration_key is only supported for dict values, not {type(value).__name__}"
    )

Copy link
Author

Choose a reason for hiding this comment

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

is there is a more abstract key indexable type we can check for?
this way we can support more than just a dict

Copy link
Contributor

Choose a reason for hiding this comment

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

👍 Nice catch! I hadn't thought that far to support more than just a regular dict. I think we can use Mapping.

We can also add tests for non-supported type input.

@abe-101 abe-101 force-pushed the ticket_27775 branch 2 times, most recently from d794bde to 3688312 Compare February 19, 2025 18:17
@abe-101
Copy link
Author

abe-101 commented Feb 19, 2025

Hi @abe-101, thanks for picking this up!

There are a few issues on the previous PR that aren't yet addressed, for instance this one. Would you like to start with those? When you're caught up on that set of comments, please unset the "Needs ..." flags on the Trac ticket. Thanks.

thank you @jacobtylerwalls for the review
This is my first Django PR so i appreciate your input.
I've added the versionchanged to the docs.
I've reviewed the comments and to the best of my knowledge I've cover it all. if I've missed anything else please point it out

@ryancheley
Copy link
Member

@sarahboyce can we add the Djangonaut label to this PR?

Copy link
Contributor

@sarahboyce sarahboyce left a comment

Choose a reason for hiding this comment

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

Thank you for the PR @abe-101

Did you investigate this comment from Florian?
I think it might be interesting to reduce the signature changes, for example:

--- a/django/contrib/sessions/backends/signed_cookies.py
+++ b/django/contrib/sessions/backends/signed_cookies.py
@@ -15,7 +15,6 @@ class SessionStore(SessionBase):
                 serializer=self.serializer,
                 max_age=self.get_session_cookie_age(),
                 salt="django.contrib.sessions.backends.signed_cookies",
-                expiration_key="_session_expiry",
             )
         except Exception:
             # BadSignature, ValueError, or unpickling exceptions. If any of
diff --git a/django/core/signing.py b/django/core/signing.py
index 770ea1c8b5..9221167734 100644
--- a/django/core/signing.py
+++ b/django/core/signing.py
@@ -159,20 +159,21 @@ def loads(
     serializer=JSONSerializer,
     max_age=None,
     fallback_keys=None,
-    expiration_key=None,
 ):
     """
     Reverse of dumps(), raise BadSignature if signature fails.
 
     The serializer is expected to accept a bytestring.
     """
-    return TimestampSigner(
-        key=key,
-        salt=salt,
-        fallback_keys=fallback_keys,
-    ).unsign_object(
-        s, serializer=serializer, max_age=max_age, expiration_key=expiration_key
+    signer = TimestampSigner(key=key, salt=salt, fallback_keys=fallback_keys)
+    unsigned_data = signer.unsign_object(
+        s,
+        serializer=serializer,
+        max_age=max_age,
     )
+    if expiry := unsigned_data.get("_session_expiry"):
+        signer.verify_max_age(expiry)
+    return unsigned_data
 
 
 class Signer:
@@ -253,6 +254,10 @@ class Signer:
 
 
 class TimestampSigner(Signer):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.payload_timestamp = None
+
     def timestamp(self):
         return b62_encode(int(time.time()))
 
@@ -268,23 +273,11 @@ class TimestampSigner(Signer):
         result = super().unsign(value)
         value, timestamp = result.rsplit(self.sep, 1)
         self.payload_timestamp = b62_decode(timestamp)
-        self._verify_max_age(max_age)
-        return value
-
-    def unsign_object(self, signed_obj, max_age=None, expiration_key=None, **kwargs):
-        # First verify the outer max_age boundary
-        value = super().unsign_object(signed_obj, max_age=max_age, **kwargs)
-
-        # Then if expiration_key is provided, check it as an additional constraint
-        if expiration_key is not None:
-            expiry = value.get(expiration_key)
-            if expiry is not None:
-                self._verify_max_age(expiry)
-
+        self.verify_max_age(max_age)
         return value
 
-    def _verify_max_age(self, max_age=None):
-        if max_age is None:
+    def verify_max_age(self, max_age=None):
+        if max_age is None or self.payload_timestamp is None:
             return
         if isinstance(max_age, datetime.timedelta):
             max_age = max_age.total_seconds()

Comment on lines 169 to 187
return TimestampSigner(
key=key, salt=salt, fallback_keys=fallback_keys
key=key,
salt=salt,
fallback_keys=fallback_keys,
).unsign_object(
s,
serializer=serializer,
max_age=max_age,
s, serializer=serializer, max_age=max_age, expiration_key=expiration_key
)
Copy link
Contributor

Choose a reason for hiding this comment

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

Try and keep formatting consistent for an easier review

    return TimestampSigner(
        key=key, salt=salt, fallback_keys=fallback_keys
    ).unsign_object(
        s,
        serializer=serializer,
        max_age=max_age,
        expiration_key=expiration_key,
    )

):
pass

def test_session_custom_expiry_load(self):
Copy link
Contributor

Choose a reason for hiding this comment

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

This will need a test for the async case

Copy link
Contributor

Choose a reason for hiding this comment

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

@abe-101 this hasn't been done

@abe-101
Copy link
Author

abe-101 commented Feb 25, 2025

Thank you for the PR @abe-101

Did you investigate this comment from Florian? I think it might be interesting to reduce the signature changes, for example:

I argue that the checking for expiration (max_age or exp_key) belongs in the unsign methods of TimestampSigner.
The unsign method handles validation for max_age so I argue unsign_object should also handle validation for exp_key.

Perhaps we should introduce a new method to TimestampSigner that would not do any time validation and instead return the unsigned value with the timestamp and leave it up to the caller to do their custom validation.

@abe-101
Copy link
Author

abe-101 commented Feb 25, 2025

--- a/django/contrib/sessions/backends/signed_cookies.py
+++ b/django/contrib/sessions/backends/signed_cookies.py
@@ -15,7 +15,6 @@ class SessionStore(SessionBase):
                 serializer=self.serializer,
                 max_age=self.get_session_cookie_age(),
                 salt="django.contrib.sessions.backends.signed_cookies",
-                expiration_key="_session_expiry",
             )
         except Exception:
             # BadSignature, ValueError, or unpickling exceptions. If any of
diff --git a/django/core/signing.py b/django/core/signing.py
index 770ea1c8b5..9221167734 100644
--- a/django/core/signing.py
+++ b/django/core/signing.py
@@ -159,20 +159,21 @@ def loads(
     serializer=JSONSerializer,
     max_age=None,
     fallback_keys=None,
-    expiration_key=None,
 ):
     """
     Reverse of dumps(), raise BadSignature if signature fails.
 
     The serializer is expected to accept a bytestring.
     """
-    return TimestampSigner(
-        key=key,
-        salt=salt,
-        fallback_keys=fallback_keys,
-    ).unsign_object(
-        s, serializer=serializer, max_age=max_age, expiration_key=expiration_key
+    signer = TimestampSigner(key=key, salt=salt, fallback_keys=fallback_keys)
+    unsigned_data = signer.unsign_object(
+        s,
+        serializer=serializer,
+        max_age=max_age,
     )
+    if expiry := unsigned_data.get("_session_expiry"):
+        signer.verify_max_age(expiry)
+    return unsigned_data

@sarahboyce I'm confused by your suggestion to remove the line (from the signing.loads method):

-    expiration_key=None,

and instead only support a predefined key (_session_expiry)

+    if expiry := unsigned_data.get("_session_expiry"):

my understanding is that the signing.loads method is a way to unsign an object. it has nothing to do with session

in this PR we add a way to specify a key to be used to lookup an expiration value (from within the signed obj) - to do that there needs to be a change to the signing module

@abe-101
Copy link
Author

abe-101 commented Feb 26, 2025

@sarahboyce I appreciate you review and i understand you hesitation to prevent signature changes - as it can be hard to know the consequences...

Let me assure you that:
the new parameter expiration_key=None introduced in signing.loads and in TimestampSigner.unsign_object both default to None.

I think can avoid changing the TimestampSigner.unsign_object signature with this approach (simular to yours)

--- signing_original.py	2025-02-25 22:21:39.185793764 -0500
+++ signing_suggested.py	2025-02-25 22:46:25.955527054 -0500
@@ -159,19 +159,23 @@
     serializer=JSONSerializer,
     max_age=None,
     fallback_keys=None,
+    expiration_key=None,
 ):
     """
     Reverse of dumps(), raise BadSignature if signature fails.
 
     The serializer is expected to accept a bytestring.
     """
-    return TimestampSigner(
-        key=key, salt=salt, fallback_keys=fallback_keys
-    ).unsign_object(
+    signer = TimestampSigner(key=key, salt=salt, fallback_keys=fallback_keys)
+    unsigned_data = signer.unsign_object(
         s,
         serializer=serializer,
         max_age=max_age,
     )
+    if expiration_key:
+        expiry = unsigned_data.get(expiration_key)
+        signer.verify_max_age(expiry)
+    return unsigned_data
 
 
 class Signer:
@@ -252,6 +256,10 @@
 
 
 class TimestampSigner(Signer):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.payload_timestamp = None
+
     def timestamp(self):
         return b62_encode(int(time.time()))
 
@@ -266,12 +274,18 @@
         """
         result = super().unsign(value)
         value, timestamp = result.rsplit(self.sep, 1)
-        timestamp = b62_decode(timestamp)
-        if max_age is not None:
-            if isinstance(max_age, datetime.timedelta):
-                max_age = max_age.total_seconds()
-            # Check timestamp is not older than max_age
-            age = time.time() - timestamp
-            if age > max_age:
-                raise SignatureExpired("Signature age %s > %s seconds" % (age, max_age))
+        self.payload_timestamp = b62_decode(timestamp)
+
+        self.verify_max_age(max_age)
         return value
+
+    def verify_max_age(self, max_age=None):
+        if max_age is None or self.payload_timestamp is None:
+            return
+        if isinstance(max_age, datetime.timedelta):
+            max_age = max_age.total_seconds()
+
+        # Check timestamp is not older than max_age
+        age = time.time() - self.payload_timestamp
+        if age > max_age:
+            raise SignatureExpired("Signature age %s > %s seconds" % (age, max_age))

I don't see a way to avoid a change in the signing.loads

@sarahboyce
Copy link
Contributor

Perhaps we should introduce a new method to TimestampSigner that would not do any time validation and instead return the unsigned value with the timestamp and leave it up to the caller to do their custom validation.

I think that's a nice idea 👍

@abe-101
Copy link
Author

abe-101 commented Feb 26, 2025

Perhaps we should introduce a new method to TimestampSigner that would not do any time validation and instead return the unsigned value with the timestamp and leave it up to the caller to do their custom validation.

I think that's a nice idea 👍

ok - Exploring this option, where should the time validation happen?

  1. In the loads method (see above [patch](Fixed #27775 -- Added support for custom expiry in signed cookies. #19191 (comment) for example) - still requires a API change in signing
  2. The signing module doesn't do any validation just exposes a new way to retrieve the timestamp - Then we'd modify signed cookies session backend to no longer use signing.loads and instead use the TimestampSigner.new_method and retrieve the timestamp and handle the validations.
  3. add a flag return_timestamp=False to signing.loads and TimestampSigner.unsign
    and leave it to the caller to make the validations

anyone have any other suggestions?

@ryancheley
Copy link
Member

@abe-101 these look like reasonable options, and I can’t think of any others. Of the options you’ve listed, which one are you leaning toward?

@abe-101
Copy link
Author

abe-101 commented Mar 4, 2025

Is it a better option to change how signed_cookies uses the signing module:

class SessionStore(SessionBase):
def load(self):
"""
Load the data from the key itself instead of fetching from some
external data store. Opposite of _get_session_key(), raise BadSignature
if signature fails.
"""
try:
return signing.loads(
self.session_key,
serializer=self.serializer,
# This doesn't handle non-default expiry dates, see #19201
max_age=self.get_session_cookie_age(),
salt="django.contrib.sessions.backends.signed_cookies",
)

where instead of using signing.loads() it directly uses the signing.TimestampSigner class
Like so:

class SessionStore(SessionBase):
    def load(self):
        """
        Load the data from the key itself instead of fetching from some
        external data store. Opposite of _get_session_key(), raise BadSignature
        if signature fails.
        """
        try:
            signer = signing.TimestampSigner(
                salt="django.contrib.sessions.backends.signed_cookies"
            )
            session_data = signer.unsign_object(
                self.session_key,
                serializer=self.serializer,
                max_age=self.get_session_cookie_age(),
            )
            expiry = session_data.get("_session_expiry")
            signer.verify_max_age(expiry)
            return session_data

this approach only makes a small change to the signing module (refactoring verify_max_age to be a public function)
but required a (big) change to how signed_cookies uses the signing module

I wonder if ther will be a side affect if not using signing.loads()

@haileyajohnson
Copy link
Contributor

My problem with this solution is that it creates a forked serialization path:
loads is typically used to serialize a python object, so e.g. calling obj.loads() returns the obj as a json object. If obj contains one or more properties that are complex types, it relies on the nested objects loads implementation, so serialization is quasi-recursive.

The problem here is that if someone asks for a jsonified TimestampSigner directly, it will go through signer.loads() but if you serialize it by calling SessionStore.load(), it skips over signer.loads() and goes straight to the next level of serialization (unsign_object), so now there's the possibility for inconsistent serialization.

That said, I also believe this is the same implementation as here, but moved up one level, introducing the fork.

Apologies if I got any of this wrong, it's been a long day but I couldn't stop thinking about this until I got this down :)

@abe-101
Copy link
Author

abe-101 commented Mar 12, 2025

Current Approach

The current approach provides a public way to tell the signing module to check for an expiry key. One or more signatures must change for that to happen (to specify what the expiry keys is).
Pros:

  • Users of the signing module can specify an expiry_key to check for expiration. (new feature)
  • Solves ticket 27775

Cons:

  • Required a signature change

Sarahs Approach

Another approach (@sarahboyce suggestion) is to only make a check for the session backend (_session_expiry). This is possible without any signature changes.

Pros:

  • Solves ticket 27775
  • No signature change

Cons:

  • Users of the signing module cannot specify an expiry_key to check for expiration.

@sarahboyce
Copy link
Contributor

Trying to make a new proposal to be closer to what Florian suggested:

--- a/django/contrib/sessions/backends/signed_cookies.py
+++ b/django/contrib/sessions/backends/signed_cookies.py
@@ -1,3 +1,6 @@
+import datetime
+import time
+
 from django.contrib.sessions.backends.base import SessionBase
 from django.core import signing
 
@@ -10,13 +13,15 @@ class SessionStore(SessionBase):
         if signature fails.
         """
         try:
-            return signing.loads(
+            payload_value, timestamp = signing.loads(
                 self.session_key,
                 serializer=self.serializer,
                 max_age=self.get_session_cookie_age(),
                 salt="django.contrib.sessions.backends.signed_cookies",
-                expiration_key="_session_expiry",
+                return_timestamp=True,
             )
+            signing.check_signature_expiry(timestamp, max_age=payload_value.get("_session_expiry"))
+            return payload_value
         except Exception:
             # BadSignature, ValueError, or unpickling exceptions. If any of
             # these happen, reset the session.
diff --git a/django/core/signing.py b/django/core/signing.py
index 770ea1c8b5..d58a3fd890 100644
--- a/django/core/signing.py
+++ b/django/core/signing.py
@@ -115,6 +115,17 @@ def get_cookie_signer(salt="django.core.signing.get_cookie_signer"):
     )
 
 
+def check_signature_expiry(timestamp, max_age=None):
+    if max_age is None:
+        return
+    if isinstance(max_age, datetime.timedelta):
+        max_age = max_age.total_seconds()
+    # Check timestamp is not older than max_age.
+    age = time.time() - timestamp
+    if age > max_age:
+        raise SignatureExpired("Signature age %s > %s seconds" % (age, max_age))
+
+
 class JSONSerializer:
     """
     Simple wrapper around json to be used in signing.dumps and
@@ -159,7 +170,7 @@ def loads(
     serializer=JSONSerializer,
     max_age=None,
     fallback_keys=None,
-    expiration_key=None,
+    return_timestamp=False,
 ):
     """
     Reverse of dumps(), raise BadSignature if signature fails.
@@ -171,7 +182,7 @@ def loads(
         salt=salt,
         fallback_keys=fallback_keys,
     ).unsign_object(
-        s, serializer=serializer, max_age=max_age, expiration_key=expiration_key
+        s, serializer=serializer, max_age=max_age, return_timestamp=return_timestamp
     )
 
 
@@ -268,27 +279,12 @@ class TimestampSigner(Signer):
         result = super().unsign(value)
         value, timestamp = result.rsplit(self.sep, 1)
         self.payload_timestamp = b62_decode(timestamp)
-        self._verify_max_age(max_age)
+        check_signature_expiry(self.payload_timestamp, max_age=max_age)
         return value
 
-    def unsign_object(self, signed_obj, max_age=None, expiration_key=None, **kwargs):
+    def unsign_object(self, signed_obj, max_age=None, return_timestamp=False, **kwargs):
         # First verify the outer max_age boundary
         value = super().unsign_object(signed_obj, max_age=max_age, **kwargs)
-
-        # Then if expiration_key is provided, check it as an additional constraint
-        if expiration_key is not None:
-            expiry = value.get(expiration_key)
-            if expiry is not None:
-                self._verify_max_age(expiry)
-
+        if return_timestamp:
+            return value, self.payload_timestamp
         return value
-
-    def _verify_max_age(self, max_age=None):
-        if max_age is None:
-            return
-        if isinstance(max_age, datetime.timedelta):
-            max_age = max_age.total_seconds()
-        # Check timestamp is not older than max_age
-        age = time.time() - self.payload_timestamp
-        if age > max_age:
-            raise SignatureExpired("Signature age %s > %s seconds" % (age, max_age))

This tries to use return_timestamp

@abe-101
Copy link
Author

abe-101 commented Mar 13, 2025

Trying to make a new proposal to be closer to what Florian suggested:
....
This tries to use return_timestamp

@sarahboyce with the above approach signing.loads and TimestampSigner.unsign_object have 2 return paths:

  1. the unsigned value, or
  2. a tuple containing the unsigned data and timestamp

is it acceptable in Django to have 2 return paths?
also, as @haileyajohnson alluded to changing the return type of the signing.loads function may interfere with the serialization...

@sarahboyce
Copy link
Contributor

I think we should either have a test project, or (even better) write a test to show the issues you're afraid of occurring

@haileyajohnson
Copy link
Contributor

@abe-101 my concern was slightly different, it was different code paths rather than different return types, e.g, the case where someone down the line changes signing.loads and that change never gets picked up by SessionStore because it was skipping that function call.

Whether loads returning either an object or a tuple(object, timestamp) could cause problems is a different question, and in my opinion it's fine, since the signer will only return the tuple if the client explicitly requests it by setting returnTimestamp=True, and I think it's reasonable to expect them to realize they're requesting a tuple. Ultimately you're not changing what's retuned by SessionStore.load, so I think if it doesn't break existing tests call that, it should be good 👍

@abe-101 abe-101 force-pushed the ticket_27775 branch 4 times, most recently from 2a45040 to 016fae7 Compare March 17, 2025 02:08
Copy link
Contributor

@sarahboyce sarahboyce left a comment

Choose a reason for hiding this comment

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

Note we also need a release note in 6.0 👍

):
pass

def test_session_custom_expiry_load(self):
Copy link
Contributor

Choose a reason for hiding this comment

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

@abe-101 this hasn't been done

Copy link
Author

Choose a reason for hiding this comment

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

@sarahboyce I removed the new test test_session_custom_expiry_load(self): that I had added.
Upon review, I realized the existing test_actual_expiry test already covers this functionality.
(as well as test_actual_expiry_async which covers the async set_expiry)

Copy link
Member

Choose a reason for hiding this comment

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

What is the point of a method solely calling it's parent? Sounds like you can just drop test_actual_expiry[_async] from this class?

@abe-101 abe-101 force-pushed the ticket_27775 branch 2 times, most recently from ce0c1b0 to fcb7a1a Compare March 18, 2025 18:52
@sarahboyce
Copy link
Contributor

@apollo13 would you mind taking a look at this PR? 🙏
This has been updated based off some feedback you gave on a previous PR for this ticket 👍

Copy link
Member

@apollo13 apollo13 left a comment

Choose a reason for hiding this comment

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

Thank you for the ping @sarahboyce. I have given this a preliminary review. Once we don't require to store something on self and are sure it works with typing in the future this should be good to go.

Copy link
Member

Choose a reason for hiding this comment

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

This looks like a code smell. Why not also adjust unsign to return a timestamp as well? What makes unsign_object special in that regard?

Copy link
Author

Choose a reason for hiding this comment

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

You raise a good point. I suppose we should also add the option to return the timestamp on unsign.

Copy link
Contributor

Choose a reason for hiding this comment

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

Sorry I noticed that we still store on self, an ugly way to get around this could be:

--- a/django/core/signing.py
+++ b/django/core/signing.py
@@ -279,15 +279,15 @@ class TimestampSigner(Signer):
         """
         result = super().unsign(value)
         value, timestamp = result.rsplit(self.sep, 1)
-        self.payload_timestamp = b62_decode(timestamp)
-        check_signature_expiry(self.payload_timestamp, max_age=max_age)
+        payload_timestamp = b62_decode(timestamp)
+        check_signature_expiry(payload_timestamp, max_age=max_age)
         if return_timestamp:
-            return value, self.payload_timestamp
+            return value, payload_timestamp
         return value
 
-    def unsign_object(self, signed_obj, max_age=None, return_timestamp=False, **kwargs):
-        # First verify the outer max_age boundary
+    def unsign_object(self, signed_obj, max_age=None, return_timestamp=False, serializer=JSONSerializer, **kwargs):
         value = super().unsign_object(signed_obj, max_age=max_age, **kwargs)
         if return_timestamp:
-            return value, self.payload_timestamp
+            _, timestamp = self.unsign(signed_obj, max_age=max_age, return_timestamp=True)
+            return value, timestamp
         return value

But you be able to find something nicer 👍

Copy link
Member

@apollo13 apollo13 Apr 15, 2025

Choose a reason for hiding this comment

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

I rather not unsign twice scratches head. So we really might need to keep storing stuff on self (unless there are other creative options). We should make it private at least if we do so.

Copy link
Member

Choose a reason for hiding this comment

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

We do not do it currently but I think in the future Django will support type hints. Is it possible to annotate a function that changes the return values based on an input boolean? I think the answer is yes, but worth checking.

Copy link
Author

Choose a reason for hiding this comment

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

Yes, in Python 3.10+ you can indicate multiple return types with the pipe operator:

def get_value(as_string: bool = False) -> int | str:
   result = 42
   return str(result) if as_string else result

For Django specifically, the Technical Board has stated here:

It is the view of the Django Technical Board that inline type annotations should not be added to Django at the current time.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think you can use overload and literals to be more precise

Copy link
Member

Choose a reason for hiding this comment

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

Yes, using overload sounds like a feasible approach.

Copy link
Member

Choose a reason for hiding this comment

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

What is the point of a method solely calling it's parent? Sounds like you can just drop test_actual_expiry[_async] from this class?

@abe-101
Copy link
Author

abe-101 commented Apr 2, 2025

What is the point of a method solely calling it's parent? Sounds like you can just drop test_actual_expiry[_async] from this class?

You're right about this. I checked and removing the test_actual_expiry and test_actual_expiry_async methods doesn't change the number of tests being run.
(I think its because the parent class tests would still be executed through inheritance)

@sarahboyce sarahboyce changed the title Fixed #27775 -- Added expiration_key support to signing module. Fixed #27775 -- Added support for custom expiry in signed cookies. Apr 14, 2025
Copy link
Contributor

@sarahboyce sarahboyce left a comment

Choose a reason for hiding this comment

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

Thank you @abe-101
I pushed minor updates to docs and tests

Copy link
Contributor

Choose a reason for hiding this comment

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

I think you can use overload and literals to be more precise

@sarahboyce
Copy link
Contributor

Been triaging ticket-36506 and it makes me wonder if we should implement django.contrib.sessions.backends.signed_cookies.SessionStore.clear_expired() in this ticket

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants