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:
Anish Sarkar 2026-03-19 01:20:48 +05:30
parent 81ddac1f54
commit 7ba5e9c662
9 changed files with 202 additions and 18 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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