From 0fdaa93d5a56cb085db1c5a4e0bdd275693e9b6d Mon Sep 17 00:00:00 2001 From: "Richard Kuo (Danswer)" Date: Tue, 3 Dec 2024 13:44:35 -0800 Subject: [PATCH 01/11] first cut at slack oauth flow --- .../tasks/doc_permission_syncing/tasks.py | 3 +- backend/danswer/configs/app_configs.py | 7 + backend/danswer/db/credentials.py | 1 - backend/danswer/main.py | 3 +- backend/danswer/server/oauth.py | 424 ++++++++++++++++++ .../[connector]/AddConnectorPage.tsx | 94 +++- .../[connector]/oauth/callback/page.tsx | 148 ++++++ web/src/lib/hooks.ts | 63 ++- web/src/lib/oauth_utils.ts | 36 ++ web/src/lib/search/interfaces.ts | 1 + web/src/lib/sources.ts | 3 + web/src/lib/types.ts | 12 + 12 files changed, 776 insertions(+), 19 deletions(-) create mode 100644 backend/danswer/server/oauth.py create mode 100644 web/src/app/admin/connectors/[connector]/oauth/callback/page.tsx create mode 100644 web/src/lib/oauth_utils.ts diff --git a/backend/danswer/background/celery/tasks/doc_permission_syncing/tasks.py b/backend/danswer/background/celery/tasks/doc_permission_syncing/tasks.py index 6a5761a7428..d36bc532801 100644 --- a/backend/danswer/background/celery/tasks/doc_permission_syncing/tasks.py +++ b/backend/danswer/background/celery/tasks/doc_permission_syncing/tasks.py @@ -8,6 +8,7 @@ from celery import Task from celery.exceptions import SoftTimeLimitExceeded from redis import Redis +from redis.lock import Lock as RedisLock from danswer.access.models import DocExternalAccess from danswer.background.celery.apps.app_base import task_logger @@ -216,7 +217,7 @@ def connector_permission_sync_generator_task( r = get_redis_client(tenant_id=tenant_id) - lock = r.lock( + lock: RedisLock = r.lock( DanswerRedisLocks.CONNECTOR_DOC_PERMISSIONS_SYNC_LOCK_PREFIX + f"_{redis_connector.id}", timeout=CELERY_PERMISSIONS_SYNC_LOCK_TIMEOUT, diff --git a/backend/danswer/configs/app_configs.py b/backend/danswer/configs/app_configs.py index e06d3f5c420..2b7605fe80d 100644 --- a/backend/danswer/configs/app_configs.py +++ b/backend/danswer/configs/app_configs.py @@ -84,6 +84,13 @@ or "" ) +OAUTH_SLACK_CLIENT_ID = os.environ.get("OAUTH_SLACK_CLIENT_ID", "") +OAUTH_SLACK_CLIENT_SECRET = os.environ.get("OAUTH_SLACK_CLIENT_SECRET", "") +OAUTH_CONFLUENCE_CLIENT_ID = os.environ.get("OAUTH_CONFLUENCE_CLIENT_ID", "") +OAUTH_CONFLUENCE_CLIENT_SECRET = os.environ.get("OAUTH_CONFLUENCE_CLIENT_SECRET", "") +OAUTH_JIRA_CLIENT_ID = os.environ.get("OAUTH_JIRA_CLIENT_ID", "") +OAUTH_JIRA_CLIENT_SECRET = os.environ.get("OAUTH_JIRA_CLIENT_SECRET", "") + USER_AUTH_SECRET = os.environ.get("USER_AUTH_SECRET", "") # for basic auth REQUIRE_EMAIL_VERIFICATION = ( diff --git a/backend/danswer/db/credentials.py b/backend/danswer/db/credentials.py index 4a146c5c5f4..5f4b83b4791 100644 --- a/backend/danswer/db/credentials.py +++ b/backend/danswer/db/credentials.py @@ -248,7 +248,6 @@ def create_credential( ) db_session.commit() - return credential diff --git a/backend/danswer/main.py b/backend/danswer/main.py index 3fd7072bb9a..a457ebf3fef 100644 --- a/backend/danswer/main.py +++ b/backend/danswer/main.py @@ -77,6 +77,7 @@ from danswer.server.manage.slack_bot import router as slack_bot_management_router from danswer.server.manage.users import router as user_router from danswer.server.middleware.latency_logging import add_latency_logging_middleware +from danswer.server.oauth import router as oauth_router from danswer.server.openai_assistants_api.full_openai_assistants_api import ( get_full_openai_assistants_api_router, ) @@ -103,7 +104,6 @@ from shared_configs.configs import MULTI_TENANT from shared_configs.configs import SENTRY_DSN - logger = setup_logger() @@ -280,6 +280,7 @@ def get_application() -> FastAPI: application, get_full_openai_assistants_api_router() ) include_router_with_global_prefix_prepended(application, long_term_logs_router) + include_router_with_global_prefix_prepended(application, oauth_router) if AUTH_TYPE == AuthType.DISABLED: # Server logs this during auth setup verification step diff --git a/backend/danswer/server/oauth.py b/backend/danswer/server/oauth.py new file mode 100644 index 00000000000..a7cb6ffecac --- /dev/null +++ b/backend/danswer/server/oauth.py @@ -0,0 +1,424 @@ +import base64 +import uuid +from typing import cast + +import requests +from fastapi import APIRouter +from fastapi import Depends +from fastapi import HTTPException +from fastapi.responses import JSONResponse +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from danswer.auth.users import current_user +from danswer.configs.app_configs import OAUTH_CONFLUENCE_CLIENT_ID +from danswer.configs.app_configs import OAUTH_CONFLUENCE_CLIENT_SECRET +from danswer.configs.app_configs import OAUTH_SLACK_CLIENT_ID +from danswer.configs.app_configs import OAUTH_SLACK_CLIENT_SECRET +from danswer.configs.app_configs import WEB_DOMAIN +from danswer.configs.constants import DocumentSource +from danswer.db.credentials import create_credential +from danswer.db.engine import get_current_tenant_id +from danswer.db.engine import get_session +from danswer.db.models import User +from danswer.redis.redis_pool import get_redis_client +from danswer.server.documents.models import CredentialBase +from danswer.utils.logger import setup_logger + + +logger = setup_logger() + +router = APIRouter(prefix="/oauth") + + +class SlackOAuth: + # https://knock.app/blog/how-to-authenticate-users-in-slack-using-oauth + # Example: https://api.slack.com/authentication/oauth-v2#exchanging + + class OAuthSession(BaseModel): + """Stored in redis to be looked up on callback""" + + email: str + redirect_on_success: str | None # Where to send the user if OAuth flow succeeds + + CLIENT_ID = OAUTH_SLACK_CLIENT_ID + CLIENT_SECRET = OAUTH_SLACK_CLIENT_SECRET + + TOKEN_URL = "https://slack.com/api/oauth.v2.access" + + # SCOPE is per https://docs.danswer.dev/connectors/slack + BOT_SCOPE = ( + "channels:history," + "channels:read," + "groups:history," + "groups:read," + "channels:join," + "im:history," + "users:read," + "users:read.email," + "usergroups:read" + ) + + REDIRECT_URI = f"{WEB_DOMAIN}/admin/connectors/slack/oauth/callback" + DEV_REDIRECT_URI = f"https://redirectmeto.com/{REDIRECT_URI}" + + @classmethod + def generate_oauth_url(cls, state: str) -> str: + url = ( + f"https://slack.com/oauth/v2/authorize" + f"?client_id={cls.CLIENT_ID}" + f"&redirect_uri={cls.REDIRECT_URI}" + f"&scope={cls.BOT_SCOPE}" + f"&state={state}" + ) + return url + + @classmethod + def generate_dev_oauth_url(cls, state: str) -> str: + """dev mode workaround for localhost testing + - https://www.nango.dev/blog/oauth-redirects-on-localhost-with-https + """ + + url = ( + f"https://slack.com/oauth/v2/authorize" + f"?client_id={cls.CLIENT_ID}" + f"&redirect_uri={cls.DEV_REDIRECT_URI}" + f"&scope={cls.BOT_SCOPE}" + f"&state={state}" + ) + return url + + @classmethod + def session_dump_json(cls, email: str, redirect_on_success: str | None) -> str: + """Temporary state to store in redis. to be looked up on auth response. + Returns a json string. + """ + session = SlackOAuth.OAuthSession( + email=email, redirect_on_success=redirect_on_success + ) + return session.model_dump_json() + + @classmethod + def parse_session(cls, session_json: str) -> OAuthSession: + session = SlackOAuth.OAuthSession.model_validate_json(session_json) + return session + + +class ConfluenceCloudOAuth: + """work in progress""" + + # https://developer.atlassian.com/cloud/confluence/oauth-2-3lo-apps/ + + class OAuthSession(BaseModel): + """Stored in redis to be looked up on callback""" + + email: str + redirect_on_success: str | None # Where to send the user if OAuth flow succeeds + + CLIENT_ID = OAUTH_CONFLUENCE_CLIENT_ID + CLIENT_SECRET = OAUTH_CONFLUENCE_CLIENT_SECRET + TOKEN_URL = "https://auth.atlassian.com/oauth/token" + + # All read scopes per https://developer.atlassian.com/cloud/confluence/scopes-for-oauth-2-3LO-and-forge-apps/ + CONFLUENCE_OAUTH_SCOPE = ( + "read:confluence-props%20" + "read:confluence-content.all%20" + "read:confluence-content.summary%20" + "read:confluence-content.permission%20" + "read:confluence-user%20" + "read:confluence-groups%20" + "readonly:content.attachment:confluence" + ) + + REDIRECT_URI = f"{WEB_DOMAIN}/admin/connectors/confluence/oauth/callback" + DEV_REDIRECT_URI = f"https://redirectmeto.com/{REDIRECT_URI}" + + # eventually for Confluence Data Center + # oauth_url = ( + # f"http://localhost:8090/rest/oauth/v2/authorize?client_id={CONFLUENCE_OAUTH_CLIENT_ID}" + # f"&scope={CONFLUENCE_OAUTH_SCOPE_2}" + # f"&redirect_uri={redirectme_uri}" + # ) + + @classmethod + def generate_oauth_url(cls, state: str) -> str: + return cls._generate_oauth_url_helper(cls.REDIRECT_URI, state) + + @classmethod + def generate_dev_oauth_url(cls, state: str) -> str: + """dev mode workaround for localhost testing + - https://www.nango.dev/blog/oauth-redirects-on-localhost-with-https + """ + return cls._generate_oauth_url_helper(cls.DEV_REDIRECT_URI, state) + + @classmethod + def _generate_oauth_url_helper(cls, redirect_uri: str, state: str) -> str: + url = ( + "https://auth.atlassian.com/authorize" + f"?audience=api.atlassian.com" + f"&client_id={cls.CLIENT_ID}" + f"&redirect_uri={redirect_uri}" + f"&scope={cls.CONFLUENCE_OAUTH_SCOPE}" + f"&state={state}" + "&response_type=code" + "&prompt=consent" + ) + return url + + @classmethod + def session_dump_json(cls, email: str, redirect_on_success: str | None) -> str: + """Temporary state to store in redis. to be looked up on auth response. + Returns a json string. + """ + session = ConfluenceCloudOAuth.OAuthSession( + email=email, redirect_on_success=redirect_on_success + ) + return session.model_dump_json() + + @classmethod + def parse_session(cls, session_json: str) -> SlackOAuth.OAuthSession: + session = SlackOAuth.OAuthSession.model_validate_json(session_json) + return session + + +@router.post("/prepare-authorization-request") +def prepare_authorization_request( + connector: DocumentSource, + redirect_on_success: str | None, + user: User = Depends(current_user), + tenant_id: str | None = Depends(get_current_tenant_id), +) -> JSONResponse: + """Used by the frontend to generate the url for the user's browser during auth request. + + Example: https://www.oauth.com/oauth2-servers/authorization/the-authorization-request/ + """ + + oauth_uuid = uuid.uuid4() + oauth_uuid_str = str(oauth_uuid) + oauth_state = ( + base64.urlsafe_b64encode(oauth_uuid.bytes).rstrip(b"=").decode("utf-8") + ) + + if connector == DocumentSource.SLACK: + oauth_url = SlackOAuth.generate_oauth_url(oauth_state) + session = SlackOAuth.session_dump_json( + email=user.email, redirect_on_success=redirect_on_success + ) + elif connector == DocumentSource.CONFLUENCE: + oauth_url = ConfluenceCloudOAuth.generate_oauth_url(oauth_state) + session = ConfluenceCloudOAuth.session_dump_json( + email=user.email, redirect_on_success=redirect_on_success + ) + # elif connector == DocumentSource.JIRA: + # oauth_url = JiraCloudOAuth.generate_dev_oauth_url(oauth_state) + # elif connector == DocumentSource.GOOGLE_DRIVE: + # oauth_url = GoogleDriveOAuth.generate_dev_oauth_url(oauth_state) + else: + oauth_url = None + + if not oauth_url: + raise HTTPException( + status_code=404, + detail=f"The document source type {connector} does not have OAuth implemented", + ) + + r = get_redis_client(tenant_id=tenant_id) + + # 10 min is the max we want an oauth flow to be valid + r.set(f"da_oauth:{oauth_uuid_str}", session, ex=600) + + return JSONResponse(content={"url": oauth_url}) + + +@router.post("/connector/slack/callback") +def handle_slack_oauth_callback( + code: str, + state: str, + user: User = Depends(current_user), + db_session: Session = Depends(get_session), + tenant_id: str | None = Depends(get_current_tenant_id), +) -> JSONResponse: + if not SlackOAuth.CLIENT_ID or not SlackOAuth.CLIENT_SECRET: + raise HTTPException( + status_code=500, + detail="Slack client ID or client secret is not configured.", + ) + + r = get_redis_client(tenant_id=tenant_id) + + # recover the state + padded_state = state + "=" * ( + -len(state) % 4 + ) # Add padding back (Base64 decoding requires padding) + uuid_bytes = base64.urlsafe_b64decode( + padded_state + ) # Decode the Base64 string back to bytes + + # Convert bytes back to a UUID + oauth_uuid = uuid.UUID(bytes=uuid_bytes) + oauth_uuid_str = str(oauth_uuid) + + r_key = f"da_oauth:{oauth_uuid_str}" + + session_json_bytes = cast(bytes, r.get(r_key)) + if not session_json_bytes: + raise HTTPException( + status_code=400, + detail=f"Slack OAuth failed - OAuth state key not found: key={r_key}", + ) + + session_json = session_json_bytes.decode("utf-8") + try: + session = SlackOAuth.parse_session(session_json) + + # Exchange the authorization code for an access token + response = requests.post( + SlackOAuth.TOKEN_URL, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data={ + "client_id": SlackOAuth.CLIENT_ID, + "client_secret": SlackOAuth.CLIENT_SECRET, + "code": code, + "redirect_uri": SlackOAuth.DEV_REDIRECT_URI, + }, + ) + + response_data = response.json() + + if not response_data.get("ok"): + raise HTTPException( + status_code=400, + detail=f"Slack OAuth failed: {response_data.get('error')}", + ) + + # Extract token and team information + access_token: str = response_data.get("access_token") + team_id: str = response_data.get("team", {}).get("id") + authed_user_id: str = response_data.get("authed_user", {}).get("id") + + credential_info = CredentialBase( + credential_json={"slack_bot_token": access_token}, + admin_public=True, + source=DocumentSource.SLACK, + name="Slack OAuth", + ) + + create_credential(credential_info, user, db_session) + except Exception as e: + return JSONResponse( + status_code=500, + content={ + "success": False, + "message": f"An error occurred during Slack OAuth: {str(e)}", + }, + ) + finally: + r.delete(r_key) + + # return the result + return JSONResponse( + content={ + "success": True, + "message": "Slack OAuth completed successfully.", + "team_id": team_id, + "authed_user_id": authed_user_id, + "redirect_on_success": session.redirect_on_success, + } + ) + + +# Work in progress +# @router.post("/connector/confluence/callback") +# def handle_confluence_oauth_callback( +# code: str, +# state: str, +# user: User = Depends(current_user), +# db_session: Session = Depends(get_session), +# tenant_id: str | None = Depends(get_current_tenant_id), +# ) -> JSONResponse: +# if not ConfluenceCloudOAuth.CLIENT_ID or not ConfluenceCloudOAuth.CLIENT_SECRET: +# raise HTTPException( +# status_code=500, +# detail="Confluence client ID or client secret is not configured." +# ) + +# r = get_redis_client(tenant_id=tenant_id) + +# # recover the state +# padded_state = state + '=' * (-len(state) % 4) # Add padding back (Base64 decoding requires padding) +# uuid_bytes = base64.urlsafe_b64decode(padded_state) # Decode the Base64 string back to bytes + +# # Convert bytes back to a UUID +# oauth_uuid = uuid.UUID(bytes=uuid_bytes) +# oauth_uuid_str = str(oauth_uuid) + +# r_key = f"da_oauth:{oauth_uuid_str}" + +# result = r.get(r_key) +# if not result: +# raise HTTPException( +# status_code=400, +# detail=f"Confluence OAuth failed - OAuth state key not found: key={r_key}" +# ) + +# try: +# session = ConfluenceCloudOAuth.parse_session(result) + +# # Exchange the authorization code for an access token +# response = requests.post( +# ConfluenceCloudOAuth.TOKEN_URL, +# headers={"Content-Type": "application/x-www-form-urlencoded"}, +# data={ +# "client_id": ConfluenceCloudOAuth.CLIENT_ID, +# "client_secret": ConfluenceCloudOAuth.CLIENT_SECRET, +# "code": code, +# "redirect_uri": ConfluenceCloudOAuth.DEV_REDIRECT_URI, +# }, +# ) + +# response_data = response.json() + +# if not response_data.get("ok"): +# raise HTTPException( +# status_code=400, +# detail=f"ConfluenceCloudOAuth OAuth failed: {response_data.get('error')}" +# ) + +# # Extract token and team information +# access_token: str = response_data.get("access_token") +# team_id: str = response_data.get("team", {}).get("id") +# authed_user_id: str = response_data.get("authed_user", {}).get("id") + +# credential_info = CredentialBase( +# credential_json={"slack_bot_token": access_token}, +# admin_public=True, +# source=DocumentSource.CONFLUENCE, +# name="Confluence OAuth", +# ) + +# logger.info(f"Slack access token: {access_token}") + +# credential = create_credential(credential_info, user, db_session) + +# logger.info(f"new_credential_id={credential.id}") +# except Exception as e: +# return JSONResponse( +# status_code=500, +# content={ +# "success": False, +# "message": f"An error occurred during Slack OAuth: {str(e)}", +# }, +# ) +# finally: +# r.delete(r_key) + +# # return the result +# return JSONResponse( +# content={ +# "success": True, +# "message": "Slack OAuth completed successfully.", +# "team_id": team_id, +# "authed_user_id": authed_user_id, +# "redirect_on_success": session.redirect_on_success, +# } +# ) diff --git a/web/src/app/admin/connectors/[connector]/AddConnectorPage.tsx b/web/src/app/admin/connectors/[connector]/AddConnectorPage.tsx index 8e7bac228c7..50575e43c4f 100644 --- a/web/src/app/admin/connectors/[connector]/AddConnectorPage.tsx +++ b/web/src/app/admin/connectors/[connector]/AddConnectorPage.tsx @@ -9,9 +9,9 @@ import { AdminPageTitle } from "@/components/admin/Title"; import { buildSimilarCredentialInfoURL } from "@/app/admin/connector/[ccPairId]/lib"; import { usePopup } from "@/components/admin/connectors/Popup"; import { useFormContext } from "@/components/context/FormContext"; -import { getSourceDisplayName } from "@/lib/sources"; +import { getSourceDisplayName, getSourceMetadata } from "@/lib/sources"; import { SourceIcon } from "@/components/SourceIcon"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { deleteCredential, linkCredential } from "@/lib/credential"; import { submitFiles } from "./pages/utils/files"; import { submitGoogleSite } from "./pages/utils/google_site"; @@ -43,6 +43,8 @@ import { Formik } from "formik"; import NavigationRow from "./NavigationRow"; import { useRouter } from "next/navigation"; import CardSection from "@/components/admin/CardSection"; +import { prepareOAuthAuthorizationRequest } from "@/lib/oauth_utils"; +import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants"; export interface AdvancedConfig { refreshFreq: number; pruneFreq: number; @@ -110,6 +112,22 @@ export default function AddConnector({ }: { connector: ConfigurableSources; }) { + const [currentPageUrl, setCurrentPageUrl] = useState(null); + const [oauthUrl, setOauthUrl] = useState(null); + const [isAuthorizing, setIsAuthorizing] = useState(false); + const [isAuthorizeVisible, setIsAuthorizeVisible] = useState(false); + useEffect(() => { + if (typeof window !== "undefined") { + setCurrentPageUrl(window.location.href); + } + + const sourceMetadata = getSourceMetadata(connector); + // if(sourceMetadata?.oauthSupported == true && NEXT_PUBLIC_CLOUD_ENABLED) { + if (sourceMetadata?.oauthSupported == true) { + setIsAuthorizeVisible(true); + } + }, []); + const router = useRouter(); // State for managing credentials and files @@ -135,8 +153,13 @@ export default function AddConnector({ const configuration: ConnectionConfiguration = connectorConfigs[connector]; // Form context and popup management - const { setFormStep, setAlowCreate, formStep, nextFormStep, prevFormStep } = - useFormContext(); + const { + setFormStep, + setAlowCreate: setAllowCreate, + formStep, + nextFormStep, + prevFormStep, + } = useFormContext(); const { popup, setPopup } = usePopup(); // Hooks for Google Drive and Gmail credentials @@ -192,7 +215,7 @@ export default function AddConnector({ const onSwap = async (selectedCredential: Credential) => { setCurrentCredential(selectedCredential); - setAlowCreate(true); + setAllowCreate(true); setPopup({ message: "Swapped credential successfully!", type: "success", @@ -204,6 +227,34 @@ export default function AddConnector({ router.push("/admin/indexing/status?message=connector-created"); }; + const handleAuthorize = async () => { + if (!currentPageUrl) return; + + setIsAuthorizing(true); + try { + const response = await prepareOAuthAuthorizationRequest( + connector, + currentPageUrl + ); + if (response.url) { + setOauthUrl(response.url); + window.open(response.url, "_blank", "noopener,noreferrer"); + } else { + setPopup({ message: "Failed to fetch OAuth URL", type: "error" }); + } + } catch (error: unknown) { + // Narrow the type of error + if (error instanceof Error) { + setPopup({ message: `Error: ${error.message}`, type: "error" }); + } else { + // Handle non-standard errors + setPopup({ message: "An unknown error occurred", type: "error" }); + } + } finally { + setIsAuthorizing(false); + } + }; + return ( {!createConnectorToggle && ( - +
+ + {/* Button to sign in via OAuth */} + +
)} {/* NOTE: connector will never be google_drive, since the ternary above will diff --git a/web/src/app/admin/connectors/[connector]/oauth/callback/page.tsx b/web/src/app/admin/connectors/[connector]/oauth/callback/page.tsx new file mode 100644 index 00000000000..baa4b7b3fa0 --- /dev/null +++ b/web/src/app/admin/connectors/[connector]/oauth/callback/page.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { AdminPageTitle } from "@/components/admin/Title"; +import { Button } from "@/components/ui/button"; +import Title from "@/components/ui/title"; +import { + useConnectorOAuthCallback, + useSlackConnectorOAuthCallback, +} from "@/lib/hooks"; +import { KeyIcon } from "@/components/icons/icons"; +import { getSourceMetadata, isValidSource } from "@/lib/sources"; +import { ValidSources } from "@/lib/types"; +import CardSection from "@/components/admin/CardSection"; + +export default function OAuthCallbackPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + + const [statusMessage, setStatusMessage] = useState("Processing..."); + const [statusDetails, setStatusDetails] = useState( + "Please wait while we complete the setup." + ); + const [redirectUrl, setRedirectUrl] = useState(null); + const [isError, setIsError] = useState(false); + const [pageTitle, setPageTitle] = useState( + "Authorize with Third-Party service" + ); + + // Extract query parameters + const code = searchParams.get("code"); + const state = searchParams.get("state"); + + const pathname = usePathname(); + const connector = pathname?.split("/")[3]; + + const { data, error, isLoading } = useConnectorOAuthCallback( + connector, + code, + state + ); + + useEffect(() => { + if (!code || !state) { + setStatusMessage("Improperly formed OAuth authorization request."); + setStatusDetails( + !code ? "Missing authorization code." : "Missing state parameter." + ); + setIsError(true); + return; + } + + if (!isValidSource(connector)) { + setStatusMessage( + `The specified connector source type ${connector} does not exist.` + ); + setStatusDetails(`${connector} is not a valid source type.`); + setIsError(true); + return; + } + + const sourceMetadata = getSourceMetadata(connector as ValidSources); + setPageTitle(`Authorize with ${sourceMetadata.displayName}`); + + if (isLoading) { + setStatusMessage("Processing..."); + setStatusDetails("Please wait while we complete authorization."); + setIsError(false); // Ensure no error state during loading + return; + } + + console.log("OAuth data:", data); + if (!data || error) { + console.error("OAuth error:", error); + setStatusMessage("Oops, something went wrong!"); + setStatusDetails( + "An error occurred during the OAuth process. Please try again." + ); + setIsError(true); + return; + } + + setStatusMessage("Success!"); + setStatusDetails( + `Your ${sourceMetadata.displayName} app has been installed successfully.` + ); + setRedirectUrl(data.redirect_on_success); // Extract the redirect URL + setIsError(false); + }, [code, state, error, isLoading]); + + return ( +
+ } /> + +
+ +

{statusMessage}

+

{statusDetails}

+ {redirectUrl && !isError && ( +
+

+ Click{" "} + + here + {" "} + to continue. +

+
+ )} +
+
+ + {/*
+ + {statusMessage} + +

{statusDetails}

+ + {redirectUrl && !isError && ( +
+

+ Click{" "} + + here + {" "} + to proceed to the next step. +

+
+ )} + + {isError && ( + + )} +
*/} +
+ ); +} diff --git a/web/src/lib/hooks.ts b/web/src/lib/hooks.ts index 3645f8a5b56..35c85ac3f99 100644 --- a/web/src/lib/hooks.ts +++ b/web/src/lib/hooks.ts @@ -1,6 +1,7 @@ "use client"; import { ConnectorIndexingStatus, + OAuthSlackCallbackResponse, DocumentBoostStatus, Tag, UserGroup, @@ -71,7 +72,9 @@ export const useConnectorCredentialIndexingStatus = ( getEditable = false ) => { const { mutate } = useSWRConfig(); - const url = `${INDEXING_STATUS_URL}${getEditable ? "?get_editable=true" : ""}`; + const url = `${INDEXING_STATUS_URL}${ + getEditable ? "?get_editable=true" : "" + }`; const swrResponse = useSWR[]>( url, errorHandlingFetcher, @@ -84,6 +87,64 @@ export const useConnectorCredentialIndexingStatus = ( }; }; +// server side handler to process the oauth redirect callback +// https://api.slack.com/authentication/oauth-v2#exchanging +export const useConnectorOAuthCallback = ( + connector: string, + code: string | null, + state: string | null +) => { + if (connector === "slack") { + return useSlackConnectorOAuthCallback(code, state); + } + + return { + data: undefined, + error: new Error(`No callback handler for ${connector}`), + isLoading: false, + }; +}; + +export const useSlackConnectorOAuthCallback = ( + code: string | null, + state: string | null +) => { + if (!code || !state) { + return { + data: undefined, + error: new Error("Missing code or state for Slack OAuth callback"), + isLoading: false, + }; + } + + const url = `/api/oauth/connector/slack/callback?code=${encodeURIComponent( + code + )}&state=${encodeURIComponent(state)}`; + + // Custom fetch function for POST request + const fetcher = async () => { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ code, state }), + }); + + if (!response.ok) { + throw new Error(`Failed to post OAuth callback: ${response.statusText}`); + } + + return response.json(); + }; + + const swrResponse = useSWR(url, fetcher); + + return { + ...swrResponse, + }; +}; + export const useCategories = () => { const { mutate } = useSWRConfig(); const swrResponse = useSWR( diff --git a/web/src/lib/oauth_utils.ts b/web/src/lib/oauth_utils.ts new file mode 100644 index 00000000000..a94e834d3ca --- /dev/null +++ b/web/src/lib/oauth_utils.ts @@ -0,0 +1,36 @@ +import { OAuthPrepareAuthorizationResponse } from "./types"; + +export async function prepareOAuthAuthorizationRequest( + connector: string, + finalRedirect: string | null // a redirect (not the oauth redirect) for the user to return to after oauth is complete) +): Promise { + let url = `/api/oauth/prepare-authorization-request?connector=${encodeURIComponent( + connector + )}`; + + // Conditionally append the `redirect_on_success` parameter + if (finalRedirect) { + url += `&redirect_on_success=${encodeURIComponent(finalRedirect)}`; + } + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + connector: connector, + redirect_on_success: finalRedirect, + }), + }); + + if (!response.ok) { + throw new Error( + `Failed to prepare OAuth authorization request: ${response.status}` + ); + } + + // Parse the JSON response + const data = (await response.json()) as OAuthPrepareAuthorizationResponse; + return data; +} diff --git a/web/src/lib/search/interfaces.ts b/web/src/lib/search/interfaces.ts index a24cc222652..f4280a57ac2 100644 --- a/web/src/lib/search/interfaces.ts +++ b/web/src/lib/search/interfaces.ts @@ -121,6 +121,7 @@ export interface SourceMetadata { shortDescription?: string; internalName: ValidSources; adminUrl: string; + oauthSupported?: boolean; } export interface SearchDefaultOverrides { diff --git a/web/src/lib/sources.ts b/web/src/lib/sources.ts index fde648db209..ab59036bb45 100644 --- a/web/src/lib/sources.ts +++ b/web/src/lib/sources.ts @@ -58,6 +58,7 @@ type SourceMap = { [K in ValidSources]: PartialSourceMetadata; }; +// rkuo: feels like this and other data should be refactored into the backend const SOURCE_METADATA_MAP: SourceMap = { web: { icon: GlobeIcon, @@ -76,6 +77,7 @@ const SOURCE_METADATA_MAP: SourceMap = { displayName: "Slack", category: SourceCategory.Messaging, docs: "https://docs.danswer.dev/connectors/slack", + oauthSupported: true, }, gmail: { icon: GmailIcon, @@ -341,6 +343,7 @@ export function listSourceMetadata(): SourceMetadata[] { export function getSourceDocLink(sourceType: ValidSources): string | null { return SOURCE_METADATA_MAP[sourceType].docs || null; } + export const isValidSource = (sourceType: string) => { return Object.keys(SOURCE_METADATA_MAP).includes(sourceType); }; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 8ea6047dd1a..fd6c3558741 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -134,6 +134,18 @@ export interface ConnectorIndexingStatus< in_progress: boolean; } +export interface OAuthPrepareAuthorizationResponse { + url: string; +} + +export interface OAuthSlackCallbackResponse { + success: boolean; + message: string; + team_id: string; + authed_user_id: string; + redirect_on_success: string; +} + export interface CCPairBasicInfo { docs_indexed: number; has_successful_run: boolean; From 76138710f71c0ed1581c0a041b3af06f52d8096d Mon Sep 17 00:00:00 2001 From: "Richard Kuo (Danswer)" Date: Tue, 3 Dec 2024 15:17:38 -0800 Subject: [PATCH 02/11] fix usage of hooks --- .../[connector]/AddConnectorPage.tsx | 3 +- .../[connector]/oauth/callback/page.tsx | 131 +++++++----------- web/src/lib/hooks.ts | 58 -------- web/src/lib/oauth_utils.ts | 35 ++++- 4 files changed, 82 insertions(+), 145 deletions(-) diff --git a/web/src/app/admin/connectors/[connector]/AddConnectorPage.tsx b/web/src/app/admin/connectors/[connector]/AddConnectorPage.tsx index 50575e43c4f..5ffbc1cc302 100644 --- a/web/src/app/admin/connectors/[connector]/AddConnectorPage.tsx +++ b/web/src/app/admin/connectors/[connector]/AddConnectorPage.tsx @@ -122,8 +122,7 @@ export default function AddConnector({ } const sourceMetadata = getSourceMetadata(connector); - // if(sourceMetadata?.oauthSupported == true && NEXT_PUBLIC_CLOUD_ENABLED) { - if (sourceMetadata?.oauthSupported == true) { + if (sourceMetadata?.oauthSupported == true && NEXT_PUBLIC_CLOUD_ENABLED) { setIsAuthorizeVisible(true); } }, []); diff --git a/web/src/app/admin/connectors/[connector]/oauth/callback/page.tsx b/web/src/app/admin/connectors/[connector]/oauth/callback/page.tsx index baa4b7b3fa0..30fd9e07478 100644 --- a/web/src/app/admin/connectors/[connector]/oauth/callback/page.tsx +++ b/web/src/app/admin/connectors/[connector]/oauth/callback/page.tsx @@ -5,14 +5,11 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { AdminPageTitle } from "@/components/admin/Title"; import { Button } from "@/components/ui/button"; import Title from "@/components/ui/title"; -import { - useConnectorOAuthCallback, - useSlackConnectorOAuthCallback, -} from "@/lib/hooks"; import { KeyIcon } from "@/components/icons/icons"; import { getSourceMetadata, isValidSource } from "@/lib/sources"; import { ValidSources } from "@/lib/types"; import CardSection from "@/components/admin/CardSection"; +import { handleOAuthAuthorizationResponse } from "@/lib/oauth_utils"; export default function OAuthCallbackPage() { const router = useRouter(); @@ -35,59 +32,58 @@ export default function OAuthCallbackPage() { const pathname = usePathname(); const connector = pathname?.split("/")[3]; - const { data, error, isLoading } = useConnectorOAuthCallback( - connector, - code, - state - ); - useEffect(() => { - if (!code || !state) { - setStatusMessage("Improperly formed OAuth authorization request."); - setStatusDetails( - !code ? "Missing authorization code." : "Missing state parameter." - ); - setIsError(true); - return; - } - - if (!isValidSource(connector)) { - setStatusMessage( - `The specified connector source type ${connector} does not exist.` - ); - setStatusDetails(`${connector} is not a valid source type.`); - setIsError(true); - return; - } - - const sourceMetadata = getSourceMetadata(connector as ValidSources); - setPageTitle(`Authorize with ${sourceMetadata.displayName}`); + const handleOAuthCallback = async () => { + if (!code || !state) { + setStatusMessage("Improperly formed OAuth authorization request."); + setStatusDetails( + !code ? "Missing authorization code." : "Missing state parameter." + ); + setIsError(true); + return; + } + + if (!connector || !isValidSource(connector)) { + setStatusMessage( + `The specified connector source type ${connector} does not exist.` + ); + setStatusDetails(`${connector} is not a valid source type.`); + setIsError(true); + return; + } + + const sourceMetadata = getSourceMetadata(connector as ValidSources); + setPageTitle(`Authorize with ${sourceMetadata.displayName}`); - if (isLoading) { setStatusMessage("Processing..."); setStatusDetails("Please wait while we complete authorization."); setIsError(false); // Ensure no error state during loading - return; - } - console.log("OAuth data:", data); - if (!data || error) { - console.error("OAuth error:", error); - setStatusMessage("Oops, something went wrong!"); - setStatusDetails( - "An error occurred during the OAuth process. Please try again." - ); - setIsError(true); - return; - } - - setStatusMessage("Success!"); - setStatusDetails( - `Your ${sourceMetadata.displayName} app has been installed successfully.` - ); - setRedirectUrl(data.redirect_on_success); // Extract the redirect URL - setIsError(false); - }, [code, state, error, isLoading]); + try { + const response = await handleOAuthAuthorizationResponse(code, state); + + if (!response) { + throw new Error("Empty response from OAuth server."); + } + + setStatusMessage("Success!"); + setStatusDetails( + `Your authorization with ${sourceMetadata.displayName} completed successfully.` + ); + setRedirectUrl(response.redirect_on_success); // Extract the redirect URL + setIsError(false); + } catch (error) { + console.error("OAuth error:", error); + setStatusMessage("Oops, something went wrong!"); + setStatusDetails( + "An error occurred during the OAuth process. Please try again." + ); + setIsError(true); + } + }; + + handleOAuthCallback(); + }, [code, state, connector]); return (
@@ -110,39 +106,6 @@ export default function OAuthCallbackPage() { )}
- - {/*
- - {statusMessage} - -

{statusDetails}

- - {redirectUrl && !isError && ( -
-

- Click{" "} - - here - {" "} - to proceed to the next step. -

-
- )} - - {isError && ( - - )} -
*/} ); } diff --git a/web/src/lib/hooks.ts b/web/src/lib/hooks.ts index 1148516a760..8d3a72a0e65 100644 --- a/web/src/lib/hooks.ts +++ b/web/src/lib/hooks.ts @@ -88,64 +88,6 @@ export const useConnectorCredentialIndexingStatus = ( }; }; -// server side handler to process the oauth redirect callback -// https://api.slack.com/authentication/oauth-v2#exchanging -export const useConnectorOAuthCallback = ( - connector: string, - code: string | null, - state: string | null -) => { - if (connector === "slack") { - return useSlackConnectorOAuthCallback(code, state); - } - - return { - data: undefined, - error: new Error(`No callback handler for ${connector}`), - isLoading: false, - }; -}; - -export const useSlackConnectorOAuthCallback = ( - code: string | null, - state: string | null -) => { - if (!code || !state) { - return { - data: undefined, - error: new Error("Missing code or state for Slack OAuth callback"), - isLoading: false, - }; - } - - const url = `/api/oauth/connector/slack/callback?code=${encodeURIComponent( - code - )}&state=${encodeURIComponent(state)}`; - - // Custom fetch function for POST request - const fetcher = async () => { - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ code, state }), - }); - - if (!response.ok) { - throw new Error(`Failed to post OAuth callback: ${response.statusText}`); - } - - return response.json(); - }; - - const swrResponse = useSWR(url, fetcher); - - return { - ...swrResponse, - }; -}; - export const useCategories = () => { const { mutate } = useSWRConfig(); const swrResponse = useSWR( diff --git a/web/src/lib/oauth_utils.ts b/web/src/lib/oauth_utils.ts index a94e834d3ca..e0b5a9f2696 100644 --- a/web/src/lib/oauth_utils.ts +++ b/web/src/lib/oauth_utils.ts @@ -1,5 +1,9 @@ -import { OAuthPrepareAuthorizationResponse } from "./types"; +import { + OAuthPrepareAuthorizationResponse, + OAuthSlackCallbackResponse, +} from "./types"; +// server side handler to help initiate the oauth authorization request export async function prepareOAuthAuthorizationRequest( connector: string, finalRedirect: string | null // a redirect (not the oauth redirect) for the user to return to after oauth is complete) @@ -34,3 +38,32 @@ export async function prepareOAuthAuthorizationRequest( const data = (await response.json()) as OAuthPrepareAuthorizationResponse; return data; } + +// server side handler to process the oauth redirect callback +// https://api.slack.com/authentication/oauth-v2#exchanging +export async function handleOAuthAuthorizationResponse( + code: string, + state: string +): Promise { + const url = `/api/oauth/connector/slack/callback?code=${encodeURIComponent( + code + )}&state=${encodeURIComponent(state)}`; + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ code, state }), + }); + + if (!response.ok) { + throw new Error( + `Failed to handle OAuth authorization response: ${response.status}` + ); + } + + // Parse the JSON response + const data = (await response.json()) as OAuthSlackCallbackResponse; + return data; +} From e4de2a8f141b74fad0a487dcb091436e92c109a8 Mon Sep 17 00:00:00 2001 From: "Richard Kuo (Danswer)" Date: Tue, 3 Dec 2024 16:12:43 -0800 Subject: [PATCH 03/11] fix button spacing --- web/src/app/admin/connectors/[connector]/AddConnectorPage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/app/admin/connectors/[connector]/AddConnectorPage.tsx b/web/src/app/admin/connectors/[connector]/AddConnectorPage.tsx index 5ffbc1cc302..a607fd12f45 100644 --- a/web/src/app/admin/connectors/[connector]/AddConnectorPage.tsx +++ b/web/src/app/admin/connectors/[connector]/AddConnectorPage.tsx @@ -437,7 +437,7 @@ export default function AddConnector({ {!createConnectorToggle && (
+ {/* Button to sign in via OAuth */}