dify的Outlook 插件 OAuth redirect_uri问题完整解决方案

AI 驱动代码审查实战

Claude code-review 插件深度解析,把 AI 智能审查接进 CI/CD 流水线

Dify Outlook 插件 OAuth 重定向 URI 问题完整解决方案

问题描述

在使用 Dify 的 Outlook 插件时,遇到 Azure AD OAuth 认证错误:

AADSTS90102: 'redirect_uri' value must be a valid absolute URI.

问题分析

经过排查发现,Dify 的 Outlook 插件在 OAuth 认证过程中,传入的 redirect_uri 是相对路径:

/console/api/oauth/plugin/langgenius/outlook/outlook/tool/callback

但 Azure AD 要求 redirect_uri 必须是完整的绝对 URI,如:

http://localhost/console/api/oauth/plugin/langgenius/outlook/outlook/tool/callback

解决方案

方案 1:修改 Outlook 插件源码(推荐)

文件位置dify-main/services/app/tools/outlook/outlook.py

修改内容:在 OutlookProvider 类中添加 URL 转换方法

import os
import time
from typing import Any, Mapping
import requests
import secrets
import urllib.parse

from dify_plugin import ToolProvider
from dify_plugin.errors.tool import ToolProviderCredentialValidationError
from dify_plugin.entities.oauth import ToolOAuthCredentials


class OutlookProvider(ToolProvider):
    _SCOPE = "Mail.Read Mail.Send Mail.ReadWrite offline_access"

    def _ensure_absolute_redirect_uri(self, redirect_uri: str) -> str:
        """确保 redirect_uri 是绝对路径"""
        if redirect_uri.startswith('/'):
            # 从环境变量读取基础 URL,默认为 http://localhost
            base_url = os.getenv('DIFY_BASE_URL', 'http://localhost')
            # 确保没有重复的斜杠
            if base_url.endswith('/'):
                base_url = base_url.rstrip('/')
            if redirect_uri.startswith('/'):
                redirect_uri = redirect_uri.lstrip('/')
            return f"{base_url}/{redirect_uri}"
        return redirect_uri

    def _validate_credentials(self, credentials: dict[str, Any]) -> None:
        """Validate access token by calling Microsoft Graph API."""
        if not credentials.get("access_token"):
            raise ToolProviderCredentialValidationError("Microsoft Graph access token is required.")
        
        headers = {"Authorization": f"Bearer {credentials['access_token']}"}
        response = requests.get("https://graph.microsoft.com/v1.0/me", headers=headers, timeout=30)
        
        if response.status_code != 200:
            raise ToolProviderCredentialValidationError("Invalid or expired access token.")

    def _oauth_get_authorization_url(self, redirect_uri: str, system_credentials: Mapping[str, Any]) -> str:
        """Generate OAuth authorization URL."""
        # 确保 redirect_uri 是绝对路径
        redirect_uri = self._ensure_absolute_redirect_uri(redirect_uri)
        
        tenant_id = system_credentials.get("tenant_id", "common")
        auth_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/authorize"
        
        params = {
            "client_id": system_credentials["client_id"],
            "redirect_uri": redirect_uri,
            "scope": system_credentials.get("scope", self._SCOPE),
            "response_type": "code",
            "state": secrets.token_urlsafe(16)
        }
        return f"{auth_url}?{urllib.parse.urlencode(params)}"

    def _oauth_get_credentials(
        self, redirect_uri: str, system_credentials: Mapping[str, Any], request: Any
    ) -> ToolOAuthCredentials:
        """Exchange authorization code for access token."""
        # 确保 redirect_uri 是绝对路径
        redirect_uri = self._ensure_absolute_redirect_uri(redirect_uri)
        
        # Get authorization code
        code = request.args.get("code")
        if not code:
            raise ToolProviderCredentialValidationError("No authorization code provided")
        
        # Exchange code for token
        tenant_id = system_credentials.get("tenant_id", "common")
        token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
        
        data = {
            "client_id": system_credentials["client_id"],
            "client_secret": system_credentials["client_secret"],
            "code": code,
            "redirect_uri": redirect_uri,
            "grant_type": "authorization_code"
        }
        
        response = requests.post(token_url, data=data, timeout=30)
        if response.status_code != 200:
            raise ToolProviderCredentialValidationError(f"Token exchange failed: {response.text}")
        
        token_data = response.json()
        access_token = token_data.get("access_token")
        refresh_token = token_data.get("refresh_token")

        if not access_token or not refresh_token:
            raise ToolProviderCredentialValidationError("No access token or refresh token in response")
        
        return ToolOAuthCredentials(
            credentials={"access_token": access_token, "refresh_token": refresh_token},
            expires_at= token_data.get("expires_in", 3599) + int(time.time())
        )

    def oauth_refresh_credentials(
        self, redirect_uri: str, system_credentials: Mapping[str, Any], credentials: Mapping[str, Any]
    ) -> ToolOAuthCredentials:
        """Refresh OAuth credentials."""
        # 确保 redirect_uri 是绝对路径
        redirect_uri = self._ensure_absolute_redirect_uri(redirect_uri)
        
        tenant_id = system_credentials.get("tenant_id", "common")
        token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"

        data = {
            "client_id": system_credentials["client_id"],
            "client_secret": system_credentials["client_secret"],
            "refresh_token": credentials.get("refresh_token"),
            "redirect_uri": redirect_uri,
            "grant_type": "refresh_token"
        }
        
        response = requests.post(token_url, data=data, timeout=30)
        if response.status_code != 200:
            raise ToolProviderCredentialValidationError(f"Token exchange failed: {response.text}")
        
        token_data = response.json()

        access_token = token_data.get("access_token")
        refresh_token = token_data.get("refresh_token")

        if not access_token or not refresh_token:
            raise ToolProviderCredentialValidationError("No access token or refresh token in response")

        return ToolOAuthCredentials(
            credentials={"access_token": access_token, "refresh_token": refresh_token},
            expires_at= token_data.get("expires_in", 3599) + int(time.time())
        )

方案 2:环境变量配置

.env 文件中添加:

# Dify 基础 URL 配置
DIFY_BASE_URL=http://localhost

# Outlook 插件配置
OUTLOOK_CLIENT_ID=your_client_id
OUTLOOK_CLIENT_SECRET=your_client_secret
OUTLOOK_REDIRECT_URI=http://localhost/console/api/oauth/plugin/langgenius/outlook/outlook/tool/callback

方案 3:Azure 应用注册配置

在 Azure 门户中,确保重定向 URI 配置为:

http://localhost/console/api/oauth/plugin/langgenius/outlook/outlook/tool/callback

部署步骤

  1. 修改源码:按照方案 1 修改 outlook.py 文件
  2. 配置环境变量:在 .env 文件中添加 DIFY_BASE_URL
  3. 重新构建服务
    docker-compose down
    docker-compose build api
    docker-compose up -d
    
  4. 验证修复:重新配置 Outlook 插件,应该能正常完成 OAuth 认证

技术要点

  1. 问题根源:Dify Outlook 插件内部使用了相对路径的 redirect_uri
  2. 解决方案:在 OAuth 流程中自动将相对路径转换为绝对路径
  3. 兼容性:修改后的代码同时支持相对路径和绝对路径
  4. 配置灵活性:通过环境变量支持不同部署环境

总结

这个问题的核心在于 Dify 插件系统与 Azure AD OAuth 规范的不兼容。通过源码级别的修改,我们实现了自动的 URL 转换,确保了 OAuth 认证流程的正常进行。这种解决方案也适用于其他类似的 OAuth 集成问题。

标签#Dify #Outlook #OAuth #AzureAD #Docker #Python

AI 驱动代码审查实战

Claude code-review 插件深度解析,把 AI 智能审查接进 CI/CD 流水线

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值