From a432bf6eaf567365e9494c73799b45a9b736a56b Mon Sep 17 00:00:00 2001 From: jshanmugasu Date: Mon, 13 Jul 2026 19:04:46 +0530 Subject: [PATCH 1/3] Support for refresh token expiry value change --- docs/user-guide.rst | 10 +++++++++- intuitlib/client.py | 15 +++++++++++++-- intuitlib/version.py | 2 +- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/docs/user-guide.rst b/docs/user-guide.rst index 3f741f6..b17d28c 100644 --- a/docs/user-guide.rst +++ b/docs/user-guide.rst @@ -33,7 +33,11 @@ The `auth_code` from URL params from Step 2 is used to get bearer tokens. Option auth_client.get_bearer_token(auth_code, realm_id=realm_id) -After successful response, `access_token`, `refresh_token`, etc properties of `auth_client` object are set. +To include the refresh token hard expiry in the response, pass `include_refresh_token_hard_expires_in=True`. The response will contain the `x_refresh_token_hard_expires_in` attribute indicating the refresh token lifespan (in seconds) :: + + auth_client.get_bearer_token(auth_code, realm_id=realm_id, include_refresh_token_hard_expires_in=True) + +After successful response, `access_token`, `refresh_token`, `expires_in`, `x_refresh_token_expires_in`, `x_refresh_token_hard_expires_in`, etc properties of `auth_client` object are set. Step 4 (OAuth): Sample API Call +++++++++++++++++++++++++++++++ @@ -71,6 +75,10 @@ Or by passing the `refresh_token` as a parameter: :: auth_client.refresh(refresh_token='EnterRefreshTokenHere') +To include the refresh token hard expiry in the response, pass `include_refresh_token_hard_expires_in=True` :: + + auth_client.refresh(include_refresh_token_hard_expires_in=True) + Revoke Tokens ------------- diff --git a/intuitlib/client.py b/intuitlib/client.py index 739a8d2..6a80d3d 100644 --- a/intuitlib/client.py +++ b/intuitlib/client.py @@ -69,6 +69,7 @@ def __init__(self, client_id, client_secret, redirect_uri, environment, state_to self.expires_in = None self.refresh_token = refresh_token self.x_refresh_token_expires_in = None + self.x_refresh_token_hard_expires_in = None self.id_token = id_token def setAuthorizeURLs(self, urlObject): @@ -107,11 +108,13 @@ def get_authorization_url(self, scopes, state_token=None): return '?'.join([self.auth_endpoint, urlencode(url_params)]) - def get_bearer_token(self, auth_code, realm_id=None): + def get_bearer_token(self, auth_code, realm_id=None, include_refresh_token_hard_expires_in=False): """Gets access_token and refresh_token using authorization code :param auth_code: Authorization code received from redirect_uri :param realm_id: Realm ID/Company ID of the QBO company + :param include_refresh_token_hard_expires_in: When True, the response will contain the + x_refresh_token_hard_expires_in attribute indicating the refresh token lifespan :raises `intuitlib.exceptions.AuthClientError`: if response status != 200 """ @@ -124,6 +127,9 @@ def get_bearer_token(self, auth_code, realm_id=None): 'Authorization': get_auth_header(self.client_id, self.client_secret) } + if include_refresh_token_hard_expires_in: + headers['x-include-refresh-token-hard-expires-in'] = 'true' + body = { 'grant_type': 'authorization_code', 'code': auth_code, @@ -132,10 +138,12 @@ def get_bearer_token(self, auth_code, realm_id=None): send_request('POST', self.token_endpoint, headers, self, body=urlencode(body), session=self) - def refresh(self, refresh_token=None): + def refresh(self, refresh_token=None, include_refresh_token_hard_expires_in=False): """Gets fresh access_token and refresh_token :param refresh_token: Refresh Token + :param include_refresh_token_hard_expires_in: When True, the response will contain the + x_refresh_token_hard_expires_in attribute indicating the refresh token lifespan :raises ValueError: if Refresh Token value not specified :raises `intuitlib.exceptions.AuthClientError`: if response status != 200 """ @@ -149,6 +157,9 @@ def refresh(self, refresh_token=None): 'Authorization': get_auth_header(self.client_id, self.client_secret) } + if include_refresh_token_hard_expires_in: + headers['x-include-refresh-token-hard-expires-in'] = 'true' + body = { 'grant_type': 'refresh_token', 'refresh_token': token diff --git a/intuitlib/version.py b/intuitlib/version.py index f17380e..9547e7c 100644 --- a/intuitlib/version.py +++ b/intuitlib/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = '1.2.6' +__version__ = '1.2.7' From 58653df67b374a1784b37915fbcb09aee03c4adb Mon Sep 17 00:00:00 2001 From: jshanmugasu Date: Fri, 17 Jul 2026 18:09:33 +0530 Subject: [PATCH 2/3] Support for new refresh token expiry field --- docs/user-guide.rst | 22 ++++++++--------- intuitlib/client.py | 59 ++++++++++++++++++++++++++++++++++++--------- intuitlib/config.py | 8 +++++- 3 files changed, 65 insertions(+), 24 deletions(-) diff --git a/docs/user-guide.rst b/docs/user-guide.rst index b17d28c..928a995 100644 --- a/docs/user-guide.rst +++ b/docs/user-guide.rst @@ -27,18 +27,22 @@ Get authorization url by specifying list of `intuitlib.enums.Scopes` :: After user connects to the app, the callback URL has params for `state`, `auth_code` and `realm_id` (`realm_id` for Accounting and Payments scopes only) Step 3: Get Tokens and Expiry details -++++++++++++++++++++++++ ++++++++++++++++++++++++++++++++++++++ The `auth_code` from URL params from Step 2 is used to get bearer tokens. Optionally, `realm_id` is passed to set this property for `auth_client` object. :: auth_client.get_bearer_token(auth_code, realm_id=realm_id) -To include the refresh token hard expiry in the response, pass `include_refresh_token_hard_expires_in=True`. The response will contain the `x_refresh_token_hard_expires_in` attribute indicating the refresh token lifespan (in seconds) :: +Refresh Token Hard Expiry ++++++++++++++++++++++++++ + +To opt in Refresh Token Hard Expiry, call `set_include_refresh_token_hard_expires_in` on the client. + + auth_client.set_include_refresh_token_hard_expires_in(True) + +After successful response, `access_token`, `refresh_token`, `expires_in`, `x_refresh_token_expires_in`, etc properties of `auth_client` object are set. - auth_client.get_bearer_token(auth_code, realm_id=realm_id, include_refresh_token_hard_expires_in=True) -After successful response, `access_token`, `refresh_token`, `expires_in`, `x_refresh_token_expires_in`, `x_refresh_token_hard_expires_in`, etc properties of `auth_client` object are set. - Step 4 (OAuth): Sample API Call +++++++++++++++++++++++++++++++ @@ -75,9 +79,9 @@ Or by passing the `refresh_token` as a parameter: :: auth_client.refresh(refresh_token='EnterRefreshTokenHere') -To include the refresh token hard expiry in the response, pass `include_refresh_token_hard_expires_in=True` :: +To opt in Hard Expiry during refresh calls, call `set_include_refresh_token_hard_expires_in` on the client. - auth_client.refresh(include_refresh_token_hard_expires_in=True) + auth_client.set_include_refresh_token_hard_expires_in(True) Revoke Tokens ------------- @@ -118,7 +122,3 @@ In case of HTTP Errors, the client raises `intuitlib.exceptions.AuthClientError` print(e.status_code) print(e.content) print(e.intuit_tid) - - - - diff --git a/intuitlib/client.py b/intuitlib/client.py index 6a80d3d..7c74de6 100644 --- a/intuitlib/client.py +++ b/intuitlib/client.py @@ -20,6 +20,7 @@ except (ModuleNotFoundError, ImportError): from future.moves.urllib.parse import urlencode +from intuitlib.config import INCLUDE_REFRESH_TOKEN_HARD_EXPIRES_IN_HEADER from intuitlib.utils import ( get_discovery_doc, generate_token, @@ -72,6 +73,44 @@ def __init__(self, client_id, client_secret, redirect_uri, environment, state_to self.x_refresh_token_hard_expires_in = None self.id_token = id_token + # When True, token-endpoint requests include the x-include-refresh-token-hard-expires-in header so the response carries + # x_refresh_token_hard_expires_in (absolute refresh-token lifespan). + self._include_refresh_token_hard_expires_in = False + + def set_include_refresh_token_hard_expires_in(self, enabled=False): + """Opt in to receive refresh token hard expiry on token-endpoint responses. + + When enabled, every subsequent get_bearer_token/refresh call sends the + `x-include-refresh-token-hard-expires-in` header. The parsed value is + available on `auth_client.x_refresh_token_hard_expires_in`.""" + + if isinstance(enabled, bool): + self._include_refresh_token_hard_expires_in = enabled + elif isinstance(enabled, str): + normalized = enabled.strip().lower() + if normalized == 'true': + self._include_refresh_token_hard_expires_in = True + else: + self._include_refresh_token_hard_expires_in = False + else: + self._include_refresh_token_hard_expires_in = False + return self + + def get_include_refresh_token_hard_expires_in(self): + """Whether the refresh token hard expiry header is currently being sent. + :return: bool + """ + return self._include_refresh_token_hard_expires_in + + def _apply_refresh_token_hard_expires_in_header(self, headers): + """Conditionally add the hard-expires opt-in header. + Uses the instance flag set via set_include_refresh_token_hard_expires_in. + """ + + if self._include_refresh_token_hard_expires_in: + headers[INCLUDE_REFRESH_TOKEN_HARD_EXPIRES_IN_HEADER] = 'true' + return headers + def setAuthorizeURLs(self, urlObject): """Set authorization url using custom values passed in the data dict :param **data: data dict for custom authorizationURLS @@ -108,13 +147,11 @@ def get_authorization_url(self, scopes, state_token=None): return '?'.join([self.auth_endpoint, urlencode(url_params)]) - def get_bearer_token(self, auth_code, realm_id=None, include_refresh_token_hard_expires_in=False): + def get_bearer_token(self, auth_code, realm_id=None): """Gets access_token and refresh_token using authorization code :param auth_code: Authorization code received from redirect_uri :param realm_id: Realm ID/Company ID of the QBO company - :param include_refresh_token_hard_expires_in: When True, the response will contain the - x_refresh_token_hard_expires_in attribute indicating the refresh token lifespan :raises `intuitlib.exceptions.AuthClientError`: if response status != 200 """ @@ -126,9 +163,9 @@ def get_bearer_token(self, auth_code, realm_id=None, include_refresh_token_hard_ 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization': get_auth_header(self.client_id, self.client_secret) } - - if include_refresh_token_hard_expires_in: - headers['x-include-refresh-token-hard-expires-in'] = 'true' + headers = self._apply_refresh_token_hard_expires_in_header( + headers + ) body = { 'grant_type': 'authorization_code', @@ -138,12 +175,10 @@ def get_bearer_token(self, auth_code, realm_id=None, include_refresh_token_hard_ send_request('POST', self.token_endpoint, headers, self, body=urlencode(body), session=self) - def refresh(self, refresh_token=None, include_refresh_token_hard_expires_in=False): + def refresh(self, refresh_token=None): """Gets fresh access_token and refresh_token :param refresh_token: Refresh Token - :param include_refresh_token_hard_expires_in: When True, the response will contain the - x_refresh_token_hard_expires_in attribute indicating the refresh token lifespan :raises ValueError: if Refresh Token value not specified :raises `intuitlib.exceptions.AuthClientError`: if response status != 200 """ @@ -156,9 +191,9 @@ def refresh(self, refresh_token=None, include_refresh_token_hard_expires_in=Fals 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization': get_auth_header(self.client_id, self.client_secret) } - - if include_refresh_token_hard_expires_in: - headers['x-include-refresh-token-hard-expires-in'] = 'true' + headers = self._apply_refresh_token_hard_expires_in_header( + headers + ) body = { 'grant_type': 'refresh_token', diff --git a/intuitlib/config.py b/intuitlib/config.py index 9ce1086..45aa020 100644 --- a/intuitlib/config.py +++ b/intuitlib/config.py @@ -36,4 +36,10 @@ ACCEPT_HEADER = { 'Accept': 'application/json', 'User-Agent': '{0}-{1}-{2}-{3} {4} {5} {6}'.format('Intuit-OAuthClient', version.__version__,'Python', PYTHON_VERSION, OS_SYSTEM, OS_RELEASE_VER, OS_MACHINE) -} \ No newline at end of file +} + +# Header to request refresh token hard expiry info on token-endpoint calls +INCLUDE_REFRESH_TOKEN_HARD_EXPIRES_IN_HEADER = 'x-include-refresh-token-hard-expires-in' + +# Response field key for the refresh token hard expiry lifespan (seconds) +X_REFRESH_TOKEN_HARD_EXPIRES_IN = 'x_refresh_token_hard_expires_in' \ No newline at end of file From ae1868d59575f3c74f0dd4098e09c2e32699367a Mon Sep 17 00:00:00 2001 From: jshanmugasu Date: Wed, 22 Jul 2026 00:00:00 +0530 Subject: [PATCH 3/3] Support for new refresh token expiry field --- docs/user-guide.rst | 20 +++++++++----------- intuitlib/client.py | 26 ++++++++++++-------------- 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/docs/user-guide.rst b/docs/user-guide.rst index 928a995..a5efd94 100644 --- a/docs/user-guide.rst +++ b/docs/user-guide.rst @@ -17,6 +17,12 @@ Step 1: Instantiate AuthClient object Valid values for environment include `sandbox` and `production`. `redirect_uri` should be set in your Intuit Developer app's Keys tab under the right environment. +Track refresh token hard expiry (x_refresh_token_hard_expires_in) on both token and refresh responses. + +To opt in, set `include_refresh_token_hard_expires_in` on the client to True:: + + auth_client.include_refresh_token_hard_expires_in = True + Step 2: Get Authorization URL +++++++++++++++++++++++++++++ @@ -33,14 +39,8 @@ The `auth_code` from URL params from Step 2 is used to get bearer tokens. Option auth_client.get_bearer_token(auth_code, realm_id=realm_id) -Refresh Token Hard Expiry -+++++++++++++++++++++++++ - -To opt in Refresh Token Hard Expiry, call `set_include_refresh_token_hard_expires_in` on the client. - - auth_client.set_include_refresh_token_hard_expires_in(True) - -After successful response, `access_token`, `refresh_token`, `expires_in`, `x_refresh_token_expires_in`, etc properties of `auth_client` object are set. +After successful response, `access_token`, `refresh_token`, `expires_in`, `x_refresh_token_expires_in`, `x_refresh_token_hard_expires_in`, +etc properties of `auth_client` object are set. Step 4 (OAuth): Sample API Call @@ -79,9 +79,7 @@ Or by passing the `refresh_token` as a parameter: :: auth_client.refresh(refresh_token='EnterRefreshTokenHere') -To opt in Hard Expiry during refresh calls, call `set_include_refresh_token_hard_expires_in` on the client. - - auth_client.set_include_refresh_token_hard_expires_in(True) +If you opted in in Step 1, `refresh()` also returns `x_refresh_token_hard_expires_in` when the server provides it. Revoke Tokens ------------- diff --git a/intuitlib/client.py b/intuitlib/client.py index 7c74de6..ab08b48 100644 --- a/intuitlib/client.py +++ b/intuitlib/client.py @@ -77,13 +77,18 @@ def __init__(self, client_id, client_secret, redirect_uri, environment, state_to # x_refresh_token_hard_expires_in (absolute refresh-token lifespan). self._include_refresh_token_hard_expires_in = False - def set_include_refresh_token_hard_expires_in(self, enabled=False): - """Opt in to receive refresh token hard expiry on token-endpoint responses. + @property + def include_refresh_token_hard_expires_in(self): + """Whether token-endpoint requests send the hard-expires opt-in header. - When enabled, every subsequent get_bearer_token/refresh call sends the - `x-include-refresh-token-hard-expires-in` header. The parsed value is - available on `auth_client.x_refresh_token_hard_expires_in`.""" + When True, every subsequent get_bearer_token/refresh call sends + `x-include-refresh-token-hard-expires-in`. The parsed value is available + on `auth_client.x_refresh_token_hard_expires_in`. + """ + return self._include_refresh_token_hard_expires_in + @include_refresh_token_hard_expires_in.setter + def include_refresh_token_hard_expires_in(self, enabled): if isinstance(enabled, bool): self._include_refresh_token_hard_expires_in = enabled elif isinstance(enabled, str): @@ -96,17 +101,10 @@ def set_include_refresh_token_hard_expires_in(self, enabled=False): self._include_refresh_token_hard_expires_in = False return self - def get_include_refresh_token_hard_expires_in(self): - """Whether the refresh token hard expiry header is currently being sent. - :return: bool - """ - return self._include_refresh_token_hard_expires_in - def _apply_refresh_token_hard_expires_in_header(self, headers): """Conditionally add the hard-expires opt-in header. - Uses the instance flag set via set_include_refresh_token_hard_expires_in. + Uses the instance flag include_refresh_token_hard_expires_in. """ - if self._include_refresh_token_hard_expires_in: headers[INCLUDE_REFRESH_TOKEN_HARD_EXPIRES_IN_HEADER] = 'true' return headers @@ -163,6 +161,7 @@ def get_bearer_token(self, auth_code, realm_id=None): 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization': get_auth_header(self.client_id, self.client_secret) } + headers = self._apply_refresh_token_hard_expires_in_header( headers ) @@ -194,7 +193,6 @@ def refresh(self, refresh_token=None): headers = self._apply_refresh_token_hard_expires_in_header( headers ) - body = { 'grant_type': 'refresh_token', 'refresh_token': token