Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion requests_oauthlib/oauth2_session.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import logging
import time
import calendar
from datetime import datetime

from oauthlib.common import generate_token, urldecode
from oauthlib.oauth2 import WebApplicationClient, InsecureTransportError
Expand Down Expand Up @@ -171,6 +174,39 @@ def authorized(self):
"""
return bool(self.access_token)

def _add_expires_at(self, token, response_date=None):
"""Add expires_at to token if expires_in is present.

OAuth2 responses often include expires_in (seconds until token expires) but
oauthlib expects expires_at (timestamp when token expires) for expiration checks.

Uses response Date header if provided. Falls back to current time if
Date header is not provided or malformed.

:param token: OAuth2 token dict
:param response_date: Optional Date header value from response (e.g. "Thu, 14 Mar 2024 08:30:00 GMT")
:return: Token dict with expires_at if expires_in was present
"""
if token and 'expires_in' in token:
# RFC 6749 requires expires_in to be 1*DIGIT, but some providers send it as string
expires_in = int(token['expires_in'])

# Try to use response Date header if provided
if response_date:
try:
# Parse HTTP date format (RFC 7231)
dt = datetime.strptime(response_date, "%a, %d %b %Y %H:%M:%S GMT")
# Convert UTC time tuple to Unix timestamp (returns integer)
Copy link

Choose a reason for hiding this comment

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

The GMT here should be parsed as %Z. Otherwise the time is likely wrong.

Copy link

Choose a reason for hiding this comment

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

Does not seem to make any difference:

TZ=PDT python3.13
Python 3.13.5 (main, Jun 12 2025, 00:40:24) [GCC] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from datetime import datetime
>>> datetime.strptime('Tue, 02 Sep 2025 10:28:50 GMT', "%a, %d %b %Y %H:%M:%S GMT")
datetime.datetime(2025, 9, 2, 10, 28, 50)
>>> datetime.strptime('Tue, 02 Sep 2025 10:28:50 GMT', "%a, %d %b %Y %H:%M:%S %Z")
datetime.datetime(2025, 9, 2, 10, 28, 50)
>>> datetime.strptime('Tue, 02 Sep 2025 10:28:50 GMT', "%a, %d %b %Y %H:%M:%S %Z").tzinfo
>>> datetime.strptime('Tue, 02 Sep 2025 10:28:50 GMT', "%a, %d %b %Y %H:%M:%S GMT").tzinfo

See also https://docs.python.org/3/library/datetime.html#datetime.datetime.utcnow

Warning

Because naive datetime objects are treated by many datetime methods as local times, it is preferred to use aware datetimes to represent times in UTC. As such, the recommended way to create an object representing the current time in UTC is by calling datetime.now(timezone.utc).

Copy link
Author

Choose a reason for hiding this comment

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

i'll still parse with %Z for consistency

Copy link

Choose a reason for hiding this comment

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

I would hope that parsing with %Z would make strptime generate the tzinfo but no, there is no tzinfo either way.

As the warning in the documentation suggests handling of time data in python is less than ideal.

Copy link

Choose a reason for hiding this comment

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

To spell it out more clearly: with timezones python leans towards implicit rather than explicit, and in the long run that is a mess because you never know in what timezone your timestamp is.

Then to get the best possible result you would parse GMT as fixed string. Then you know that the timastamp is GMT, and can manually add explicit tzinfo hardcoded to timezone.utc making the timestamp unambiguous.

Or you can forget all this, leave the code as is, hope it works with the implicit timezones.

token['expires_at'] = calendar.timegm(dt.utctimetuple()) + expires_in
return token
except (TypeError, ValueError):
# Skip if Date header is malformed or uses non-standard format
log.debug("Failed to parse Date header: %s", response_date)

# Fall back to current time (truncate to second for conservative expiry)
token['expires_at'] = int(time.time()) + expires_in
return token

def authorization_url(self, url, state=None, **kwargs):
"""Form an authorization URL.

Expand Down Expand Up @@ -404,7 +440,7 @@ def fetch_token(
r = hook(r)

self._client.parse_request_body_response(r.text, scope=self.scope)

Choose a reason for hiding this comment

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

Doesn't r here have the actual time the server sent the response?

That should be more reliable than time.time() considering delays and clock skew between hosts.

Choose a reason for hiding this comment

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

Also should completely avoid fiddling with floats because both is in whole seconds.

Copy link
Author

Choose a reason for hiding this comment

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

@hramrach would that just be the Date header in response?

I am not sure if we're always guaranteed to have that so wdyt about a fallback to time.time() if not present?

Copy link
Author

Choose a reason for hiding this comment

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

@hramrach - addressed your feedback! can you take another look?

Copy link

Choose a reason for hiding this comment

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

Indeed, HTTP does not guarantee a date header.

A sender that generates a Date header field SHOULD generate its field value as the best available approximation of the date and time of message generation. In theory, the date ought to represent the moment just before generating the message content. In practice, a sender can generate the date value at any time during message origination.

 An origin server with a clock (as defined in [Section 5.6.7](https://httpwg.org/specs/rfc9110.html#http.date)) MUST generate a Date header field in all [2xx (Successful)](https://httpwg.org/specs/rfc9110.html#status.2xx), [3xx (Redirection)](https://httpwg.org/specs/rfc9110.html#status.3xx), and [4xx (Client Error)](https://httpwg.org/specs/rfc9110.html#status.4xx) responses, and MAY generate a Date header field in [1xx (Informational)](https://httpwg.org/specs/rfc9110.html#status.1xx) and [5xx (Server Error)](https://httpwg.org/specs/rfc9110.html#status.5xx) responses.

When storing the Date value using time.time() as fallback is appropriate. I don't think storing the fractional seconds is needed, or adds any value.

self.token = self._client.token
self.token = self._add_expires_at(self._client.token, response_date=r.headers.get('Date'))
log.debug("Obtained token %s.", self.token)
return self.token

Expand Down Expand Up @@ -494,6 +530,7 @@ def refresh_token(
r = hook(r)

self.token = self._client.parse_request_body_response(r.text, scope=self.scope)
self.token = self._add_expires_at(self.token, response_date=r.headers.get('Date'))
if "refresh_token" not in self.token:
log.debug("No new refresh token given. Re-using old.")
self.token["refresh_token"] = refresh_token
Expand Down
8 changes: 4 additions & 4 deletions tests/test_compliance_fixes.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,9 @@ def test_fetch_access_token(self):
authorization_response="https://i.b/?code=hello",
)
# Times should be close
approx_expires_at = round(time.time()) + 3600
approx_expires_at = int(time.time()) + 3600
actual_expires_at = token.pop("expires_at")
self.assertAlmostEqual(actual_expires_at, approx_expires_at, places=2)
self.assertEqual(actual_expires_at, approx_expires_at)

# Other token values exact
self.assertEqual(token, {"access_token": "mailchimp", "expires_in": 3600})
Expand Down Expand Up @@ -289,9 +289,9 @@ def test_fetch_access_token(self):
authorization_response="https://i.b/?code=hello",
)

approx_expires_at = round(time.time()) + 86400
approx_expires_at = int(time.time()) + 86400
actual_expires_at = token.pop("expires_at")
self.assertAlmostEqual(actual_expires_at, approx_expires_at, places=2)
self.assertEqual(actual_expires_at, approx_expires_at)

self.assertEqual(
token,
Expand Down
37 changes: 33 additions & 4 deletions tests/test_oauth2_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def setUp(self):
"access_token": "asdfoiw37850234lkjsdfsdf",
"refresh_token": "sldvafkjw34509s8dfsdf",
"expires_in": 3600,
"expires_at": fake_time + 3600,
"expires_at": int(fake_time) + 3600,
}
# use someclientid:someclientsecret to easily differentiate between client and user credentials
# these are the values used in oauthlib tests
Expand Down Expand Up @@ -401,10 +401,10 @@ def test_cleans_previous_token_before_fetching_new_one(self):

"""
new_token = deepcopy(self.token)
past = time.time() - 7200
now = time.time()
self.token["expires_at"] = past
new_token["expires_at"] = now + 3600
past = now - 7200
self.token["expires_at"] = int(past)
new_token["expires_at"] = int(now) + 3600
url = "https://example.com/token"

with mock.patch("time.time", lambda: now):
Expand Down Expand Up @@ -488,6 +488,35 @@ def test_token_proxy(self):
with self.assertRaises(AttributeError):
del sess.token

@mock.patch("time.time", new=lambda: fake_time)
def test_add_expires_at_from_expires_in(self):
"""Test that expires_at is correctly calculated from expires_in"""
sess = OAuth2Session("someclientid")
now = int(fake_time)

# Test with missing expires_in (should not modify token)
token = {"access_token": "foo"}
updated_token = sess._add_expires_at(token)
self.assertNotIn('expires_at', updated_token)

# Test with Date header
date_str = "Thu, 14 Mar 2024 08:30:00 GMT"
token = {"access_token": "foo", "expires_in": 3600}
updated_token = sess._add_expires_at(token, response_date=date_str)
self.assertIn('expires_at', updated_token)
expected_timestamp = 1710405000 + 3600 # 2024-03-14 08:30:00 UTC + 1 hour
self.assertEqual(updated_token['expires_at'], expected_timestamp)

# Test with malformed Date header
updated_token = sess._add_expires_at(token, response_date="invalid date format")
self.assertIn('expires_at', updated_token)
self.assertEqual(updated_token['expires_at'], now + 3600)

# Test with missing Date header
updated_token = sess._add_expires_at(token, response_date=None)
self.assertIn('expires_at', updated_token)
self.assertEqual(updated_token['expires_at'], now + 3600)

def test_authorized_false(self):
sess = OAuth2Session("someclientid")
self.assertFalse(sess.authorized)
Expand Down