fix(github): implement OAuth refresh credentials logic#3431
fix(github): implement OAuth refresh credentials logic#3431Harsh23Kashyap wants to merge 3 commits into
Conversation
tools/github/provider/github.py:_oauth_refresh_credentials was a TODO stub that returned ToolOAuthCredentials(credentials=credentials, expires_at=-1) regardless of input. For GitHub Apps with 8-hour expiring user-to-server tokens plus a refresh_token, this means the framework never schedules a refresh: the stored token expires silently and the next tool call returns 401 Unauthorized. The user has to manually re-authorize the entire plugin to recover. This commit implements the real refresh-credentials path: - When credentials contain a refresh_token, POST https://github.com/login/oauth/access_token with grant_type=refresh_token and the existing client_id / client_secret. - Replace access_tokens with the new value; replace refresh_token with GitHub's rotated one if returned, else keep the old one. - Compute expires_at from the response's expires_in field minus a 60s safety margin. When expires_in is absent, return -1 (backwards- compatible for classic OAuth Apps that don't expose the field). - Raise ToolProviderOAuthError on HTTP >= 400 or a response without access_token (revoked / invalid refresh_token) so the framework surfaces a clear error instead of silently keeping a dead token. - When credentials lack a refresh_token, return credentials unchanged with expires_at=-1 (backwards-compatible for the credentials_for_provider flow and classic OAuth Apps with non-expiring tokens). Fixes langgenius#3430
Adds the first tests/ directory for the github plugin (along with __init__.py) and 7 unit tests for the new _oauth_refresh_credentials method: - No refresh_token in credentials -> returns unchanged with expires_at=-1 (backwards compat for classic OAuth + credentials_for_provider flow). - Successful refresh with new access_token and refresh_token rotation, expires_at computed from expires_in minus 60s safety. - Successful refresh without rotated refresh_token -> old one preserved. - Successful refresh without expires_in -> expires_at=-1 (backwards compat for non-expiring OAuth Apps). - HTTP 400 error response -> raises ToolProviderOAuthError. - 200 response without access_token (e.g. error body) -> raises ToolProviderOAuthError. - Tiny expires_in (less than 60s) -> floors to a 60s window.
Per PR template requirement to bump top-level version when changing plugin behavior, even when the change is backwards-compatible.
There was a problem hiding this comment.
Code Review
This pull request implements the GitHub OAuth token refresh logic in _oauth_refresh_credentials and adds comprehensive unit tests covering various success and failure scenarios. Feedback suggests improving robustness when parsing the JSON response from the token endpoint to prevent crashes on non-JSON or non-dictionary responses.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| response_json = response.json() | ||
| if response.status_code >= 400 or "access_token" not in response_json: | ||
| raise ToolProviderOAuthError( | ||
| f"GitHub OAuth refresh failed: " | ||
| f"{response_json.get('error_description') or response_json.get('error') or response.text}" | ||
| ) |
There was a problem hiding this comment.
If the GitHub API returns a non-JSON response (such as a 502 Bad Gateway HTML page or a Cloudflare error page), calling response.json() directly will raise a ValueError (or requests.exceptions.JSONDecodeError), causing the refresh logic to crash with a JSON decoding traceback instead of raising a clean ToolProviderOAuthError with the response text.
Additionally, if the response is valid JSON but not a dictionary (e.g., a list or string), calling .get() on it will raise an AttributeError.
We should wrap the JSON parsing in a try-except block and verify that the parsed result is a dictionary to ensure robust error handling.
try:
response_json = response.json()
if not isinstance(response_json, dict):
response_json = {}
except ValueError:
response_json = {}
if response.status_code >= 400 or "access_token" not in response_json:
raise ToolProviderOAuthError(
f"GitHub OAuth refresh failed: "
f"{response_json.get('error_description') or response_json.get('error') or response.text}"
)
Summary
Fixes #3430
tools/github/provider/github.py:_oauth_refresh_credentialswas a no-op stub that returnedToolOAuthCredentials(credentials=credentials, expires_at=-1)regardless of input. For GitHub Apps with 8-hour expiring user-to-server tokens plus arefresh_token, this means the framework never schedules a refresh: the stored token expires silently and the next tool call returns 401 Unauthorized. The user has to manually re-authorize the entire plugin to recover.This PR implements the real refresh-credentials path:
credentialscontain arefresh_token, POSThttps://github.com/login/oauth/access_tokenwithgrant_type=refresh_tokenand the existingclient_id/client_secret.access_tokenswith the new value; replacerefresh_tokenwith GitHub's rotated one if returned, else keep the old one.expires_atfrom the response'sexpires_infield minus a 60s safety margin. Whenexpires_inis absent, return-1(backwards-compatible for classic OAuth Apps that don't expose the field).ToolProviderOAuthErroron HTTP >= 400 or a response withoutaccess_token(revoked / invalid refresh_token) so the framework surfaces a clear error instead of silently keeping a dead token.refresh_token, return credentials unchanged withexpires_at=-1(backwards-compatible for thecredentials_for_providerflow and classic OAuth Apps with non-expiring tokens).Change Type
Version
versioninmanifest.yaml(not the one undermeta) —0.3.7→0.3.8dify_plugin>=0.3.0,<0.6.0is declared inpyproject.tomland locked inuv.lock(or kept inrequirements.txtfor legacy plugins withoutuv.lock) — already present, unchangedTesting
python3 -m py_compile tools/github/provider/github.py— passtools/github/tests/test_refresh_credentials.py— 7 unit cases covering: no refresh_token (backwards compat), successful refresh + new tokens + computed expires_at, rotated refresh_token preserved, missing expires_in (backwards compat), HTTP 400 error, missing access_token in body, tiny expires_in floors to 60s minimumrequests.postto avoid hitting the networkNotes
refresh_tokencontinue to returnexpires_at=-1exactly as before.tools/notion/provider/notion.py,tools/firecrawl/provider/firecrawl.py—dify_pluginSDK standard interface).tests/directory for thetools/githubplugin (along with__init__.py); a foundation for future test additions.Follow-up candidates
provider/github.yamlreminding operators to register GitHub Apps withrefresh_token_enabledto opt into expiring tokens.aws,gitlab,notion)._oauth_get_credentials→_oauth_refresh_credentialshappy path with stubbedrequests.