도구 플러그인 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 프로젝트 만들기
- Google Cloud Console로 가서 새 프로젝트를 만들거나 기존 것을 선택해요.
- 필요한 API(예: Gmail API)를 활성화해요.
OAuth 동의 화면 구성
- APIs & Services > OAuth consent screen으로 이동해요.
- 공개 플러그인이라면 External 사용자 유형을 선택해요.
- 애플리케이션 이름, 사용자 지원 이메일, 개발자 연락처를 입력해요.
- 필요하면 승인된 도메인을 추가해요.
- 테스트하려면 Test users 섹션에 테스트 사용자를 추가해요.
OAuth 2.0 자격증명 만들기
- APIs & Services > Credentials로 이동해요.
- Create Credentials > OAuth 2.0 Client IDs를 클릭해요.
- Web application 유형을 선택해요.
client_id와client_secret이 생성돼요. 이것들을 자격증명으로 저장해요.
Dify에 자격증명 입력
OAuth Client 구성 팝업에 client_id와 client_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-domain이CONSOLE_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_schema와credentials_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