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:
rkuo-danswer
2024-12-06 11:55:21 -08:00
committed by GitHub
parent 7a0d823c89
commit 7a3c06c2d2
16 changed files with 796 additions and 21 deletions

View File

@@ -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

View 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>
);
}

View File

@@ -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 (

View File

@@ -1,6 +1,7 @@
"use client";
import {
ConnectorIndexingStatus,
OAuthSlackCallbackResponse,
DocumentBoostStatus,
Tag,
UserGroup,

View 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;
}

View File

@@ -124,6 +124,7 @@ export interface SourceMetadata {
shortDescription?: string;
internalName: ValidSources;
adminUrl: string;
oauthSupported?: boolean;
}
export interface SearchDefaultOverrides {

View File

@@ -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);
};

View File

@@ -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;

View 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."
);
}
);