Repository: oauthlib/oauthlib

Issue:
Null value in expires_in field not handled correctly
**Describe the bug**

Some OAuth-based services have been discovered to include the `expires_in` field, but set it to `null`. This issue was discovered on our end attempting to authenticate to SurveyMonkey's API. The `oauthlib` library does not handle this case correctly, and will throw an error if it encounters this.

**How to reproduce**

1. Create an account with SurveyMonkey
1. Create a sample app to test against
1. Attempt to authenticate using OAuth flow against the sample app
1. Receive the following error: `int() argument must be a string, a bytes-like object or a number, not 'NoneType'`

**Expected behavior**

Ignore the null `expires_in` field, and continue without throwing an error.

**Additional context**

The following is an example of a returned payload (access_token hidden for security purposes):

`{"access_token":".g.w2***kjP","token_type":"bearer","access_url":"https:\/\/api.surveymonkey.com","expires_in":null}`

The following is the stacktrace we receive:

```
Traceback (most recent call last):
  File "/code/ac_auth/views.py", line 59, in dispatch
    return super().dispatch(request, *args, **kwargs)
  File "/usr/local/lib/python3.7/site-packages/rest_framework/views.py", line 495, in dispatch
    response = self.handle_exception(exc)
  File "/usr/local/lib/python3.7/site-packages/rest_framework/views.py", line 455, in handle_exception
    self.raise_uncaught_exception(exc)
  File "/usr/local/lib/python3.7/site-packages/rest_framework/views.py", line 492, in dispatch
    response = handler(request, *args, **kwargs)
  File "/code/ac_auth/views.py", line 158, in get
    request.get_raw_uri(),
  File "/code/ac_auth/oauth_service.py", line 46, in fetch_access_token
    timeout=CONNECTION_TIMEOUT,
  File "/usr/local/lib/python3.7/site-packages/requests_oauthlib/oauth2_session.py", line 307, in fetch_token
    self._client.parse_request_body_response(r.text, scope=self.scope)
  File "/usr/local/lib/python3.7/site-packages/oauthlib/oauth2/rfc6749/clients/base.py", line 415, in parse_request_body_response
    self.token = parse_token_response(body, scope=scope)
  File "/usr/local/lib/python3.7/site-packages/oauthlib/oauth2/rfc6749/parameters.py", line 422, in parse_token_response
    params['expires_at'] = time.time() + int(params['expires_in'])
TypeError: int() argument must be a string, a bytes-like object or a number, not 'NoneType'
```

We are using `oauthlib` 3.0.1 from pip.

- Are you using OAuth1, OAuth2 or OIDC? **OAuth2**
- Are you writing client or server side code? **Server-side**
- If client, what provider are you connecting to? **N/A**
- Are you using a downstream library, such as `requests-oauthlib`, `django-oauth-toolkit`, ...? **requests-oauthlib**


Comments:

Patch:
diff --git a/oauthlib/oauth2/rfc6749/parameters.py b/oauthlib/oauth2/rfc6749/parameters.py
index df724ee..14d4c0d 100644
--- a/oauthlib/oauth2/rfc6749/parameters.py
+++ b/oauthlib/oauth2/rfc6749/parameters.py
@@ -422,7 +422,10 @@ def parse_token_response(body, scope=None):
         params['scope'] = scope_to_list(params['scope'])
 
     if 'expires_in' in params:
-        params['expires_at'] = time.time() + int(params['expires_in'])
+        if params['expires_in'] is None:
+            params.pop('expires_in')
+        else:
+            params['expires_at'] = time.time() + int(params['expires_in'])
 
     params = OAuth2Token(params, old_scope=scope)
     validate_token_parameters(params)


Test patch:
diff --git a/tests/oauth2/rfc6749/test_parameters.py b/tests/oauth2/rfc6749/test_parameters.py
index 0d293cc..48b7eac 100644
--- a/tests/oauth2/rfc6749/test_parameters.py
+++ b/tests/oauth2/rfc6749/test_parameters.py
@@ -103,6 +103,15 @@ class ParameterTests(TestCase):
                      '  "expires_in": 3600,'
                      '  "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",'
                      '  "example_parameter": "example_value" }')
+    json_response_noexpire = ('{ "access_token": "2YotnFZFEjr1zCsicMWpAA",'
+                     '  "token_type": "example",'
+                     '  "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",'
+                     '  "example_parameter": "example_value"}')
+    json_response_expirenull = ('{ "access_token": "2YotnFZFEjr1zCsicMWpAA",'
+                     '  "token_type": "example",'
+                     '  "expires_in": null,'
+                     '  "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",'
+                     '  "example_parameter": "example_value"}')
 
     json_custom_error = '{ "error": "incorrect_client_credentials" }'
     json_error = '{ "error": "access_denied" }'
@@ -136,6 +145,13 @@ class ParameterTests(TestCase):
        'example_parameter': 'example_value'
     }
 
+    json_noexpire_dict = {
+        'access_token': '2YotnFZFEjr1zCsicMWpAA',
+        'token_type': 'example',
+        'refresh_token': 'tGzv3JOkF0XG5Qx2TlKWIA',
+        'example_parameter': 'example_value'
+    }
+
     json_notype_dict = {
        'access_token': '2YotnFZFEjr1zCsicMWpAA',
        'expires_in': 3600,
@@ -212,6 +228,8 @@ class ParameterTests(TestCase):
 
         self.assertEqual(parse_token_response(self.json_response_noscope,
             scope=['all', 'the', 'scopes']), self.json_noscope_dict)
+        self.assertEqual(parse_token_response(self.json_response_noexpire), self.json_noexpire_dict)
+        self.assertEqual(parse_token_response(self.json_response_expirenull), self.json_noexpire_dict)
 
         scope_changes_recorded = []
         def record_scope_change(sender, message, old, new):