Skip to content

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

Description

@Harsh23Kashyap

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)

  1. Register a GitHub OAuth App at https://github.com/settings/applications/new.
  2. Configure the Dify GitHub plugin with the App's client_id / client_secret.
  3. Complete OAuth authorization via the plugin's OAuth flow.
  4. Wait until the access token expires (8 hours for GitHub App user-to-server tokens) OR revoke the token manually from GitHub settings.
  5. Trigger any GitHub plugin tool (e.g. github_user_info).
  6. Observe the tool call fails with 401 Unauthorized.
  7. 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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions