diff --git a/src/backend/services/agent_gmail_oauth_service.py b/src/backend/services/agent_gmail_oauth_service.py index 3f77f4f6..4baedeba 100644 --- a/src/backend/services/agent_gmail_oauth_service.py +++ b/src/backend/services/agent_gmail_oauth_service.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from datetime import datetime from typing import TYPE_CHECKING, Any, Optional -from urllib.parse import urlparse +from urllib.parse import urlparse, urlsplit from google_auth_oauthlib.flow import Flow @@ -184,7 +184,15 @@ def _temporary_env(var_name: str, value: str): # superset of the requested scopes (e.g., Google returning OIDC scopes). # This is benign for our use case; accept and continue. with _temporary_env("OAUTHLIB_RELAX_TOKEN_SCOPE", "1"): - flow.fetch_token(authorization_response=authorisation_response_url) + # A TLS-terminating proxy can make request.url appear to use HTTP. + # Use the same operator-configured/start-bound callback as the flow, + # preserving the provider's encoded query without trusting callback + # Host or forwarded headers to choose the response origin. + callback = urlsplit(self._get_redirect_uri(redirect_uri)) + response_url = callback._replace( + query=urlsplit(authorisation_response_url).query, fragment="" + ).geturl() + flow.fetch_token(authorization_response=response_url) creds = flow.credentials if creds is None: diff --git a/tests/backend/test_agent_gmail_oauth_service_scope_relax.py b/tests/backend/test_agent_gmail_oauth_service_scope_relax.py index 6609264a..d09c9b35 100644 --- a/tests/backend/test_agent_gmail_oauth_service_scope_relax.py +++ b/tests/backend/test_agent_gmail_oauth_service_scope_relax.py @@ -4,12 +4,55 @@ import pytest from oauthlib.oauth2.rfc6749.parameters import parse_token_response +from oauthlib.oauth2 import WebApplicationClient from src.backend.services import agent_gmail_oauth_service as oauth_module from src.backend.services.agent_gmail_oauth_service import AgentGmailOAuthService from src.backend.integrations.google import gmail_service as gs +def test_token_exchange_uses_configured_https_callback_behind_proxy(monkeypatch): + callback = "https://von.example.test/von/api/agent/gmail/oauth/callback" + monkeypatch.setenv("VON_AGENT_GMAIL_OAUTH_REDIRECT_URI", callback) + monkeypatch.delenv("OAUTHLIB_INSECURE_TRANSPORT", raising=False) + captured = {} + + class Creds: + expiry = None + + def to_json(self): + return '{"token":"test-only"}' + + class Flow: + credentials = Creds() + + def fetch_token(self, authorization_response): + captured["response"] = authorization_response + # Exercise oauthlib's real HTTPS requirement and query decoding. + parsed = WebApplicationClient("test-client").parse_request_uri_response( + authorization_response, state="state-1" + ) + assert parsed["code"] == "a+b/c" + assert "OAUTHLIB_INSECURE_TRANSPORT" not in os.environ + + service = AgentGmailOAuthService() + monkeypatch.setattr(service, "_build_flow", lambda **kw: Flow()) + monkeypatch.setattr(service, "_get_profile", lambda _: object()) + monkeypatch.setattr( + service, "_resolve_scopes", lambda **kw: list(gs.DEFAULT_SCOPES) + ) + monkeypatch.setattr( + service, "_get_authorised_email", lambda _: "agent@example.test" + ) + monkeypatch.setattr(oauth_module, "upsert_agent_gmail_tokens", lambda **kw: None) + service.exchange_code_for_tokens( + profile_id="test-profile", + authorisation_response_url="http://internal.invalid/callback?state=state-1&code=a%2Bb%2Fc", + redirect_uri="https://untrusted.invalid/callback", + ) + assert captured["response"] == callback + "?state=state-1&code=a%2Bb%2Fc" + + def test_oauthlib_scope_change_is_warning_exception() -> None: body = json.dumps( { @@ -184,9 +227,7 @@ class DummyProfile: lambda self, *, profile_id, profile: list(profile.scopes), ) - def fake_from_client_secrets_file( - path, *, scopes, redirect_uri, code_verifier - ): + def fake_from_client_secrets_file(path, *, scopes, redirect_uri, code_verifier): captured.update( { "path": path,