mirror of
https://github.com/danswer-ai/danswer.git
synced 2025-09-25 19:37:29 +02:00
first cut at slack oauth flow (#3323)
* first cut at slack oauth flow * fix usage of hooks * fix button spacing * add additional error logging * no dev redirect * cleanup * comment work in progress * move some stuff to ee, add some playwright tests for the oauth callback edge cases * fix ee, fix test name * fix tests * code review fixes
This commit is contained in:
@@ -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 { EE_ENABLED, NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
|
||||
export interface AdvancedConfig {
|
||||
refreshFreq: number;
|
||||
pruneFreq: number;
|
||||
@@ -110,6 +112,23 @@ export default function AddConnector({
|
||||
}: {
|
||||
connector: ConfigurableSources;
|
||||
}) {
|
||||
const [currentPageUrl, setCurrentPageUrl] = useState<string | null>(null);
|
||||
const [oauthUrl, setOauthUrl] = useState<string | null>(null);
|
||||
const [isAuthorizing, setIsAuthorizing] = useState(false);
|
||||
const [isAuthorizeVisible, setIsAuthorizeVisible] = useState(false);
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
setCurrentPageUrl(window.location.href);
|
||||
}
|
||||
|
||||
if (EE_ENABLED && NEXT_PUBLIC_CLOUD_ENABLED) {
|
||||
const sourceMetadata = getSourceMetadata(connector);
|
||||
if (sourceMetadata?.oauthSupported == true) {
|
||||
setIsAuthorizeVisible(true);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// State for managing credentials and files
|
||||
@@ -135,8 +154,13 @@ export default function AddConnector({
|
||||
const configuration: ConnectionConfiguration = connectorConfigs[connector];
|
||||
|
||||
// Form context and popup management
|
||||
const { setFormStep, setAlowCreate, formStep, nextFormStep, prevFormStep } =
|
||||
useFormContext();
|
||||
const {
|
||||
setFormStep,
|
||||
setAllowCreate: setAllowCreate,
|
||||
formStep,
|
||||
nextFormStep,
|
||||
prevFormStep,
|
||||
} = useFormContext();
|
||||
const { popup, setPopup } = usePopup();
|
||||
|
||||
// Hooks for Google Drive and Gmail credentials
|
||||
@@ -192,7 +216,7 @@ export default function AddConnector({
|
||||
|
||||
const onSwap = async (selectedCredential: Credential<any>) => {
|
||||
setCurrentCredential(selectedCredential);
|
||||
setAlowCreate(true);
|
||||
setAllowCreate(true);
|
||||
setPopup({
|
||||
message: "Swapped credential successfully!",
|
||||
type: "success",
|
||||
@@ -204,6 +228,37 @@ export default function AddConnector({
|
||||
router.push("/admin/indexing/status?message=connector-created");
|
||||
};
|
||||
|
||||
const handleAuthorize = async () => {
|
||||
// authorize button handler
|
||||
// gets an auth url from the server and directs the user to it in a popup
|
||||
|
||||
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 (
|
||||
<Formik
|
||||
initialValues={{
|
||||
@@ -385,16 +440,31 @@ export default function AddConnector({
|
||||
onSwitch={onSwap}
|
||||
/>
|
||||
{!createConnectorToggle && (
|
||||
<button
|
||||
className="mt-6 text-sm bg-background-900 px-2 py-1.5 flex text-text-200 flex-none rounded"
|
||||
onClick={() =>
|
||||
setCreateConnectorToggle(
|
||||
(createConnectorToggle) => !createConnectorToggle
|
||||
)
|
||||
}
|
||||
>
|
||||
Create New
|
||||
</button>
|
||||
<div className="mt-6 flex space-x-4">
|
||||
{/* Button to pop up a form to manually enter credentials */}
|
||||
<button
|
||||
className="mt-6 text-sm bg-background-900 px-2 py-1.5 flex text-text-200 flex-none rounded mr-4"
|
||||
onClick={() =>
|
||||
setCreateConnectorToggle(
|
||||
(createConnectorToggle) => !createConnectorToggle
|
||||
)
|
||||
}
|
||||
>
|
||||
Create New
|
||||
</button>
|
||||
|
||||
{/* Button to sign in via OAuth */}
|
||||
<button
|
||||
onClick={handleAuthorize}
|
||||
className="mt-6 text-sm bg-blue-500 px-2 py-1.5 flex text-text-200 flex-none rounded"
|
||||
disabled={isAuthorizing}
|
||||
hidden={!isAuthorizeVisible}
|
||||
>
|
||||
{isAuthorizing
|
||||
? "Authorizing..."
|
||||
: `Authorize with ${getSourceDisplayName(connector)}`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* NOTE: connector will never be google_drive, since the ternary above will
|
||||
|
111
web/src/app/admin/connectors/[connector]/oauth/callback/page.tsx
Normal file
111
web/src/app/admin/connectors/[connector]/oauth/callback/page.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
"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 { 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();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [statusMessage, setStatusMessage] = useState("Processing...");
|
||||
const [statusDetails, setStatusDetails] = useState(
|
||||
"Please wait while we complete the setup."
|
||||
);
|
||||
const [redirectUrl, setRedirectUrl] = useState<string | null>(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];
|
||||
|
||||
useEffect(() => {
|
||||
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}`);
|
||||
|
||||
setStatusMessage("Processing...");
|
||||
setStatusDetails("Please wait while we complete authorization.");
|
||||
setIsError(false); // Ensure no error state during loading
|
||||
|
||||
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 (
|
||||
<div className="container mx-auto py-8">
|
||||
<AdminPageTitle title={pageTitle} icon={<KeyIcon size={32} />} />
|
||||
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<CardSection className="max-w-md">
|
||||
<h1 className="text-2xl font-bold mb-4">{statusMessage}</h1>
|
||||
<p className="text-text-500">{statusDetails}</p>
|
||||
{redirectUrl && !isError && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm">
|
||||
Click{" "}
|
||||
<a href={redirectUrl} className="text-blue-500 underline">
|
||||
here
|
||||
</a>{" "}
|
||||
to continue.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardSection>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -20,7 +20,7 @@ interface FormContextType {
|
||||
allowAdvanced: boolean;
|
||||
setAllowAdvanced: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
allowCreate: boolean;
|
||||
setAlowCreate: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setAllowCreate: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const FormContext = createContext<FormContextType | undefined>(undefined);
|
||||
@@ -39,7 +39,7 @@ export const FormProvider: React.FC<{
|
||||
const [formValues, setFormValues] = useState<Record<string, any>>({});
|
||||
|
||||
const [allowAdvanced, setAllowAdvanced] = useState(false);
|
||||
const [allowCreate, setAlowCreate] = useState(false);
|
||||
const [allowCreate, setAllowCreate] = useState(false);
|
||||
|
||||
const nextFormStep = (values = "") => {
|
||||
setFormStep((prevStep) => prevStep + 1);
|
||||
@@ -88,7 +88,7 @@ export const FormProvider: React.FC<{
|
||||
allowAdvanced,
|
||||
setAllowAdvanced,
|
||||
allowCreate,
|
||||
setAlowCreate,
|
||||
setAllowCreate,
|
||||
};
|
||||
|
||||
return (
|
||||
|
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import {
|
||||
ConnectorIndexingStatus,
|
||||
OAuthSlackCallbackResponse,
|
||||
DocumentBoostStatus,
|
||||
Tag,
|
||||
UserGroup,
|
||||
|
80
web/src/lib/oauth_utils.ts
Normal file
80
web/src/lib/oauth_utils.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
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)
|
||||
): Promise<OAuthPrepareAuthorizationResponse> {
|
||||
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;
|
||||
}
|
||||
|
||||
// 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<OAuthSlackCallbackResponse> {
|
||||
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) {
|
||||
let errorDetails = `Failed to handle OAuth authorization response: ${response.status}`;
|
||||
|
||||
try {
|
||||
const responseBody = await response.text(); // Read the body as text
|
||||
errorDetails += `\nResponse Body: ${responseBody}`;
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
errorDetails += `\nUnable to read response body: ${err.message}`;
|
||||
} else {
|
||||
errorDetails += `\nUnable to read response body: Unknown error type`;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(errorDetails);
|
||||
}
|
||||
|
||||
// Parse the JSON response
|
||||
const data = (await response.json()) as OAuthSlackCallbackResponse;
|
||||
return data;
|
||||
}
|
@@ -124,6 +124,7 @@ export interface SourceMetadata {
|
||||
shortDescription?: string;
|
||||
internalName: ValidSources;
|
||||
adminUrl: string;
|
||||
oauthSupported?: boolean;
|
||||
}
|
||||
|
||||
export interface SearchDefaultOverrides {
|
||||
|
@@ -76,6 +76,7 @@ export const SOURCE_METADATA_MAP: SourceMap = {
|
||||
displayName: "Slack",
|
||||
category: SourceCategory.Messaging,
|
||||
docs: "https://docs.danswer.dev/connectors/slack",
|
||||
oauthSupported: true,
|
||||
},
|
||||
gmail: {
|
||||
icon: GmailIcon,
|
||||
@@ -341,6 +342,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);
|
||||
};
|
||||
|
@@ -135,6 +135,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 {
|
||||
has_successful_run: boolean;
|
||||
source: ValidSources;
|
||||
|
65
web/tests/e2e/admin_oauth_redirect_uri.spec.ts
Normal file
65
web/tests/e2e/admin_oauth_redirect_uri.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { test, expect } from "@chromatic-com/playwright";
|
||||
|
||||
test(
|
||||
"Admin - OAuth Redirect - Missing Code",
|
||||
{
|
||||
tag: "@admin",
|
||||
},
|
||||
async ({ page }, testInfo) => {
|
||||
await page.goto(
|
||||
"http://localhost:3000/admin/connectors/slack/oauth/callback?state=xyz"
|
||||
);
|
||||
|
||||
await expect(page.locator("p.text-text-500")).toHaveText(
|
||||
"Missing authorization code."
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"Admin - OAuth Redirect - Missing State",
|
||||
{
|
||||
tag: "@admin",
|
||||
},
|
||||
async ({ page }, testInfo) => {
|
||||
await page.goto(
|
||||
"http://localhost:3000/admin/connectors/slack/oauth/callback?code=123"
|
||||
);
|
||||
|
||||
await expect(page.locator("p.text-text-500")).toHaveText(
|
||||
"Missing state parameter."
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"Admin - OAuth Redirect - Invalid Connector",
|
||||
{
|
||||
tag: "@admin",
|
||||
},
|
||||
async ({ page }, testInfo) => {
|
||||
await page.goto(
|
||||
"http://localhost:3000/admin/connectors/invalid-connector/oauth/callback?code=123&state=xyz"
|
||||
);
|
||||
|
||||
await expect(page.locator("p.text-text-500")).toHaveText(
|
||||
"invalid-connector is not a valid source type."
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"Admin - OAuth Redirect - No Session",
|
||||
{
|
||||
tag: "@admin",
|
||||
},
|
||||
async ({ page }, testInfo) => {
|
||||
await page.goto(
|
||||
"http://localhost:3000/admin/connectors/slack/oauth/callback?code=123&state=xyz"
|
||||
);
|
||||
|
||||
await expect(page.locator("p.text-text-500")).toHaveText(
|
||||
"An error occurred during the OAuth process. Please try again."
|
||||
);
|
||||
}
|
||||
);
|
Reference in New Issue
Block a user