diff --git a/e2e-chatbot-app-next/client/src/components/chat-header.tsx b/e2e-chatbot-app-next/client/src/components/chat-header.tsx index 7b62a346..a83970b5 100644 --- a/e2e-chatbot-app-next/client/src/components/chat-header.tsx +++ b/e2e-chatbot-app-next/client/src/components/chat-header.tsx @@ -4,7 +4,7 @@ import { useWindowSize } from 'usehooks-ts'; import { SidebarToggle } from '@/components/sidebar-toggle'; import { Button } from '@/components/ui/button'; import { useSidebar } from './ui/sidebar'; -import { PlusIcon, CloudOffIcon, MessageSquareOff } from 'lucide-react'; +import { PlusIcon, CloudOffIcon, MessageSquareOff, ShieldAlert } from 'lucide-react'; import { useConfig } from '@/hooks/use-config'; import { Tooltip, @@ -19,7 +19,7 @@ const DOCS_URL = export function ChatHeader() { const navigate = useNavigate(); const { open } = useSidebar(); - const { chatHistoryEnabled, feedbackEnabled } = useConfig(); + const { chatHistoryEnabled, feedbackEnabled, oboEnabled, oboRequiredScopes } = useConfig(); const { width: windowWidth } = useWindowSize(); @@ -81,6 +81,32 @@ export function ChatHeader() { )} + {oboEnabled && ( + + + + + + OBO scopes required + + + +

+ This endpoint uses on-behalf-of user authorization. Add these scopes to your + app via the Databricks UI or databricks.yml:{' '} + {oboRequiredScopes.join(', ')}. + Note: UC function scopes are not yet supported. + Click to learn more. +

+
+
+
+ )} ); diff --git a/e2e-chatbot-app-next/client/src/contexts/AppConfigContext.tsx b/e2e-chatbot-app-next/client/src/contexts/AppConfigContext.tsx index caee9956..0a8cb3a8 100644 --- a/e2e-chatbot-app-next/client/src/contexts/AppConfigContext.tsx +++ b/e2e-chatbot-app-next/client/src/contexts/AppConfigContext.tsx @@ -7,6 +7,10 @@ interface ConfigResponse { chatHistory: boolean; feedback: boolean; }; + obo?: { + enabled: boolean; + requiredScopes: string[]; + }; } interface AppConfigContextType { @@ -15,6 +19,8 @@ interface AppConfigContextType { error: Error | undefined; chatHistoryEnabled: boolean; feedbackEnabled: boolean; + oboEnabled: boolean; + oboRequiredScopes: string[]; } const AppConfigContext = createContext( @@ -40,6 +46,8 @@ export function AppConfigProvider({ children }: { children: ReactNode }) { // Default to true until loaded to avoid breaking existing behavior chatHistoryEnabled: data?.features.chatHistory ?? true, feedbackEnabled: data?.features.feedback ?? false, + oboEnabled: data?.obo?.enabled ?? false, + oboRequiredScopes: data?.obo?.requiredScopes ?? [], }; return ( diff --git a/e2e-chatbot-app-next/packages/ai-sdk-providers/src/providers-server.ts b/e2e-chatbot-app-next/packages/ai-sdk-providers/src/providers-server.ts index ef1d0d12..8f2bf994 100644 --- a/e2e-chatbot-app-next/packages/ai-sdk-providers/src/providers-server.ts +++ b/e2e-chatbot-app-next/packages/ai-sdk-providers/src/providers-server.ts @@ -73,10 +73,10 @@ const LOG_SSE_EVENTS = process.env.LOG_SSE_EVENTS === 'true'; const API_PROXY = process.env.API_PROXY; -// Cache for endpoint details to check task type +// Cache for endpoint details to check task type and OBO scopes const endpointDetailsCache = new Map< string, - { task: string | undefined; timestamp: number } + { task: string | undefined; userApiScopes: string[]; timestamp: number } >(); const ENDPOINT_DETAILS_CACHE_DURATION = 5 * 60 * 1000; // 5 minutes @@ -271,7 +271,17 @@ async function getOrCreateDatabricksProvider(): Promise { return provider; } -// Get the task type of the serving endpoint +// Response type for serving endpoint details +interface EndpointDetailsResponse { + task: string | undefined; + auth_policy?: { + user_auth_policy: { + api_scopes: string[]; + }; + }; +} + +// Get the task type and OBO scopes of the serving endpoint const getEndpointDetails = async (servingEndpoint: string) => { const cached = endpointDetailsCache.get(servingEndpoint); if ( @@ -294,15 +304,41 @@ const getEndpointDetails = async (servingEndpoint: string) => { headers, }, ); - const data = (await response.json()) as { task: string | undefined }; + const data = (await response.json()) as EndpointDetailsResponse; + const userApiScopes = data.auth_policy?.user_auth_policy?.api_scopes ?? []; + + if (userApiScopes.length > 0) { + console.warn( + `⚠ OBO detected on endpoint "${servingEndpoint}". Required user authorization scopes: ${JSON.stringify(userApiScopes)}\n` + + ` → Add these scopes to your app via the Databricks UI or in databricks.yml under resources.apps..user_authorization.scopes\n` + + ` → See: https://docs.databricks.com/aws/en/dev-tools/databricks-apps/auth`, + ); + } + const returnValue = { task: data.task as string | undefined, + userApiScopes, timestamp: Date.now(), }; endpointDetailsCache.set(servingEndpoint, returnValue); return returnValue; }; +/** + * Returns the OBO scopes for the configured serving endpoint, or empty array. + * Fetches endpoint details if not yet cached. + */ +export async function getEndpointOboScopes(): Promise { + const servingEndpoint = process.env.DATABRICKS_SERVING_ENDPOINT; + if (!servingEndpoint) return []; + try { + const details = await getEndpointDetails(servingEndpoint); + return details.userApiScopes; + } catch { + return []; + } +} + // Create a smart provider wrapper that handles OAuth initialization interface SmartProvider { languageModel(id: string): Promise; diff --git a/e2e-chatbot-app-next/server/src/routes/config.ts b/e2e-chatbot-app-next/server/src/routes/config.ts index 0413eb93..1a62f5eb 100644 --- a/e2e-chatbot-app-next/server/src/routes/config.ts +++ b/e2e-chatbot-app-next/server/src/routes/config.ts @@ -5,18 +5,24 @@ import { type Router as RouterType, } from 'express'; import { isDatabaseAvailable } from '@chat-template/db'; +import { getEndpointOboScopes } from '@chat-template/ai-sdk-providers'; export const configRouter: RouterType = Router(); /** * GET /api/config - Get application configuration - * Returns feature flags based on environment configuration + * Returns feature flags and OBO status based on environment configuration */ -configRouter.get('/', (_req: Request, res: Response) => { +configRouter.get('/', async (_req: Request, res: Response) => { + const oboScopes = await getEndpointOboScopes(); res.json({ features: { chatHistory: isDatabaseAvailable(), feedback: !!process.env.MLFLOW_EXPERIMENT_ID, }, + obo: { + enabled: oboScopes.length > 0, + requiredScopes: oboScopes, + }, }); }); diff --git a/e2e-chatbot-app-next/tests/api-mocking/api-mock-handlers.ts b/e2e-chatbot-app-next/tests/api-mocking/api-mock-handlers.ts index 6c52d625..8d714aa6 100644 --- a/e2e-chatbot-app-next/tests/api-mocking/api-mock-handlers.ts +++ b/e2e-chatbot-app-next/tests/api-mocking/api-mock-handlers.ts @@ -280,10 +280,16 @@ export const handlers = [ // Mock fetching endpoint details // Returns agent/v1/responses to enable context injection testing + // Includes auth_policy to simulate an OBO-enabled endpoint http.get(/\/api\/2\.0\/serving-endpoints\/[^/]+$/, () => { return HttpResponse.json({ name: 'test-endpoint', task: 'agent/v1/responses', + auth_policy: { + user_auth_policy: { + api_scopes: ['serving.serving-endpoints'], + }, + }, }); }),