Summary
tools/github/provider/github.py:_oauth_refresh_credentials is a no-op stub — the function body is # TODO: Implement the refresh credentials logic followed by return ToolOAuthCredentials(credentials=credentials, expires_at=-1). The returned expires_at=-1 is the framework's "never expires" sentinel, which means:
- For GitHub Apps (the default OAuth App flow since 2020) that issue 8-hour expiring user-to-server tokens plus a refresh token, 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. There is no automatic recovery path.
- The
refresh_token that GitHub returned in the original token exchange is discarded.
Impact
Every Dify installation that uses the GitHub plugin via OAuth (the only auth method supported by oauth_schema in provider/github.yaml) hits this issue. The default-registered GitHub App flow is affected; classic OAuth Apps with non-expiring tokens are not.
Reproduction (logic walk-through)
- Register a GitHub OAuth App at https://github.com/settings/applications/new.
- Configure the Dify GitHub plugin with the App's
client_id / client_secret.
- Complete OAuth authorization via the plugin's OAuth flow.
- Wait until the access token expires (8 hours for GitHub App user-to-server tokens) OR revoke the token manually from GitHub settings.
- Trigger any GitHub plugin tool (e.g.
github_user_info).
- Observe the tool call fails with 401 Unauthorized.
- There is no automatic recovery: the framework cannot refresh the token because
_oauth_refresh_credentials is a no-op.
Expected behavior
- When
credentials contains a refresh_token, call GitHub's POST https://github.com/login/oauth/access_token with grant_type=refresh_token, client_id, client_secret, and the refresh token.
- Update the stored
access_tokens with the new value returned by GitHub.
- If GitHub returns a new
refresh_token (it rotates the refresh token on some OAuth Apps), replace the stored one.
- Compute
expires_at from the response's expires_in field (minus a small safety margin). If expires_in is absent, treat the token as non-expiring (expires_at=-1) to preserve backwards compatibility with classic OAuth Apps.
- On refresh failure (e.g. revoked refresh token), raise
ToolProviderOAuthError with a clear message — do not silently keep the dead token.
Current behavior
def _oauth_refresh_credentials(
self, redirect_uri: str, system_credentials: Mapping[str, Any], credentials: Mapping[str, Any]
) -> ToolOAuthCredentials:
"""
Refresh the credentials
"""
# TODO: Implement the refresh credentials logic
return ToolOAuthCredentials(credentials=credentials, expires_at=-1)
Proposed solution
def _oauth_refresh_credentials(
self, redirect_uri: str, system_credentials: Mapping[str, Any], credentials: Mapping[str, Any]
) -> ToolOAuthCredentials:
"""
Refresh the GitHub OAuth access token via GitHub's token endpoint.
GitHub's OAuth Apps issue refresh tokens only when the app is configured
to allow it (newer GitHub Apps default behavior). If no refresh_token is
present (classic OAuth Apps with non-expiring tokens), return credentials
unchanged with expires_at=-1 to signal "never expires" — this preserves
backwards compatibility.
"""
refresh_token = credentials.get("refresh_token")
if not refresh_token:
# No refresh token: token does not expire. Pass through.
return ToolOAuthCredentials(credentials=credentials, expires_at=-1)
data = {
"client_id": system_credentials["client_id"],
"client_secret": system_credentials["client_secret"],
"grant_type": "refresh_token",
"refresh_token": refresh_token,
}
headers = {"Accept": "application/json"}
response = requests.post(self._TOKEN_URL, data=data, headers=headers, timeout=10)
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}"
)
new_credentials = {
"access_tokens": response_json["access_token"],
# GitHub may rotate the refresh token; keep the new one if returned,
# else keep the previous refresh_token as a fallback.
"refresh_token": response_json.get("refresh_token", refresh_token),
}
expires_in = response_json.get("expires_in")
if expires_in is None:
# No expiry — backwards-compatible with non-expiring OAuth Apps.
expires_at = -1
else:
# Refresh 60s before expiry to avoid edge-case 401s.
expires_at = max(int(expires_in) - 60, 60) + int(time.time())
return ToolOAuthCredentials(credentials=new_credentials, expires_at=expires_at)
Imports needed: add import time at the top of tools/github/provider/github.py.
Backward compatibility
- When no
refresh_token is present in credentials (classic OAuth Apps with non-expiring tokens, or the manual credentials_for_provider flow that doesn't use OAuth), the function returns credentials unchanged with expires_at=-1 — identical to the current behavior.
- No existing flow changes for non-OAuth usage.
Alternatives considered
- Re-authorize flow: ask the user to re-authorize when the token expires. Heavy UX, requires user interaction; rejected in favor of silent refresh.
- Periodic background re-authorization: out of scope for a stateless plugin.
Risks
- A revoked refresh token must surface as
ToolProviderOAuthError (not be silently passed through) — covered by the error branch.
- The rotated
refresh_token from GitHub must replace the old one — covered by the response_json.get("refresh_token", refresh_token) fallback.
- A misbehaving GitHub response (no
access_token in body) must raise — covered by the explicit not in response_json check.
- Compatibility with classic OAuth Apps (no refresh_token in the response) is preserved by the early-return at the top of the function.
Scope
- One file:
tools/github/provider/github.py
- New test file:
tools/github/tests/test_refresh_credentials.py (and tools/github/tests/__init__.py)
- One manifest version bump:
tools/github/manifest.yaml
Acceptance criteria:
- Existing flow with no
refresh_token continues to return expires_at=-1 and credentials unchanged.
- New flow with a
refresh_token issues a POST to https://github.com/login/oauth/access_token with grant_type=refresh_token, updates access_tokens, rotates refresh_token if returned, and computes expires_at from expires_in.
- Refresh failure raises
ToolProviderOAuthError with a clear message.
- Plugin's 19 existing tools continue to work unchanged.
Summary
tools/github/provider/github.py:_oauth_refresh_credentialsis a no-op stub — the function body is# TODO: Implement the refresh credentials logicfollowed byreturn ToolOAuthCredentials(credentials=credentials, expires_at=-1). The returnedexpires_at=-1is the framework's "never expires" sentinel, which means:refresh_tokenthat GitHub returned in the original token exchange is discarded.Impact
Every Dify installation that uses the GitHub plugin via OAuth (the only auth method supported by
oauth_schemainprovider/github.yaml) hits this issue. The default-registered GitHub App flow is affected; classic OAuth Apps with non-expiring tokens are not.Reproduction (logic walk-through)
client_id/client_secret.github_user_info)._oauth_refresh_credentialsis a no-op.Expected behavior
credentialscontains arefresh_token, call GitHub'sPOST https://github.com/login/oauth/access_tokenwithgrant_type=refresh_token,client_id,client_secret, and the refresh token.access_tokenswith the new value returned by GitHub.refresh_token(it rotates the refresh token on some OAuth Apps), replace the stored one.expires_atfrom the response'sexpires_infield (minus a small safety margin). Ifexpires_inis absent, treat the token as non-expiring (expires_at=-1) to preserve backwards compatibility with classic OAuth Apps.ToolProviderOAuthErrorwith a clear message — do not silently keep the dead token.Current behavior
Proposed solution
Imports needed: add
import timeat the top oftools/github/provider/github.py.Backward compatibility
refresh_tokenis present incredentials(classic OAuth Apps with non-expiring tokens, or the manualcredentials_for_providerflow that doesn't use OAuth), the function returns credentials unchanged withexpires_at=-1— identical to the current behavior.Alternatives considered
Risks
ToolProviderOAuthError(not be silently passed through) — covered by the error branch.refresh_tokenfrom GitHub must replace the old one — covered by theresponse_json.get("refresh_token", refresh_token)fallback.access_tokenin body) must raise — covered by the explicitnot in response_jsoncheck.Scope
tools/github/provider/github.pytools/github/tests/test_refresh_credentials.py(andtools/github/tests/__init__.py)tools/github/manifest.yamlAcceptance criteria:
refresh_tokencontinues to returnexpires_at=-1and credentials unchanged.refresh_tokenissues a POST tohttps://github.com/login/oauth/access_tokenwithgrant_type=refresh_token, updatesaccess_tokens, rotatesrefresh_tokenif returned, and computesexpires_atfromexpires_in.ToolProviderOAuthErrorwith a clear message.