Skip to content

fix(github): implement OAuth refresh credentials logic#3431

Open
Harsh23Kashyap wants to merge 3 commits into
langgenius:mainfrom
Harsh23Kashyap:fix/github-oauth-refresh
Open

fix(github): implement OAuth refresh credentials logic#3431
Harsh23Kashyap wants to merge 3 commits into
langgenius:mainfrom
Harsh23Kashyap:fix/github-oauth-refresh

Conversation

@Harsh23Kashyap

Copy link
Copy Markdown
Contributor

Summary

Fixes #3430

tools/github/provider/github.py:_oauth_refresh_credentials was a no-op 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 PR 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).

Change Type

  • Documentation / non-plugin change
  • Non-LLM plugin (tools, extensions, datasource, etc.)
  • LLM plugin

Version

  • Bumped top-level version in manifest.yaml (not the one under meta) — 0.3.70.3.8
  • dify_plugin>=0.3.0,<0.6.0 is declared in pyproject.toml and locked in uv.lock (or kept in requirements.txt for legacy plugins without uv.lock) — already present, unchanged

Testing

  • python3 -m py_compile tools/github/provider/github.py — pass
  • New tools/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 minimum
  • All tests stub requests.post to avoid hitting the network

Notes

  • The fix is fully backwards-compatible. Classic OAuth Apps that don't issue refresh_token continue to return expires_at=-1 exactly as before.
  • The implementation follows the same pattern as other tool providers in the repo (e.g. tools/notion/provider/notion.py, tools/firecrawl/provider/firecrawl.pydify_plugin SDK standard interface).
  • Plugin's 19 existing tools continue to work unchanged.
  • Adds the first tests/ directory for the tools/github plugin (along with __init__.py); a foundation for future test additions.

Follow-up candidates

  • Add a docstring note in provider/github.yaml reminding operators to register GitHub Apps with refresh_token_enabled to opt into expiring tokens.
  • Add a similar test scaffold for other large plugins that don't have any tests today (aws, gitlab, notion).
  • Add an end-to-end test that exercises the full _oauth_get_credentials_oauth_refresh_credentials happy path with stubbed requests.

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.
@dosubot dosubot Bot added the size:M This PR changes 30-99 lines, ignoring generated files. label Jul 12, 2026

@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 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.

Comment on lines +86 to +91
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}"
)

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

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}"
            )

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

Labels

size:M This PR changes 30-99 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(github): _oauth_refresh_credentials is a no-op; OAuth App tokens break silently when they expire

1 participant