Skip to content
Merged
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
12 changes: 10 additions & 2 deletions src/backend/services/agent_gmail_oauth_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
47 changes: 44 additions & 3 deletions tests/backend/test_agent_gmail_oauth_service_scope_relax.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down Expand Up @@ -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,
Expand Down
Loading