diff --git a/api/apps/connector_app.py b/api/apps/connector_app.py index 0e687ea69..0c123f700 100644 --- a/api/apps/connector_app.py +++ b/api/apps/connector_app.py @@ -193,20 +193,25 @@ async def start_google_web_oauth(): if source not in ("google-drive", "gmail"): return get_json_result(code=RetCode.ARGUMENT_ERROR, message="Invalid Google OAuth type.") + req = await get_request_json() + if source == "gmail": - redirect_uri = GMAIL_WEB_OAUTH_REDIRECT_URI + default_redirect_uri = GMAIL_WEB_OAUTH_REDIRECT_URI scopes = GOOGLE_SCOPES[DocumentSource.GMAIL] else: - redirect_uri = GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI + default_redirect_uri = GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI scopes = GOOGLE_SCOPES[DocumentSource.GOOGLE_DRIVE] + redirect_uri = req.get("redirect_uri", default_redirect_uri) + if isinstance(redirect_uri, str): + redirect_uri = redirect_uri.strip() + if not redirect_uri: return get_json_result( code=RetCode.SERVER_ERROR, message="Google OAuth redirect URI is not configured on the server.", ) - req = await get_request_json() raw_credentials = req.get("credentials", "") try: @@ -246,6 +251,7 @@ async def start_google_web_oauth(): cache_payload = { "user_id": current_user.id, "client_config": client_config, + "redirect_uri": redirect_uri, "created_at": int(time.time()), } REDIS_CONN.set_obj(_web_state_cache_key(flow_id, source), cache_payload, WEB_FLOW_TTL_SECS) @@ -276,6 +282,7 @@ async def google_gmail_web_oauth_callback(): state_obj = json.loads(state_cache) client_config = state_obj.get("client_config") + redirect_uri = state_obj.get("redirect_uri", GMAIL_WEB_OAUTH_REDIRECT_URI) if not client_config: REDIS_CONN.delete(_web_state_cache_key(state_id, source)) return await _render_web_oauth_popup(state_id, False, "Authorization session was invalid. Please retry.", source) @@ -291,7 +298,7 @@ async def google_gmail_web_oauth_callback(): try: # TODO(google-oauth): branch scopes/redirect_uri based on source_type (drive vs gmail) flow = Flow.from_client_config(client_config, scopes=GOOGLE_SCOPES[DocumentSource.GMAIL]) - flow.redirect_uri = GMAIL_WEB_OAUTH_REDIRECT_URI + flow.redirect_uri = redirect_uri flow.fetch_token(code=code) except Exception as exc: # pragma: no cover - defensive logging.exception("Failed to exchange Google OAuth code: %s", exc) @@ -326,6 +333,7 @@ async def google_drive_web_oauth_callback(): state_obj = json.loads(state_cache) client_config = state_obj.get("client_config") + redirect_uri = state_obj.get("redirect_uri", GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI) if not client_config: REDIS_CONN.delete(_web_state_cache_key(state_id, source)) return await _render_web_oauth_popup(state_id, False, "Authorization session was invalid. Please retry.", source) @@ -341,7 +349,7 @@ async def google_drive_web_oauth_callback(): try: # TODO(google-oauth): branch scopes/redirect_uri based on source_type (drive vs gmail) flow = Flow.from_client_config(client_config, scopes=GOOGLE_SCOPES[DocumentSource.GOOGLE_DRIVE]) - flow.redirect_uri = GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI + flow.redirect_uri = redirect_uri flow.fetch_token(code=code) except Exception as exc: # pragma: no cover - defensive logging.exception("Failed to exchange Google OAuth code: %s", exc) @@ -480,4 +488,4 @@ async def poll_box_web_result(): REDIS_CONN.delete(_web_result_cache_key(flow_id, "box")) - return get_json_result(data={"credentials": cache_raw}) \ No newline at end of file + return get_json_result(data={"credentials": cache_raw}) diff --git a/web/src/pages/user-setting/data-source/component/gmail-token-field.tsx b/web/src/pages/user-setting/data-source/component/gmail-token-field.tsx index 3777ebb31..7df7ec6d5 100644 --- a/web/src/pages/user-setting/data-source/component/gmail-token-field.tsx +++ b/web/src/pages/user-setting/data-source/component/gmail-token-field.tsx @@ -10,6 +10,7 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; import message from '@/components/ui/message'; import { FileMimeType } from '@/constants/common'; import { @@ -49,6 +50,51 @@ const describeCredentials = (content?: string) => { } }; +const parseJsonObject = (content?: string): Record | null => { + if (!content) return null; + try { + const parsed = JSON.parse(content); + return typeof parsed === 'object' && parsed !== null ? parsed : null; + } catch { + return null; + } +}; + +const extractRedirectUri = (content?: string): string => { + const parsed = parseJsonObject(content); + if (!parsed) return ''; + + if (typeof parsed.redirect_uri === 'string' && parsed.redirect_uri.trim()) { + return parsed.redirect_uri.trim(); + } + + const redirectUris = + parsed.web?.redirect_uris ?? parsed.installed?.redirect_uris; + if (Array.isArray(redirectUris)) { + const firstValidRedirect = redirectUris.find( + (item) => typeof item === 'string' && item.trim(), + ); + if (firstValidRedirect) { + return firstValidRedirect.trim(); + } + } + + return ''; +}; + +const withRedirectUri = (credentials: string, redirectUri: string): string => { + const trimmedRedirectUri = redirectUri.trim(); + if (!trimmedRedirectUri) return credentials; + + const parsed = parseJsonObject(credentials); + if (!parsed) return credentials; + + return JSON.stringify({ + ...parsed, + redirect_uri: trimmedRedirectUri, + }); +}; + const GmailTokenField = ({ value, onChange, @@ -56,6 +102,7 @@ const GmailTokenField = ({ }: GmailTokenFieldProps) => { const [files, setFiles] = useState([]); const [pendingCredentials, setPendingCredentials] = useState(''); + const [redirectUri, setRedirectUri] = useState(''); const [dialogOpen, setDialogOpen] = useState(false); const [webAuthLoading, setWebAuthLoading] = useState(false); const [webFlowId, setWebFlowId] = useState(null); @@ -89,6 +136,12 @@ const GmailTokenField = ({ webFlowIdRef.current = webFlowId; }, [webFlowId]); + useEffect(() => { + if (!dialogOpen) { + setRedirectUri(extractRedirectUri(value)); + } + }, [dialogOpen, value]); + const credentialSummary = useMemo(() => describeCredentials(value), [value]); const hasVerifiedTokens = useMemo( () => Boolean(value && credentialHasRefreshToken(value)), @@ -118,7 +171,11 @@ const GmailTokenField = ({ flow_id: flowId, }); if (data.code === 0 && data.data?.credentials) { - onChange(data.data.credentials); + const rawCredentials = + typeof data.data.credentials === 'string' + ? data.data.credentials + : JSON.stringify(data.data.credentials); + onChange(withRedirectUri(rawCredentials, redirectUri)); setPendingCredentials(''); message.success('Gmail credentials verified.'); resetDialog(false); @@ -143,7 +200,7 @@ const GmailTokenField = ({ clearWebState(); } }, - [clearWebState, onChange, resetDialog], + [clearWebState, onChange, redirectUri, resetDialog], ); useEffect(() => { @@ -198,8 +255,10 @@ const GmailTokenField = ({ } setFiles([file]); clearWebState(); + const extractedRedirectUri = extractRedirectUri(text); + setRedirectUri(extractedRedirectUri); if (credentialHasRefreshToken(text)) { - onChange(text); + onChange(withRedirectUri(text, extractedRedirectUri)); setPendingCredentials(''); message.success('Gmail OAuth credentials uploaded.'); return; @@ -223,11 +282,17 @@ const GmailTokenField = ({ message.error('No Google credential file detected.'); return; } + if (!redirectUri.trim()) { + message.error('Please fill in Redirect URI.'); + return; + } + const trimmedRedirectUri = redirectUri.trim(); setWebAuthLoading(true); clearWebState(); try { const { data } = await startGmailWebAuth({ credentials: pendingCredentials, + redirect_uri: trimmedRedirectUri, }); if (data.code === 0 && data.data?.authorization_url) { const flowId = data.data.flow_id; @@ -255,7 +320,7 @@ const GmailTokenField = ({ } finally { setWebAuthLoading(false); } - }, [clearWebState, pendingCredentials]); + }, [clearWebState, pendingCredentials, redirectUri]); const handleManualWebCheck = useCallback(() => { if (!webFlowId) { @@ -334,6 +399,14 @@ const GmailTokenField = ({
+
+ + setRedirectUri(e.target.value)} + /> +
Authorize in browser diff --git a/web/src/pages/user-setting/data-source/component/google-drive-token-field.tsx b/web/src/pages/user-setting/data-source/component/google-drive-token-field.tsx index bb0565bae..1077a349c 100644 --- a/web/src/pages/user-setting/data-source/component/google-drive-token-field.tsx +++ b/web/src/pages/user-setting/data-source/component/google-drive-token-field.tsx @@ -8,6 +8,7 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; import message from '@/components/ui/message'; import { FileMimeType } from '@/constants/common'; import { @@ -47,12 +48,58 @@ const describeCredentials = (content?: string) => { } }; +const parseJsonObject = (content?: string): Record | null => { + if (!content) return null; + try { + const parsed = JSON.parse(content); + return typeof parsed === 'object' && parsed !== null ? parsed : null; + } catch { + return null; + } +}; + +const extractRedirectUri = (content?: string): string => { + const parsed = parseJsonObject(content); + if (!parsed) return ''; + + if (typeof parsed.redirect_uri === 'string' && parsed.redirect_uri.trim()) { + return parsed.redirect_uri.trim(); + } + + const redirectUris = + parsed.web?.redirect_uris ?? parsed.installed?.redirect_uris; + if (Array.isArray(redirectUris)) { + const firstValidRedirect = redirectUris.find( + (item) => typeof item === 'string' && item.trim(), + ); + if (firstValidRedirect) { + return firstValidRedirect.trim(); + } + } + + return ''; +}; + +const withRedirectUri = (credentials: string, redirectUri: string): string => { + const trimmedRedirectUri = redirectUri.trim(); + if (!trimmedRedirectUri) return credentials; + + const parsed = parseJsonObject(credentials); + if (!parsed) return credentials; + + return JSON.stringify({ + ...parsed, + redirect_uri: trimmedRedirectUri, + }); +}; + const GoogleDriveTokenField = ({ value, onChange, }: GoogleDriveTokenFieldProps) => { const [files, setFiles] = useState([]); const [pendingCredentials, setPendingCredentials] = useState(''); + const [redirectUri, setRedirectUri] = useState(''); const [dialogOpen, setDialogOpen] = useState(false); const [webAuthLoading, setWebAuthLoading] = useState(false); const [webFlowId, setWebFlowId] = useState(null); @@ -86,6 +133,12 @@ const GoogleDriveTokenField = ({ webFlowIdRef.current = webFlowId; }, [webFlowId]); + useEffect(() => { + if (!dialogOpen) { + setRedirectUri(extractRedirectUri(value)); + } + }, [dialogOpen, value]); + const credentialSummary = useMemo(() => describeCredentials(value), [value]); const hasVerifiedTokens = useMemo( () => Boolean(value && credentialHasRefreshToken(value)), @@ -115,7 +168,11 @@ const GoogleDriveTokenField = ({ flow_id: flowId, }); if (data.code === 0 && data.data?.credentials) { - onChange(data.data.credentials); + const rawCredentials = + typeof data.data.credentials === 'string' + ? data.data.credentials + : JSON.stringify(data.data.credentials); + onChange(withRedirectUri(rawCredentials, redirectUri)); setPendingCredentials(''); message.success('Google Drive credentials verified.'); resetDialog(false); @@ -140,7 +197,7 @@ const GoogleDriveTokenField = ({ clearWebState(); } }, - [clearWebState, onChange, resetDialog], + [clearWebState, onChange, redirectUri, resetDialog], ); useEffect(() => { @@ -195,8 +252,10 @@ const GoogleDriveTokenField = ({ } setFiles([file]); clearWebState(); + const extractedRedirectUri = extractRedirectUri(text); + setRedirectUri(extractedRedirectUri); if (credentialHasRefreshToken(text)) { - onChange(text); + onChange(withRedirectUri(text, extractedRedirectUri)); setPendingCredentials(''); message.success('OAuth credentials uploaded.'); return; @@ -220,11 +279,17 @@ const GoogleDriveTokenField = ({ message.error('No Google credential file detected.'); return; } + if (!redirectUri.trim()) { + message.error('Please fill in Redirect URI.'); + return; + } + const trimmedRedirectUri = redirectUri.trim(); setWebAuthLoading(true); clearWebState(); try { const { data } = await startGoogleDriveWebAuth({ credentials: pendingCredentials, + redirect_uri: trimmedRedirectUri, }); if (data.code === 0 && data.data?.authorization_url) { const flowId = data.data.flow_id; @@ -252,7 +317,7 @@ const GoogleDriveTokenField = ({ } finally { setWebAuthLoading(false); } - }, [clearWebState, pendingCredentials]); + }, [clearWebState, pendingCredentials, redirectUri]); const handleManualWebCheck = useCallback(() => { if (!webFlowId) { @@ -330,6 +395,14 @@ const GoogleDriveTokenField = ({
+
+ + setRedirectUri(e.target.value)} + /> +
Authorize in browser diff --git a/web/src/services/data-source-service.ts b/web/src/services/data-source-service.ts index bfc54b27c..2ed698c3f 100644 --- a/web/src/services/data-source-service.ts +++ b/web/src/services/data-source-service.ts @@ -33,16 +33,20 @@ export const getDataSourceLogs = (id: string, params?: any) => export const featchDataSourceDetail = (id: string) => request.get(api.dataSourceDetail(id)); -export const startGoogleDriveWebAuth = (payload: { credentials: string }) => - request.post(api.googleWebAuthStart('google-drive'), { data: payload }); +export const startGoogleDriveWebAuth = (payload: { + credentials: string; + redirect_uri?: string; +}) => request.post(api.googleWebAuthStart('google-drive'), { data: payload }); export const pollGoogleDriveWebAuthResult = (payload: { flow_id: string }) => request.post(api.googleWebAuthResult('google-drive'), { data: payload }); // Gmail web auth follows the same pattern as Google Drive, but uses // Gmail-specific endpoints and is consumed by the GmailTokenField UI. -export const startGmailWebAuth = (payload: { credentials: string }) => - request.post(api.googleWebAuthStart('gmail'), { data: payload }); +export const startGmailWebAuth = (payload: { + credentials: string; + redirect_uri?: string; +}) => request.post(api.googleWebAuthStart('gmail'), { data: payload }); export const pollGmailWebAuthResult = (payload: { flow_id: string }) => request.post(api.googleWebAuthResult('gmail'), { data: payload });