From 236cc6eab9bff635f0257103cf6e51b41661c3eb Mon Sep 17 00:00:00 2001 From: Chengbiao Jin Date: Mon, 18 May 2026 19:23:37 -0700 Subject: [PATCH 1/3] Fix authentication for password-only connections to REST++-protected instances - Password-only connections automatically mint a token and retry when the server signals that a token is required. - getToken() correctly stores the token string on the connection; every subsequent request now authenticates as expected. - refreshToken() correctly applies the refreshed token to the connection. - Token mint requests prefer the modern endpoint, falling back to the legacy one only on failure. --- CHANGELOG.md | 11 +++++++ pyTigerGraph/pyTigerGraphAuth.py | 23 ++++++++------ pyTigerGraph/pyTigerGraphBase.py | 35 ++++++++++++++-------- pyTigerGraph/pytgasync/pyTigerGraphAuth.py | 8 +++-- pyTigerGraph/pytgasync/pyTigerGraphBase.py | 29 ++++++++++-------- 5 files changed, 71 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b51804e..057f8345 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.0.4] - 2026-05-18 + +### Fixed + +- **Authentication with username/password on instances with REST++ authentication enabled** — connections constructed with only `username` and `password` (no `apiToken`) now work against any TigerGraph instance that has REST++ authentication enabled. The library automatically mints a token and retries the request when the server signals that a token is required. +- **`getToken()` correctly applies the token to the connection** — the connection's `apiToken` is now the token string. Previously it was stored as an internal tuple `(token, expiration)`, leaving every subsequent request unauthenticated. +- **`refreshToken()` correctly applies the refreshed token** — the connection is now updated to use the refreshed token. Previously the call returned successfully but the connection silently continued using the old token. +- **Token mint prefers the modern endpoint** — token requests try the TigerGraph 4.x endpoint (`POST /gsql/v1/tokens`) first and fall back to the legacy endpoint (`POST /restpp/requesttoken`) on failure, matching the order already used by the async client. + +--- + ## [2.0.3] - 2026-04-09 ### New Features diff --git a/pyTigerGraph/pyTigerGraphAuth.py b/pyTigerGraph/pyTigerGraphAuth.py index f39e3fb7..9d3f67d2 100644 --- a/pyTigerGraph/pyTigerGraphAuth.py +++ b/pyTigerGraph/pyTigerGraphAuth.py @@ -348,16 +348,17 @@ def _token(self, secret: str = None, lifetime: int = None, token: str = None, _m if _method: method = _method - # Try using TG 3.x endpoint first, if url not found then try <4.1 endpoint + # Try TG 4.x endpoint first (POST /gsql/v1/tokens); fall back to + # the legacy TG 3.x endpoint (POST /restpp/requesttoken) on failure. try: - res = self._req( - method, alt_url, authMode=authMode, data=alt_data, resKey=None) - mainVer = 3 + res = self._req(method, url, authMode=authMode, + data=data, resKey=None, jsonData=True) + mainVer = 4 except: try: - res = self._req(method, url, authMode=authMode, - data=data, resKey=None, jsonData=True) - mainVer = 4 + res = self._req( + method, alt_url, authMode=authMode, data=alt_data, resKey=None) + mainVer = 3 except requests.exceptions.HTTPError as e: if e.response.status_code == 404: raise TigerGraphException( @@ -420,10 +421,11 @@ def getToken(self, mainVer, self.base64_credential ) - self.apiToken = token + self.apiToken = token[0] if isinstance(token, tuple) else token self.authHeader = auth_header self.authMode = "token" self._token_source = "generated" + self._refresh_auth_headers() logger.debug("exit: getToken") return token @@ -480,7 +482,10 @@ def refreshToken(self, secret: str = None, setToken: bool = True, lifetime: int token = self.apiToken res, mainVer = self._token(secret, lifetime, token, "PUT") - newToken = _parse_token_response(res, setToken, mainVer, self.base64_credential) + newToken, auth_header = _parse_token_response(res, setToken, mainVer, self.base64_credential) + self.apiToken = newToken[0] if isinstance(newToken, tuple) else newToken + self.authHeader = auth_header + self._refresh_auth_headers() logger.debug("exit: refreshToken") diff --git a/pyTigerGraph/pyTigerGraphBase.py b/pyTigerGraph/pyTigerGraphBase.py index 477b98a0..b6e37f2f 100644 --- a/pyTigerGraph/pyTigerGraphBase.py +++ b/pyTigerGraph/pyTigerGraphBase.py @@ -222,14 +222,31 @@ def _req(self, method: str, url: str, authMode: str = "token", headers: dict = N conn_err = e if res is not None: - # Auto-refresh token on 401 if the token was generated by getToken() - if res.status_code == 401 and getattr(self, "_token_source", None) == "generated": + # Auto-mint/refresh token and retry once when the server signals the request + # needs a Bearer token. Two trigger shapes: + # * HTTP 401 (only when we previously generated a token — preserves the + # historical behavior of not silently replacing a user-supplied token). + # * REST-10016 in the JSON body — REST++ application-level "token missing + # or empty" returned on a non-2xx status (e.g. by TigerGraph Cloud). Always + # retry; the server is unambiguously asking for a token and a successful + # mint resolves it. + needs_token_retry = False + if not getattr(self, "_refreshing_token", False): + if res.status_code == 401 and getattr(self, "_token_source", None) == "generated": + needs_token_retry = True + elif res.content: + try: + _body = json.loads(res.content) + except (json.decoder.JSONDecodeError, ValueError): + _body = None + if isinstance(_body, dict) and _body.get("error") and _body.get("code") == "REST-10016": + needs_token_retry = True + if needs_token_retry: with self._token_refresh_lock: if not getattr(self, "_refreshing_token", False): try: self._refreshing_token = True self.getToken() - self._refresh_auth_headers() finally: self._refreshing_token = False _headers, _data, _ = self._prep_req(authMode, headers, url, method, data) @@ -583,15 +600,9 @@ def _version_greater_than_4_0(self) -> bool: if hasattr(self, '_cached_version_greater_than_4_0'): return self._cached_version_greater_than_4_0 - # Cache not set, fetch version and cache the result - try: - version = self.getVer().split('.') - except TigerGraphException as e: - if e.code == "REST-10016": - self.getToken() - version = self.getVer().split('.') - else: - raise e + # The generic _req-level retry handles REST-10016 (mint a token, retry once), + # so no ad-hoc recovery is needed here. + version = self.getVer().split('.') result = version[0] >= "4" and version[1] > "0" self._cached_version_greater_than_4_0 = result return result diff --git a/pyTigerGraph/pytgasync/pyTigerGraphAuth.py b/pyTigerGraph/pytgasync/pyTigerGraphAuth.py index 99d3400d..f528e687 100644 --- a/pyTigerGraph/pytgasync/pyTigerGraphAuth.py +++ b/pyTigerGraph/pytgasync/pyTigerGraphAuth.py @@ -368,10 +368,11 @@ async def getToken(self, secret: str = None, setToken: bool = True, lifetime: in self.base64_credential ) - self.apiToken = token + self.apiToken = token[0] if isinstance(token, tuple) else token self.authHeader = auth_header self.authMode = "token" self._token_source = "generated" + self._refresh_auth_headers() logger.debug("exit: getToken") return token @@ -389,7 +390,10 @@ async def refreshToken(self, secret: str = None, setToken: bool = True, lifetime if not token: token = self.apiToken res, mainVer = await self._token(secret=secret, lifetime=lifetime, token=token, _method="PUT") - newToken = _parse_token_response(res, setToken, mainVer) + newToken, auth_header = _parse_token_response(res, setToken, mainVer, self.base64_credential) + self.apiToken = newToken[0] if isinstance(newToken, tuple) else newToken + self.authHeader = auth_header + self._refresh_auth_headers() logger.debug("exit: refreshToken") return newToken diff --git a/pyTigerGraph/pytgasync/pyTigerGraphBase.py b/pyTigerGraph/pytgasync/pyTigerGraphBase.py index af54d31d..5944426e 100644 --- a/pyTigerGraph/pytgasync/pyTigerGraphBase.py +++ b/pyTigerGraph/pytgasync/pyTigerGraphBase.py @@ -163,14 +163,25 @@ async def _req(self, method: str, url: str, authMode: str = "token", headers: di conn_err = e if resp is not None: - # Auto-refresh token on 401 if the token was generated by getToken() - if status == 401 and getattr(self, "_token_source", None) == "generated": + # Auto-mint/refresh token and retry once when the server signals the request + # needs a Bearer token. See sync _req for full rationale. + needs_token_retry = False + if not getattr(self, "_refreshing_token", False): + if status == 401 and getattr(self, "_token_source", None) == "generated": + needs_token_retry = True + elif body: + try: + _body = json.loads(body) + except (json.decoder.JSONDecodeError, ValueError): + _body = None + if isinstance(_body, dict) and _body.get("error") and _body.get("code") == "REST-10016": + needs_token_retry = True + if needs_token_retry: async with self._token_refresh_lock: if not getattr(self, "_refreshing_token", False): try: self._refreshing_token = True await self.getToken() - self._refresh_auth_headers() finally: self._refreshing_token = False _headers, _data, _ = self._prep_req(authMode, headers, url, method, data) @@ -554,15 +565,9 @@ async def _version_greater_than_4_0(self) -> bool: if hasattr(self, '_cached_version_greater_than_4_0'): return self._cached_version_greater_than_4_0 - # Cache not set, fetch version and cache the result - try: - version = (await self.getVer()).split('.') - except TigerGraphException as e: - if e.code == "REST-10016": - await self.getToken() - version = (await self.getVer()).split('.') - else: - raise e + # The generic _req-level retry handles REST-10016 (mint a token, retry once), + # so no ad-hoc recovery is needed here. + version = (await self.getVer()).split('.') result = version[0] >= "4" and version[1] > "0" self._cached_version_greater_than_4_0 = result return result From 6dc43f1688cc332cf4a83a6dc7b66a1b1dc9deb8 Mon Sep 17 00:00:00 2001 From: Chengbiao Jin Date: Mon, 18 May 2026 19:51:12 -0700 Subject: [PATCH 2/3] Bump version to 2.0.4 --- pyTigerGraph/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyTigerGraph/__init__.py b/pyTigerGraph/__init__.py index 18674644..189e9644 100644 --- a/pyTigerGraph/__init__.py +++ b/pyTigerGraph/__init__.py @@ -7,7 +7,7 @@ try: __version__ = _pkg_version("pyTigerGraph") except PackageNotFoundError: - __version__ = "2.0.2" + __version__ = "2.0.4" __license__ = "Apache 2" diff --git a/pyproject.toml b/pyproject.toml index 1e6831b1..d3ca2043 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pyTigerGraph" -version = "2.0.3" +version = "2.0.4" description = "Library to connect to TigerGraph databases" readme = "README.md" license = "Apache-2.0" From b36aee6d8f821a0b5b7a2a2a707ec8a1b48ddddc Mon Sep 17 00:00:00 2001 From: Chengbiao Jin Date: Mon, 18 May 2026 20:16:16 -0700 Subject: [PATCH 3/3] Tighten auth-failure detection and protect user-supplied tokens - Auto-mint a token on auth failure only when the connection's credentials are library-managed; surface the error when the user supplied an explicit token, instead of silently replacing it. - Detect auth failure from both REST++ (REST-10016) and GSQL ("Authentication failed.") response bodies, in addition to HTTP 401. - Fall back from the modern token endpoint to the legacy one only when the modern endpoint is genuinely absent (HTTP 404 or REST-1000 "Endpoint is not found"); preserve the original error otherwise. --- pyTigerGraph/common/auth.py | 46 ++++++++++++++++++++++ pyTigerGraph/pyTigerGraphAuth.py | 21 ++++++---- pyTigerGraph/pyTigerGraphBase.py | 21 +++++----- pyTigerGraph/pytgasync/pyTigerGraphAuth.py | 20 +++++++--- pyTigerGraph/pytgasync/pyTigerGraphBase.py | 8 ++-- 5 files changed, 91 insertions(+), 25 deletions(-) diff --git a/pyTigerGraph/common/auth.py b/pyTigerGraph/common/auth.py index 398b2643..838da7ec 100644 --- a/pyTigerGraph/common/auth.py +++ b/pyTigerGraph/common/auth.py @@ -8,6 +8,52 @@ logger = logging.getLogger(__name__) + +def _is_auth_failure_response(body) -> bool: + """True if `body` (a parsed JSON dict, or anything else) is a TigerGraph + error response signaling that the request lacks valid authentication. + Multiple shapes are accepted, since REST++ and GSQL surface auth failures + differently: + + * REST++ `code == "REST-10016"` ("Access Denied because the input + token = '' is empty or too short"). + * Plain ``{"error": true, "message": "Authentication failed."}`` — + returned by GSQL endpoints with no `code` field when the request + carries no credentials. + """ + if not isinstance(body, dict) or not body.get("error"): + return False + if body.get("code") == "REST-10016": + return True + msg = body.get("message") + return isinstance(msg, str) and "Authentication failed" in msg + + +def _is_endpoint_not_found(exc: Exception) -> bool: + """True if `exc` looks like the server reporting that the requested URL + isn't served. Two shapes are accepted: + + * HTTP 404. Surfaced as ``requests.exceptions.HTTPError`` on the sync + path and ``aiohttp.ClientResponseError`` on the async path. We + duck-type on the status attribute to avoid importing either lib here. + * ``TigerGraphException`` carrying REST++ code ``REST-1000`` with the + canonical "Endpoint is not found" message in its first argument. + """ + status = None + response = getattr(exc, "response", None) + if response is not None: + status = getattr(response, "status_code", None) + if status is None: + status = getattr(exc, "status", None) + if status == 404: + return True + if isinstance(exc, TigerGraphException): + if getattr(exc, "code", None) != "REST-1000": + return False + msg = exc.args[0] if exc.args else "" + return isinstance(msg, str) and "Endpoint is not found" in msg + return False + def _parse_get_secrets(response: str) -> Dict[str, str]: secrets_dict = {} lines = response.split("\n") diff --git a/pyTigerGraph/pyTigerGraphAuth.py b/pyTigerGraph/pyTigerGraphAuth.py index 9d3f67d2..3c6579fc 100644 --- a/pyTigerGraph/pyTigerGraphAuth.py +++ b/pyTigerGraph/pyTigerGraphAuth.py @@ -13,7 +13,8 @@ _parse_get_secrets, _parse_create_secret, _prep_token_request, - _parse_token_response + _parse_token_response, + _is_endpoint_not_found, ) from pyTigerGraph.common.exception import TigerGraphException from pyTigerGraph.pyTigerGraphGSQL import pyTigerGraphGSQL @@ -348,25 +349,29 @@ def _token(self, secret: str = None, lifetime: int = None, token: str = None, _m if _method: method = _method - # Try TG 4.x endpoint first (POST /gsql/v1/tokens); fall back to - # the legacy TG 3.x endpoint (POST /restpp/requesttoken) on failure. + # Try TG 4.x endpoint first (POST /gsql/v1/tokens); fall back to the + # legacy TG 3.x endpoint (POST /restpp/requesttoken) only when the + # modern endpoint is genuinely absent on this server. "Endpoint not + # found" can surface either as an HTTP 404 or as a 2xx response with + # REST-1000 in the JSON body — _is_endpoint_not_found handles both. try: res = self._req(method, url, authMode=authMode, data=data, resKey=None, jsonData=True) mainVer = 4 - except: + except Exception as e: + if not _is_endpoint_not_found(e): + raise try: res = self._req( method, alt_url, authMode=authMode, data=alt_data, resKey=None) mainVer = 3 - except requests.exceptions.HTTPError as e: - if e.response.status_code == 404: + except Exception as e2: + if _is_endpoint_not_found(e2): raise TigerGraphException( "Error requesting token. Check if the connection's graphname is correct and that REST authentication is enabled.", 404 ) - else: - raise e + raise # uses mainVer instead of _versionGreaterThan4_0 since you need a token for verson checking return res, mainVer diff --git a/pyTigerGraph/pyTigerGraphBase.py b/pyTigerGraph/pyTigerGraphBase.py index b6e37f2f..e623197b 100644 --- a/pyTigerGraph/pyTigerGraphBase.py +++ b/pyTigerGraph/pyTigerGraphBase.py @@ -33,6 +33,7 @@ from typing import Union from urllib.parse import urlparse +from pyTigerGraph.common.auth import _is_auth_failure_response from pyTigerGraph.common.exception import TigerGraphException from pyTigerGraph.common.base import PyTigerGraphCore @@ -224,22 +225,24 @@ def _req(self, method: str, url: str, authMode: str = "token", headers: dict = N if res is not None: # Auto-mint/refresh token and retry once when the server signals the request # needs a Bearer token. Two trigger shapes: - # * HTTP 401 (only when we previously generated a token — preserves the - # historical behavior of not silently replacing a user-supplied token). - # * REST-10016 in the JSON body — REST++ application-level "token missing - # or empty" returned on a non-2xx status (e.g. by TigerGraph Cloud). Always - # retry; the server is unambiguously asking for a token and a successful - # mint resolves it. + # * HTTP 401 + # * an auth-failure JSON body — REST++ REST-10016 or a GSQL + # `{"error":true,"message":"Authentication failed."}` shape. + # In both cases, only retry when the connection's credentials are ours to + # manage (no user-supplied apiToken/jwtToken). A user-supplied token is + # treated as deliberate: we surface the auth error rather than silently + # replacing the user's token with a freshly-minted one. needs_token_retry = False - if not getattr(self, "_refreshing_token", False): - if res.status_code == 401 and getattr(self, "_token_source", None) == "generated": + if (not getattr(self, "_refreshing_token", False) + and getattr(self, "_token_source", None) != "user"): + if res.status_code == 401: needs_token_retry = True elif res.content: try: _body = json.loads(res.content) except (json.decoder.JSONDecodeError, ValueError): _body = None - if isinstance(_body, dict) and _body.get("error") and _body.get("code") == "REST-10016": + if _is_auth_failure_response(_body): needs_token_retry = True if needs_token_retry: with self._token_refresh_lock: diff --git a/pyTigerGraph/pytgasync/pyTigerGraphAuth.py b/pyTigerGraph/pytgasync/pyTigerGraphAuth.py index f528e687..2473d5b4 100644 --- a/pyTigerGraph/pytgasync/pyTigerGraphAuth.py +++ b/pyTigerGraph/pytgasync/pyTigerGraphAuth.py @@ -11,7 +11,8 @@ _parse_get_secrets, _parse_create_secret, _prep_token_request, - _parse_token_response + _parse_token_response, + _is_endpoint_not_found, ) from pyTigerGraph.pytgasync.pyTigerGraphGSQL import AsyncPyTigerGraphGSQL @@ -342,16 +343,25 @@ async def _token(self, secret: str = None, lifetime: int = None, token=None, _me if _method: method = _method - # Try using TG 4.1 endpoint first, if url not found then try <4.1 endpoint + # Try TG 4.x endpoint first; fall back to the legacy TG 3.x endpoint + # only when the modern endpoint is genuinely absent on this server. + # See sync _token for the rationale and the helper's contract. try: res = await self._req(method, url, authMode=authMode, data=data, resKey=None, jsonData=True) mainVer = 4 - except: + except Exception as e: + if not _is_endpoint_not_found(e): + raise try: res = await self._req(method, alt_url, authMode=authMode, data=alt_data, resKey=None) mainVer = 3 - except: - raise TigerGraphException("Error requesting token. Check if the connection's graphname is correct.", 400) + except Exception as e2: + if _is_endpoint_not_found(e2): + raise TigerGraphException( + "Error requesting token. Check if the connection's graphname is correct and that REST authentication is enabled.", + 404 + ) + raise # uses mainVer instead of _versionGreaterThan4_0 since you need a token for verson checking return res, mainVer diff --git a/pyTigerGraph/pytgasync/pyTigerGraphBase.py b/pyTigerGraph/pytgasync/pyTigerGraphBase.py index 5944426e..af4e8c05 100644 --- a/pyTigerGraph/pytgasync/pyTigerGraphBase.py +++ b/pyTigerGraph/pytgasync/pyTigerGraphBase.py @@ -34,6 +34,7 @@ from typing import Optional, Union from urllib.parse import urlparse +from pyTigerGraph.common.auth import _is_auth_failure_response from pyTigerGraph.common.base import PyTigerGraphCore from pyTigerGraph.common.exception import TigerGraphException @@ -166,15 +167,16 @@ async def _req(self, method: str, url: str, authMode: str = "token", headers: di # Auto-mint/refresh token and retry once when the server signals the request # needs a Bearer token. See sync _req for full rationale. needs_token_retry = False - if not getattr(self, "_refreshing_token", False): - if status == 401 and getattr(self, "_token_source", None) == "generated": + if (not getattr(self, "_refreshing_token", False) + and getattr(self, "_token_source", None) != "user"): + if status == 401: needs_token_retry = True elif body: try: _body = json.loads(body) except (json.decoder.JSONDecodeError, ValueError): _body = None - if isinstance(_body, dict) and _body.get("error") and _body.get("code") == "REST-10016": + if _is_auth_failure_response(_body): needs_token_retry = True if needs_token_retry: async with self._token_refresh_lock: