도구 플러그인 OAuth

도구 플러그인 OAuth

수동 API 키 입력을 OAuth 인증 흐름으로 바꿔, 사용자가 클릭 한 번으로 서드파티 서비스 접근을 허용하게 만드는 방법을 배워요.

출처: 공식문서

이 가이드는 도구 플러그인에 OAuth 지원을 넣는 법을 알려줘요.

OAuth는 Gmail이나 GitHub처럼 서드파티 서비스의 사용자 데이터에 접근해야 하는 도구 플러그인을 인가하는 더 나은 방법이에요. 사용자가 API 키를 수동으로 입력하게 하는 대신, OAuth는 사용자의 명시적 동의를 받아 도구가 그 사용자 대신 행동하게 해 줍니다.

배경

Dify의 OAuth는 두 개의 분리된 흐름을 포함해요. 개발자는 둘 다 이해하고 설계해야 합니다.

sequenceDiagram
    autonumber
    participant Admin as Admin / Developer
    participant Service as Third-party Service
    participant Dify
    participant User

    rect rgb(235, 245, 255)
    Note over Admin,Dify: Flow 1: One-time OAuth client setup
    Admin->>Service: Register OAuth app
    Service-->>Admin: client_id + client_secret
    Admin->>Dify: Configure plugin OAuth client
    end

    rect rgb(245, 255, 235)
    Note over User,Service: Flow 2: Per-user authorization
    User->>Dify: Click "Authorize"
    Dify->>Service: Redirect to consent screen
    User->>Service: Approve
    Service-->>Dify: Authorization code
    Dify->>Service: Exchange for access token
    Service-->>Dify: Access + refresh tokens
    Dify-->>User: Tool ready to use
    end

흐름 1: OAuth 클라이언트 설정 (Admin / Developer 흐름)

📝 Dify Cloud에서는 Dify 팀이 인기 도구 플러그인의 OAuth 앱을 만들고 OAuth 클라이언트를 설정해 두므로, 사용자가 직접 구성할 필요가 없어요.

셀프호스팅 Dify 인스턴스의 관리자는 이 설정 흐름을 직접 거쳐야 합니다.

Dify 인스턴스의 관리자 또는 개발자가 먼저 서드파티 서비스에 OAuth 앱을 신뢰할 수 있는 애플리케이션으로 등록해요. 그러면 Dify 도구 프로바이더를 OAuth 클라이언트로 구성하는 데 필요한 자격증명이 생깁니다.

예시로, Dify의 Gmail 도구 프로바이더용 OAuth 클라이언트를 설정하는 단계를 보여줄게요.

Google Cloud 프로젝트 만들기

  1. Google Cloud Console로 가서 새 프로젝트를 만들거나 기존 것을 선택해요.
  2. 필요한 API(예: Gmail API)를 활성화해요.

OAuth 동의 화면 구성

  1. APIs & Services > OAuth consent screen으로 이동해요.
  2. 공개 플러그인이라면 External 사용자 유형을 선택해요.
  3. 애플리케이션 이름, 사용자 지원 이메일, 개발자 연락처를 입력해요.
  4. 필요하면 승인된 도메인을 추가해요.
  5. 테스트하려면 Test users 섹션에 테스트 사용자를 추가해요.

OAuth 2.0 자격증명 만들기

  1. APIs & Services > Credentials로 이동해요.
  2. Create Credentials > OAuth 2.0 Client IDs를 클릭해요.
  3. Web application 유형을 선택해요.
  4. client_idclient_secret이 생성돼요. 이것들을 자격증명으로 저장해요.

Dify에 자격증명 입력 OAuth Client 구성 팝업에 client_idclient_secret을 입력해 도구 프로바이더를 클라이언트로 설정해요.

리다이렉트 URI 승인 Google OAuth Client 페이지에 Dify가 생성한 리다이렉트 URI를 등록해요.

💡 Dify는 OAuth Client 구성 팝업에 redirect_uri를 표시해요. 보통 다음 형식을 따릅니다.

https://{your-dify-domain}/console/api/oauth/plugin/{plugin-id}/{provider-name}/{tool-name}/callback

셀프호스팅 Dify라면 your-dify-domainCONSOLE_WEB_URL과 일치해야 해요.

💡 각 서비스는 요구사항이 고유하므로, 통합하는 서비스의 구체적인 OAuth 문서를 항상 확인하세요.

흐름 2: 사용자 인가 (Dify 사용자 흐름)

OAuth 클라이언트를 구성한 뒤엔 개별 Dify 사용자가 플러그인에게 자기 개인 계정 접근을 인가할 수 있어요.

구현

1. 프로바이더 매니페스트에 OAuth 스키마 정의

프로바이더 매니페스트의 oauth_schema 섹션은 플러그인의 OAuth 설정이 어떤 자격증명을 필요로 하고, OAuth 흐름이 무엇을 만들어 내는지 Dify에 알려줘요. OAuth를 설정하려면 스키마 두 개가 필요해요.

client_schema — OAuth 클라이언트 설정의 입력을 정의해요.

oauth_schema:
  client_schema:
    - name: "client_id"
      type: "secret-input"
      required: true
      url: "https://developers.google.com/identity/protocols/oauth2"
    - name: "client_secret"
      type: "secret-input" 
      required: true

💡 url 필드는 서드파티 서비스의 도움말 문서로 연결돼, 관리자와 개발자가 설정 중 참고할 수 있게 해 줘요.

credentials_schema — 사용자 인가 흐름이 무엇을 만들어 내는지 지정해요(Dify가 자동 관리).

# also under oauth_schema
  credentials_schema:
    - name: "access_token"
      type: "secret-input"
    - name: "refresh_token"
      type: "secret-input"
    - name: "expires_at"
      type: "secret-input"

💡 oauth_schemacredentials_for_provider를 함께 포함하면 OAuth와 API 키 두 인증 옵션을 모두 제공할 수 있어요.

2. 도구 프로바이더에서 필수 OAuth 메서드 구현

ToolProvider를 구현한 곳에 다음 import를 추가해요.

from dify_plugin.entities.oauth import ToolOAuthCredentials
from dify_plugin.errors.tool import ToolProviderCredentialValidationError, ToolProviderOAuthError

ToolProvider 클래스는 이 OAuth 메서드 세 개를 구현해야 해요 (GmailProvider를 예시로).

⚠️ ToolOAuthCredentials의 자격증명에 client_secret을 절대 반환하지 마세요. 보안 문제로 이어질 수 있어요.

_oauth_get_authorization_url — OAuth 클라이언트 설정 흐름의 자격증명을 사용해 인가 URL을 생성해요. 사용자가 권한을 부여하는 그 주소죠.

def _oauth_get_authorization_url(self, redirect_uri: str, system_credentials: Mapping[str, Any]) -> str:
    """
    Generate the authorization URL using credentials from OAuth Client Setup Flow. 
    This URL is where users grant permissions.
    """
    # Generate random state for CSRF protection (recommended for all OAuth flows)
    state = secrets.token_urlsafe(16)
    
    # Define Gmail-specific scopes - request minimal necessary permissions
    scope = "read:user read:data"  # Replace with your required scopes
    
    # Assemble Gmail-specific payload
    params = {
        "client_id": system_credentials["client_id"],    # From OAuth Client Setup
        "redirect_uri": redirect_uri,                    # Dify generates this - DON'T modify
        "scope": scope,                                  
        "response_type": "code",                         # Standard OAuth authorization code flow
        "access_type": "offline",                        # Critical: gets refresh token (if supported)
        "prompt": "consent",                             # Forces reauth when scopes change (if supported)
        "state": state,                                  # CSRF protection
    }
    
    return f"{self._AUTH_URL}?{urllib.parse.urlencode(params)}"

_oauth_get_credentials — 인가 코드를 액세스 토큰과 리프레시 토큰으로 교환해요. 계정 연결 하나당 자격증명 세트 하나를 만들 때 호출됩니다.

def _oauth_get_credentials(
    self, redirect_uri: str, system_credentials: Mapping[str, Any], request: Request
) -> ToolOAuthCredentials:
    """
    Exchange authorization code for access token and refresh token. This is called
    to create ONE credential set for one account connection.
    """
    # Extract authorization code from OAuth callback
    code = request.args.get("code")
    if not code:
        raise ToolProviderOAuthError("Authorization code not provided")
    
    # Check for authorization errors from OAuth provider
    error = request.args.get("error")
    if error:
        error_description = request.args.get("error_description", "")
        raise ToolProviderOAuthError(f"OAuth authorization failed: {error} - {error_description}")
    
    # Exchange authorization code for tokens using OAuth Client Setup credentials
    # Assemble Gmail-specific payload
    data = {
        "client_id": system_credentials["client_id"],        # From OAuth Client Setup
        "client_secret": system_credentials["client_secret"], # From OAuth Client Setup
        "code": code,                                        # From user's authorization
        "grant_type": "authorization_code",                  # Standard OAuth flow type
        "redirect_uri": redirect_uri,                        # Must exactly match authorization URL
    }
    
    headers = {"Content-Type": "application/x-www-form-urlencoded"}
    
    try:
        response = requests.post(
            self._TOKEN_URL,
            data=data,
            headers=headers,
            timeout=10
        )
        response.raise_for_status()
        
        token_data = response.json()
        
        # Handle OAuth provider errors in response
        if "error" in token_data:
            error_desc = token_data.get('error_description', token_data['error'])
            raise ToolProviderOAuthError(f"Token exchange failed: {error_desc}")
        
        access_token = token_data.get("access_token")
        if not access_token:
            raise ToolProviderOAuthError("No access token received from provider")
        
        # Build credentials dict matching your credentials_schema
        credentials = {
            "access_token": access_token,
            "token_type": token_data.get("token_type", "Bearer"),
        }
        
        # Include refresh token if provided (critical for long-term access)
        refresh_token = token_data.get("refresh_token")
        if refresh_token:
            credentials["refresh_token"] = refresh_token
        
        # Handle token expiration - some providers don't provide expires_in
        expires_in = token_data.get("expires_in", 3600)  # Default to 1 hour
        expires_at = int(time.time()) + expires_in
        
        return ToolOAuthCredentials(credentials=credentials, expires_at=expires_at)
        
    except requests.RequestException as e:
        raise ToolProviderOAuthError(f"Network error during token exchange: {str(e)}")
    except Exception as e:
        raise ToolProviderOAuthError(f"Failed to exchange authorization code: {str(e)}")

_oauth_refresh_credentials — 리프레시 토큰으로 자격증명을 새로 고쳐요. 토큰이 만료되면 Dify가 자동으로 호출해요.

def _oauth_refresh_credentials(
    self, redirect_uri: str, system_credentials: Mapping[str, Any], credentials: Mapping[str, Any]
) -> ToolOAuthCredentials:
    """
    Refresh the credentials using the refresh token. 
    Dify calls this automatically when tokens expire.
    """
    refresh_token = credentials.get("refresh_token")
    if not refresh_token:
        raise ToolProviderOAuthError("No refresh token available")

    # Standard OAuth refresh token flow
    data = {
        "client_id": system_credentials["client_id"],       # From OAuth Client Setup
        "client_secret": system_credentials["client_secret"], # From OAuth Client Setup
        "refresh_token": refresh_token,                     # From previous authorization
        "grant_type": "refresh_token",                      # OAuth refresh flow
    }

    headers = {"Content-Type": "application/x-www-form-urlencoded"}

    try:
        response = requests.post(
            self._TOKEN_URL,
            data=data,
            headers=headers,
            timeout=10
        )
        response.raise_for_status()

        token_data = response.json()

        # Handle refresh errors
        if "error" in token_data:
            error_desc = token_data.get('error_description', token_data['error'])
            raise ToolProviderOAuthError(f"Token refresh failed: {error_desc}")

        access_token = token_data.get("access_token")
        if not access_token:
            raise ToolProviderOAuthError("No access token received from provider")

        # Build new credentials, preserving existing refresh token
        new_credentials = {
            "access_token": access_token,
            "token_type": token_data.get("token_type", "Bearer"),
            "refresh_token": refresh_token,  # Keep existing refresh token
        }

        # Handle token expiration
        expires_in = token_data.get("expires_in", 3600)

        # update refresh token if new one provided
        new_refresh_token = token_data.get("refresh_token")
        if new_refresh_token:
            new_credentials["refresh_token"] = new_refresh_token

        # Calculate new expiration timestamp for Dify's token management
        expires_at = int(time.time()) + expires_in

        return ToolOAuthCredentials(credentials=new_credentials, expires_at=expires_at)

    except requests.RequestException as e:
        raise ToolProviderOAuthError(f"Network error during token refresh: {str(e)}")
    except Exception as e:
        raise ToolProviderOAuthError(f"Failed to refresh credentials: {str(e)}")

3. 도구 안에서 토큰 접근

Tool 구현에서 OAuth 자격증명으로 인증된 API 호출을 해요.

class YourTool(BuiltinTool):
    def _invoke(self, user_id: str, tool_parameters: dict[str, Any]) -> ToolInvokeMessage:
        if self.runtime.credential_type == CredentialType.OAUTH:
            access_token = self.runtime.credentials["access_token"]
        
        response = requests.get("https://api.service.com/data",
                              headers={"Authorization": f"Bearer {access_token}"})
        return self.create_text_message(response.text)

self.runtime.credentials현재 사용자의 토큰을 자동으로 제공해요. Dify가 갱신을 자동 처리합니다.

OAuth와 API_KEY 인증을 모두 지원하는 플러그인이라면 self.runtime.credential_type으로 두 인증 유형을 구분해요.

4. 올바른 버전 지정

OAuth는 최신 SDK와 Dify 버전을 요구해요. requirements.txt에 플러그인 SDK를 고정하세요.

dify_plugin>=0.5.0

manifest.yaml에 최소 Dify 버전을 추가해요.

meta:
  version: 0.0.1
  arch:
    - amd64
    - arm64
  runner:
    language: python
    version: "3.12"
    entrypoint: main
  minimum_dify_version: 1.7.1

더 알아보기 (Learn more)