mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-08 20:25:19 +02:00
feat: implement re-authentication flow for connectors and enhance auth expiration handling
- Added a mechanism to mark connectors as 'auth_expired' in the database, allowing the frontend to prompt users for re-authentication. - Updated Linear and Notion connector routes to handle token refresh failures by invoking the new expiration flagging function. - Enhanced UI components to display re-authentication options when a connector's authentication status is expired.
This commit is contained in:
parent
81ddac1f54
commit
7ba5e9c662
9 changed files with 202 additions and 18 deletions
|
|
@ -15,6 +15,9 @@ from pydantic import ValidationError
|
|||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from app.config import config
|
||||
from app.connectors.linear_connector import fetch_linear_organization_name
|
||||
from app.db import (
|
||||
|
|
@ -335,8 +338,6 @@ async def linear_callback(
|
|||
reauth_return_url = data.get("return_url")
|
||||
|
||||
if reauth_connector_id:
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
result = await session.execute(
|
||||
select(SearchSourceConnector).filter(
|
||||
SearchSourceConnector.id == reauth_connector_id,
|
||||
|
|
@ -446,6 +447,22 @@ async def linear_callback(
|
|||
) from e
|
||||
|
||||
|
||||
async def _mark_connector_auth_expired(
|
||||
session: AsyncSession, connector: SearchSourceConnector
|
||||
) -> None:
|
||||
"""Persist auth_expired flag in the connector config so the frontend can show a re-auth prompt."""
|
||||
try:
|
||||
connector.config = {**connector.config, "auth_expired": True}
|
||||
flag_modified(connector, "config")
|
||||
await session.commit()
|
||||
await session.refresh(connector)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
f"Failed to persist auth_expired flag for connector {connector.id}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
async def refresh_linear_token(
|
||||
session: AsyncSession, connector: SearchSourceConnector
|
||||
) -> SearchSourceConnector:
|
||||
|
|
@ -479,6 +496,7 @@ async def refresh_linear_token(
|
|||
) from e
|
||||
|
||||
if not refresh_token:
|
||||
await _mark_connector_auth_expired(session, connector)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No refresh token available. Please re-authenticate.",
|
||||
|
|
@ -521,6 +539,7 @@ async def refresh_linear_token(
|
|||
or "expired" in error_lower
|
||||
or "revoked" in error_lower
|
||||
):
|
||||
await _mark_connector_auth_expired(session, connector)
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Linear authentication failed. Please re-authenticate.",
|
||||
|
|
@ -557,10 +576,14 @@ async def refresh_linear_token(
|
|||
credentials.expires_at = expires_at
|
||||
credentials.scope = token_json.get("scope")
|
||||
|
||||
# Update connector config with encrypted tokens
|
||||
# Update connector config with encrypted tokens, preserving non-credential fields
|
||||
credentials_dict = credentials.to_dict()
|
||||
credentials_dict["_token_encrypted"] = True
|
||||
if connector.config.get("organization_name"):
|
||||
credentials_dict["organization_name"] = connector.config["organization_name"]
|
||||
credentials_dict.pop("auth_expired", None)
|
||||
connector.config = credentials_dict
|
||||
flag_modified(connector, "config")
|
||||
await session.commit()
|
||||
await session.refresh(connector)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ from pydantic import ValidationError
|
|||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from app.config import config
|
||||
from app.db import (
|
||||
SearchSourceConnector,
|
||||
|
|
@ -334,8 +337,6 @@ async def notion_callback(
|
|||
reauth_return_url = data.get("return_url")
|
||||
|
||||
if reauth_connector_id:
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
result = await session.execute(
|
||||
select(SearchSourceConnector).filter(
|
||||
SearchSourceConnector.id == reauth_connector_id,
|
||||
|
|
@ -448,6 +449,22 @@ async def notion_callback(
|
|||
) from e
|
||||
|
||||
|
||||
async def _mark_connector_auth_expired(
|
||||
session: AsyncSession, connector: SearchSourceConnector
|
||||
) -> None:
|
||||
"""Persist auth_expired flag in the connector config so the frontend can show a re-auth prompt."""
|
||||
try:
|
||||
connector.config = {**connector.config, "auth_expired": True}
|
||||
flag_modified(connector, "config")
|
||||
await session.commit()
|
||||
await session.refresh(connector)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
f"Failed to persist auth_expired flag for connector {connector.id}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
async def refresh_notion_token(
|
||||
session: AsyncSession, connector: SearchSourceConnector
|
||||
) -> SearchSourceConnector:
|
||||
|
|
@ -481,6 +498,7 @@ async def refresh_notion_token(
|
|||
) from e
|
||||
|
||||
if not refresh_token:
|
||||
await _mark_connector_auth_expired(session, connector)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No refresh token available. Please re-authenticate.",
|
||||
|
|
@ -523,6 +541,7 @@ async def refresh_notion_token(
|
|||
or "expired" in error_lower
|
||||
or "revoked" in error_lower
|
||||
):
|
||||
await _mark_connector_auth_expired(session, connector)
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Notion authentication failed. Please re-authenticate.",
|
||||
|
|
@ -571,7 +590,9 @@ async def refresh_notion_token(
|
|||
# Update connector config with encrypted tokens
|
||||
credentials_dict = credentials.to_dict()
|
||||
credentials_dict["_token_encrypted"] = True
|
||||
credentials_dict.pop("auth_expired", None)
|
||||
connector.config = credentials_dict
|
||||
flag_modified(connector, "config")
|
||||
await session.commit()
|
||||
await session.refresh(connector)
|
||||
|
||||
|
|
|
|||
|
|
@ -1236,6 +1236,48 @@ async def run_slack_indexing(
|
|||
)
|
||||
|
||||
|
||||
_AUTH_ERROR_PATTERNS = (
|
||||
"failed to refresh linear oauth",
|
||||
"failed to refresh your notion connection",
|
||||
"failed to refresh notion token",
|
||||
"authentication failed",
|
||||
"auth_expired",
|
||||
"token has been expired or revoked",
|
||||
"invalid_grant",
|
||||
)
|
||||
|
||||
|
||||
def _is_auth_error(error_message: str) -> bool:
|
||||
"""Check if an error message indicates an OAuth token expiry failure."""
|
||||
if not error_message:
|
||||
return False
|
||||
lower = error_message.lower()
|
||||
return any(pattern in lower for pattern in _AUTH_ERROR_PATTERNS)
|
||||
|
||||
|
||||
async def _persist_auth_expired(session: AsyncSession, connector_id: int) -> None:
|
||||
"""Flag a connector as auth_expired so the frontend shows a re-auth prompt."""
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(SearchSourceConnector).where(
|
||||
SearchSourceConnector.id == connector_id
|
||||
)
|
||||
)
|
||||
connector = result.scalar_one_or_none()
|
||||
if connector and not connector.config.get("auth_expired"):
|
||||
connector.config = {**connector.config, "auth_expired": True}
|
||||
flag_modified(connector, "config")
|
||||
await session.commit()
|
||||
logger.info(f"Marked connector {connector_id} as auth_expired")
|
||||
except Exception:
|
||||
logger.warning(
|
||||
f"Failed to persist auth_expired for connector {connector_id}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
async def _run_indexing_with_notifications(
|
||||
session: AsyncSession,
|
||||
connector_id: int,
|
||||
|
|
@ -1520,6 +1562,8 @@ async def _run_indexing_with_notifications(
|
|||
else:
|
||||
# Actual failure
|
||||
logger.error(f"Indexing failed: {error_or_warning}")
|
||||
if _is_auth_error(str(error_or_warning)):
|
||||
await _persist_auth_expired(session, connector_id)
|
||||
if notification:
|
||||
# Refresh notification to ensure it's not stale after indexing function commits
|
||||
await session.refresh(notification)
|
||||
|
|
@ -1584,6 +1628,9 @@ async def _run_indexing_with_notifications(
|
|||
except Exception as e:
|
||||
logger.error(f"Error in indexing task: {e!s}", exc_info=True)
|
||||
|
||||
if _is_auth_error(str(e)):
|
||||
await _persist_auth_expired(session, connector_id)
|
||||
|
||||
# Update notification on exception
|
||||
if notification:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from dataclasses import dataclass
|
|||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from app.connectors.linear_connector import LinearConnector
|
||||
from app.db import (
|
||||
|
|
@ -118,6 +119,17 @@ class LinearToolMetadataService:
|
|||
workspace.name,
|
||||
e,
|
||||
)
|
||||
try:
|
||||
connector.config = {**connector.config, "auth_expired": True}
|
||||
flag_modified(connector, "config")
|
||||
await self._db_session.commit()
|
||||
await self._db_session.refresh(connector)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to persist auth_expired for connector %s",
|
||||
connector.id,
|
||||
exc_info=True,
|
||||
)
|
||||
workspaces.append(
|
||||
{
|
||||
"id": workspace.id,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from dataclasses import dataclass
|
|||
from sqlalchemy import and_, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from app.connectors.notion_history import NotionHistoryConnector
|
||||
from app.db import (
|
||||
|
|
@ -92,6 +93,25 @@ class NotionToolMetadataService:
|
|||
acc_dict = acc.to_dict()
|
||||
auth_expired = await self._check_account_health(acc.id)
|
||||
acc_dict["auth_expired"] = auth_expired
|
||||
if auth_expired:
|
||||
try:
|
||||
result = await self._db_session.execute(
|
||||
select(SearchSourceConnector).where(
|
||||
SearchSourceConnector.id == acc.id
|
||||
)
|
||||
)
|
||||
db_connector = result.scalar_one_or_none()
|
||||
if db_connector and not db_connector.config.get("auth_expired"):
|
||||
db_connector.config = {**db_connector.config, "auth_expired": True}
|
||||
flag_modified(db_connector, "config")
|
||||
await self._db_session.commit()
|
||||
await self._db_session.refresh(db_connector)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to persist auth_expired for connector %s",
|
||||
acc.id,
|
||||
exc_info=True,
|
||||
)
|
||||
accounts_with_status.append(acc_dict)
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,26 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft, Plus, Server } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Plus, RefreshCw, Server } from "lucide-react";
|
||||
import { type FC, useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
import { formatRelativeDate } from "@/lib/format-date";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useConnectorStatus } from "../hooks/use-connector-status";
|
||||
import { getConnectorDisplayName } from "../tabs/all-connectors-tab";
|
||||
|
||||
const REAUTH_ENDPOINTS: Partial<Record<string, string>> = {
|
||||
[EnumConnectorName.LINEAR_CONNECTOR]: "/api/v1/auth/linear/connector/reauth",
|
||||
[EnumConnectorName.NOTION_CONNECTOR]: "/api/v1/auth/notion/connector/reauth",
|
||||
};
|
||||
|
||||
interface ConnectorAccountsListViewProps {
|
||||
connectorType: string;
|
||||
connectorTitle: string;
|
||||
|
|
@ -43,12 +52,46 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
|||
isConnecting = false,
|
||||
addButtonText,
|
||||
}) => {
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const [reauthingId, setReauthingId] = useState<number | null>(null);
|
||||
|
||||
// Get connector status
|
||||
const { isConnectorEnabled, getConnectorStatusMessage } = useConnectorStatus();
|
||||
|
||||
const isEnabled = isConnectorEnabled(connectorType);
|
||||
const statusMessage = getConnectorStatusMessage(connectorType);
|
||||
|
||||
const reauthEndpoint = REAUTH_ENDPOINTS[connectorType];
|
||||
|
||||
const handleReauth = useCallback(
|
||||
async (connectorId: number) => {
|
||||
if (!searchSpaceId || !reauthEndpoint) return;
|
||||
setReauthingId(connectorId);
|
||||
try {
|
||||
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
||||
const url = new URL(`${backendUrl}${reauthEndpoint}`);
|
||||
url.searchParams.set("connector_id", String(connectorId));
|
||||
url.searchParams.set("space_id", String(searchSpaceId));
|
||||
url.searchParams.set("return_url", window.location.pathname);
|
||||
const response = await authenticatedFetch(url.toString());
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
toast.error(data.detail ?? "Failed to initiate re-authentication.");
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
if (data.auth_url) {
|
||||
window.location.href = data.auth_url;
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to initiate re-authentication.");
|
||||
} finally {
|
||||
setReauthingId(null);
|
||||
}
|
||||
},
|
||||
[searchSpaceId, reauthEndpoint]
|
||||
);
|
||||
|
||||
// Filter connectors to only show those of this type
|
||||
const typeConnectors = connectors.filter((c) => c.connector_type === connectorType);
|
||||
|
||||
|
|
@ -149,6 +192,8 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
|||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{typeConnectors.map((connector) => {
|
||||
const isIndexing = indexingConnectorIds.has(connector.id);
|
||||
const isAuthExpired =
|
||||
!!reauthEndpoint && connector.config?.auth_expired === true;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -189,14 +234,30 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
|||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80 shrink-0"
|
||||
onClick={() => onManage(connector)}
|
||||
>
|
||||
Manage
|
||||
</Button>
|
||||
{isAuthExpired ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-amber-600 hover:bg-amber-700 text-white border-0 shadow-xs shrink-0"
|
||||
onClick={() => handleReauth(connector.id)}
|
||||
disabled={reauthingId === connector.id}
|
||||
>
|
||||
{reauthingId === connector.id ? (
|
||||
<Spinner size="xs" />
|
||||
) : (
|
||||
<RefreshCw className="size-3.5" />
|
||||
)}
|
||||
Re-authenticate
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80 shrink-0"
|
||||
onClick={() => onManage(connector)}
|
||||
>
|
||||
Manage
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { CornerDownLeftIcon, Pen } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -13,7 +14,6 @@ import {
|
|||
} from "@/components/ui/select";
|
||||
import { PlateEditor } from "@/components/editor/plate-editor";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
|
||||
|
||||
interface LinearLabel {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { CornerDownLeftIcon, Pen } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -13,7 +14,6 @@ import {
|
|||
} from "@/components/ui/select";
|
||||
import { PlateEditor } from "@/components/editor/plate-editor";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
|
||||
|
||||
interface InterruptResult {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { CornerDownLeftIcon, Pen } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PlateEditor } from "@/components/editor/plate-editor";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
|
||||
|
||||
interface InterruptResult {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue