Skip to content

fix(openapi): exchange client credentials without user consent - #7545

Open
Samecho wants to merge 2 commits into
google:mainfrom
Samecho:codex/openapi-client-credentials
Open

Samecho wants to merge 2 commits into
google:mainfrom
Samecho:codex/openapi-client-credentials

Conversation

@Samecho

@Samecho Samecho commented Oct 15, 2026

Copy link
Copy Markdown

Link to Issue or Description of Change

Fixes #3753. I shared the current reproduction and proposed scope on the issue.

An OpenAPI tool configured with only the OAuth2 client-credentials flow still emits adk_request_credential, with a login URL built from the token endpoint. The token endpoint and target API are never called. The standalone async OAuth exchanger already supports this grant; the legacy OpenAPI auth path asks for user authorization before reaching it.

This uses that exchanger for client-credentials-only schemes, off the event loop. It reuses valid cached tokens and obtains a new client token after expiry. A rejected token is removed so a later call can obtain a new one; it does not fall back to interactive consent. The client secret stays in the configured credential and is omitted from the session token cache. Authorization-code and mixed-flow schemes keep their existing consent path.

Testing Plan

  • Added regression coverage for the token POST, execution off the event loop, caching, expiry, configured tokens, exchange failure, rejected tokens, shared configuration and authorization-code/mixed-flow compatibility.
  • The targeted auth-handler suite passes 57 tests; the existing authentication and OpenAPI suites pass 720 tests.
  • All 16 fork validation jobs pass for 8d9f57d.

Full unit suites passed 19,132 tests on Python 3.11, 19,123 tests on Python 3.12, 19,123 tests on Python 3.13, 19,123 tests on Python 3.14. Existing skips and expected-failure results are recorded in the run.

The uncached mypy comparison found no new diagnostics on Python 3.11–3.13 (3.11: 538 baseline and 538 branch errors, 3.12: 538 baseline and 538 branch errors, 3.13: 538 baseline and 538 branch errors).

The new regression file fails six cases on unmodified main. Self-review also caught a rejected token remaining cached in the first version; the regression reproduced that failure before the invalidation fix. The linked full validation is for the corrected final commit. The fork workflow excludes lancedb from dependency installation.

Manual End-to-End (E2E) Tests:

Ran a real OpenAPIToolset and Runner with a fake model and fake HTTP endpoints, from source and from a wheel installed into a fresh environment. Both issue a token POST with grant_type=client_credentials, call the API with the returned bearer token, and emit no adk_request_credential. The loaded auth module has the same hash in the source and wheel runs.

The reproduction script and saved results are on a separate fork branch. Run the first command with the source checkout and the second with the fresh wheel environment's Python:

python openapi_client_credentials_e2e.py --source /path/to/adk-python/src --result source.json
python openapi_client_credentials_e2e.py --result wheel.json

Checklist

  • I have read CONTRIBUTING.md and reviewed the diff.
  • The fix has regression tests and Runner E2E evidence.
  • Format and compliance checks pass.
  • No dependent code change is required.

Use the existing async OAuth exchanger for client-credentials-only schemes before the interactive authorization path. Reuse unexpired session tokens and obtain a new client token when they expire. Keep client secrets out of the session token cache and preserve authorization-code consent.

Fixes google#3753

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for the OAuth2 client credentials flow in ToolAuthHandler, enabling token exchange without user interaction, as well as token caching, expiration handling, and eviction on authorization errors. It also adds comprehensive unit tests for these scenarios. The feedback suggests adding a safety check in _is_client_credentials_only to prevent a potential AttributeError if flows is None.

Comment on lines +737 to +744
def _is_client_credentials_only(self) -> bool:
if not isinstance(self.auth_scheme, OAuth2):
return False
flows = self.auth_scheme.flows
return bool(
flows.clientCredentials
and not (flows.authorizationCode or flows.implicit or flows.password)
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The flows attribute of OAuth2 can be None or falsy if the security scheme is not fully or correctly populated. Accessing flows.clientCredentials directly without checking if flows is None will raise an AttributeError. Adding a check to ensure flows is present before accessing its attributes prevents potential runtime crashes.

Suggested change
def _is_client_credentials_only(self) -> bool:
if not isinstance(self.auth_scheme, OAuth2):
return False
flows = self.auth_scheme.flows
return bool(
flows.clientCredentials
and not (flows.authorizationCode or flows.implicit or flows.password)
)
def _is_client_credentials_only(self) -> bool:
if not isinstance(self.auth_scheme, OAuth2) or not self.auth_scheme.flows:
return False
flows = self.auth_scheme.flows
return bool(
flows.clientCredentials
and not (flows.authorizationCode or flows.implicit or flows.password)
)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OAuth2.flows is a required OAuthFlows model in FastAPI. Missing and None values both fail validation, while flows={} creates an empty model that this predicate already handles. I checked those cases, so I’m keeping the validated-model contract here.

@Samecho

Samecho commented Oct 15, 2026 •

Copy link
Copy Markdown
Author

The final commit, 8d9f57d, passed all 16 jobs in the fork validation run, including Python 3.11–3.14, compatibility checks, formatting, and the baseline type comparison. The Runner flow also passed from source and a freshly installed wheel.

Could a maintainer approve the CI run and rerun the triage job? The triage job stopped on Gemini 503 UNAVAILABLE.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Client credentials still opens browser for authentication

1 participant