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
151 changes: 127 additions & 24 deletions nodes/src/nodes/tool_gmail/gmail_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,11 @@ def resolve_refresh_url(token_url: object) -> str | None:
allowed = set(_BROKER_HOSTS)
env_broker = os.environ.get('RR_OAUTH_BROKER_URL', '')
if env_broker:
env_host = urlparse(env_broker).hostname
# A schemeless value (e.g. "broker.example.com") parses with
# hostname=None and would be silently dropped from the allowlist, so
# add a scheme before parsing when one is missing.
to_parse = env_broker if '://' in env_broker else f'https://{env_broker}'
env_host = urlparse(to_parse).hostname
if env_host:
allowed.add(env_host.lower())

Expand All @@ -87,9 +91,55 @@ def resolve_refresh_url(token_url: object) -> str | None:
return token_url


# Google's canonical OAuth token endpoint, used when a token omits token_uri.
_GOOGLE_TOKEN_URI = 'https://oauth2.googleapis.com/token'

# Hosts the google-auth library may POST the refresh_token to when minting a
# new access token. Like the broker URL, token_uri rides in the untrusted token
# payload, so a token-supplied value is honored only if it is https and its host
# is one of Google's official token endpoints — otherwise a tampered token could
# redirect the refresh_token to an attacker host.
_GOOGLE_TOKEN_HOSTS = frozenset({'oauth2.googleapis.com', 'accounts.google.com'})


def resolve_token_uri(token_uri: object) -> str:
"""Validate a token-supplied OAuth token endpoint against Google's hosts.

``token_uri`` is where the google-auth library POSTs the ``refresh_token``
to obtain a fresh access token. The token payload is untrusted (it rides in
saved pipes and broker responses), so a tampered token could point this at
an attacker host and exfiltrate the refresh_token.

Returns the URL when it is https and its host is one of Google's official
token endpoints. Falls back to the canonical endpoint when the token omits
the field. Raises ValueError for any other value so a tampered token fails
loud instead of silently posting credentials elsewhere.
"""
# Only an absent value (None or empty string) falls back; other falsy
# non-strings (0, False, [], {}) are tampering and must fail loud.
if token_uri is None or token_uri == '':
return _GOOGLE_TOKEN_URI
if not isinstance(token_uri, str):
raise ValueError('Gmail token token_uri must be a string')

parsed = urlparse(token_uri)
if parsed.scheme != 'https' or not parsed.hostname or parsed.hostname.lower() not in _GOOGLE_TOKEN_HOSTS:
raise ValueError(
f'Gmail token token_uri {token_uri!r} is not a trusted Google OAuth token endpoint '
'(expected https and one of: ' + ', '.join(sorted(_GOOGLE_TOKEN_HOSTS)) + '). '
'Reconnect your Google account.'
)
return token_uri


# HTTP status codes worth retrying (rate-limit + transient server errors).
_RETRY_STATUSES = {429, 500, 502, 503, 504}

# Fallback access-token lifetime when the broker response omits expiry_date.
# Matches Google's standard access-token lifetime; without it the credential
# keeps a stale past expiry and google-auth refreshes on every API call.
_DEFAULT_ACCESS_TOKEN_LIFETIME_S = 3600

# Google's full-access Gmail scope — a superset of all granular Gmail scopes.
_GMAIL_FULL_SCOPE = 'https://mail.google.com/'

Expand Down Expand Up @@ -118,6 +168,78 @@ def _decode_blob(value: str) -> str:
return value


def _refresh_access_token_via_broker(broker_url: str, refresh_token: str):
"""POST the refresh_token to the OAuth broker and return (access_token, expiry).

Every failure is surfaced as a google-auth ``RefreshError`` with a clear
reconnect message rather than leaking a raw transport error or silently
keeping a stale token:

- An HTTP error status means the broker was reached but rejected the
request — it must not be misreported as "broker unreachable" (an
``HTTPError`` is a subclass of ``URLError``, so it is matched first).
- A connection failure or a socket timeout during ``read()`` (an
``OSError``, not a ``URLError``) is reported as unreachable.
- A non-JSON or malformed body is reported as an invalid response.
- A 200 without ``access_token`` raises instead of keeping the stale token.
- A 200 without ``expiry_date`` falls back to a default lifetime instead of
leaving a stale past expiry that forces a refresh on every call.

Returns:
tuple[str, datetime.datetime]: the new access token and its expiry.
"""
import datetime as _dt
import urllib.error as _uerr
import urllib.request as _req

import google.auth.exceptions as _gae

body = json.dumps({'refresh_token': refresh_token}).encode()
req = _req.Request(broker_url, data=body, headers={'Content-Type': 'application/json'}, method='POST')
try:
with _req.urlopen(req, timeout=10) as resp:
raw = resp.read()
data = json.loads(raw.decode())
except _uerr.HTTPError as exc:
raise _gae.RefreshError(
f'Gmail token refresh was rejected by the broker (HTTP {exc.code}). Please reconnect your Google account.'
) from exc
except (_uerr.URLError, OSError) as exc:
raise _gae.RefreshError(
f'Gmail token refresh failed (broker unreachable: {exc}). Please reconnect your Google account.'
) from exc
except ValueError as exc:
raise _gae.RefreshError(
'Gmail token refresh returned an invalid response from the broker. Please reconnect your Google account.'
) from exc

# Valid JSON that is not an object (null, a number, a list) would crash
# data.get(...) with AttributeError — treat it as an invalid response.
if not isinstance(data, dict):
raise _gae.RefreshError(
'Gmail token refresh returned an invalid response from the broker. Please reconnect your Google account.'
)

access_token = data.get('access_token')
if not isinstance(access_token, str) or not access_token:
raise _gae.RefreshError(
'Gmail token refresh returned no access token from the broker. Please reconnect your Google account.'
)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
ms = data.get('expiry_date')
if ms is None:
expiry = _dt.datetime.utcnow() + _dt.timedelta(seconds=_DEFAULT_ACCESS_TOKEN_LIFETIME_S)
elif isinstance(ms, bool) or not isinstance(ms, (int, float)):
# A non-numeric expiry_date would raise a raw TypeError during
# timestamp conversion — surface it as an invalid response instead.
raise _gae.RefreshError(
'Gmail token refresh returned an invalid response from the broker. Please reconnect your Google account.'
)
else:
expiry = _dt.datetime.utcfromtimestamp(ms / 1000)
return access_token, expiry


def build_service(auth_type: str, cfg: dict, scopes: list[str]) -> Any:
"""Build a Gmail v1 service from node config, scoped to ``scopes``.

Expand All @@ -136,7 +258,9 @@ def build_service(auth_type: str, cfg: dict, scopes: list[str]) -> Any:
refresh_token = info.get('refresh_token')
client_id = info.get('client_id')
client_secret = info.get('client_secret')
token_uri = info.get('token_uri', 'https://oauth2.googleapis.com/token')
# Untrusted input: reject any token endpoint not owned by Google so a
# tampered token can never redirect the refresh_token POST.
token_uri = resolve_token_uri(info.get('token_uri'))
# Untrusted input: reject any refresh URL not pointing at a known broker.
oauth_server_url = resolve_refresh_url(info.get('oauth_server_url'))
expiry_date_ms = info.get('expiry_date')
Expand Down Expand Up @@ -182,34 +306,13 @@ def build_service(auth_type: str, cfg: dict, scopes: list[str]) -> Any:

class _BrokerCredentials(Credentials):
def refresh(self, request: object) -> None: # type: ignore[override]
import urllib.error as _uerr
import urllib.request as _req

import google.auth.exceptions as _gae

if not _broker_url or not self.refresh_token:
raise _gae.RefreshError(
'Gmail access token has expired. Please reconnect your Google account in the node settings.'
)
body = json.dumps({'refresh_token': self.refresh_token}).encode()
req = _req.Request(
_broker_url,
data=body,
headers={'Content-Type': 'application/json'},
method='POST',
)
try:
with _req.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode())
except _uerr.URLError as exc:
raise _gae.RefreshError(
f'Gmail token refresh failed (broker unreachable: {exc}). '
'Please reconnect your Google account.'
) from exc
self.token = data.get('access_token') or self.token
ms = data.get('expiry_date')
if ms:
self.expiry = _dt.datetime.utcfromtimestamp(ms / 1000)
self.token, self.expiry = _refresh_access_token_via_broker(_broker_url, self.refresh_token)

creds = _BrokerCredentials(
token=access_token,
Expand Down
6 changes: 6 additions & 0 deletions nodes/test/mocks/google/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# =============================================================================
# MIT License
# Copyright (c) 2026 Aparavi Software AG
# =============================================================================

"""Mock google.auth package (exceptions) for tool_gmail broker-refresh tests."""
19 changes: 19 additions & 0 deletions nodes/test/mocks/google/auth/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# =============================================================================
# MIT License
# Copyright (c) 2026 Aparavi Software AG
# =============================================================================

"""Minimal stand-in for google.auth.exceptions used in tests.

Only ``RefreshError`` is needed: the Gmail broker-refresh path raises it to
signal a failed token refresh, and the real ``google-auth`` package is not
installed in the node test environment.
"""


class GoogleAuthError(Exception):
"""Base class mirroring google.auth.exceptions.GoogleAuthError."""


class RefreshError(GoogleAuthError):
"""Raised when a credential refresh fails (mirrors the real RefreshError)."""
Loading
Loading