401 Error in Custom Teams Tab to Copilot Studio Direct Line Integration

Adrian Stauffer 20 Reputation points
2025-05-19T08:58:53.5533333+00:00

Hello,

I'm developing a custom Microsoft Teams Tab application that integrates with a Copilot Studio agent. I've designed a specific architecture to meet requirements like a custom UI, seamless Single Sign-On (SSO) within Teams, backend processing of bot interactions, and direct communication with Copilot Studio.

I'm currently facing a persistent 401 Unauthorized error when my backend attempts to send user messages to the Copilot Studio agent via the Direct Line API, and I'm hoping for some expert insight.

My Goal & Key Requirements (Why a Custom Solution):

I aim to provide users with a tailored chat experience embedded directly within Teams, going beyond the standard Web Chat widget. Key goals include:

  • Seamless User Experience with SSO: Users should open the Teams Tab and be automatically signed in using their existing Microsoft Entra ID credentials, without needing to log in separately to the bot or related services. This is crucial for a smooth, enterprise-grade experience.
  • Custom Frontend UI: The chat interface within Teams needs to match our branding and offer specific UI elements/controls not available in the standard Web Chat.
  • Backend Control & Orchestration:
    • I need a backend layer to manage user sessions, potentially enrich messages, interact with other internal services before or after bot interaction, and persist conversation history to our own database (Azure PostgreSQL).
    • The backend should handle the complexities of connecting to Copilot Studio and manage the message flow.
    • It's also responsible for managing real-time updates to the client using Azure Web PubSub (e.g., for progress indicators or streaming responses if the bot supported it).
  • Direct Copilot Studio Integration: The intention is to leverage the power of a Copilot Studio agent for its conversational AI capabilities, natural language understanding, and ability to trigger backend actions or access knowledge.
  • Secure & Scalable Architecture: The solution must be secure, using Managed Identities for Azure service communication, and scalable to handle our user base.

High-Level Architecture & Azure Services:

  • Frontend: Next.js/React application embedded as a Teams Tab.
  • Backend: Next.js API routes hosted within the same Azure App Service as the frontend.
  • Azure App Service (Linux, Node.js, Elastic Premium): Hosts the unified frontend and backend.
    • Backend API Endpoint for Direct Line Secret: COPILOT_DIRECTLINE_ENDPOINT_URL is set to https://europe.directline.botframework.com via an environment variable.
  • Azure Front Door (AFD):
    • Purpose: Provides a single, public, custom domain (https://bot.teamsapp.co) for the entire application (frontend assets and all backend APIs like /api/negotiate, /api/webpubsub, /api/messages).
    • Configuration: It routes all traffic (/*) to the Azure App Service. Handles SSL/TLS termination. This custom domain setup is critical for Teams SSO to function correctly, as the token obtained by the Teams client is scoped to this AFD domain.
  • Azure Web PubSub:
    • Purpose: Enables real-time, bidirectional communication between the Teams Tab frontend and the App Service backend.
    • Usage: Used for sending user messages from the client to the backend, and for the backend to push bot responses and system messages back to the client.
  • Microsoft Copilot Studio:
    • Agent: The conversational AI agent is built here.
    • Direct Line Secret Source: The secret used by my backend is obtained from the Copilot Studio portal: Settings -> Channels -> Web channel security -> "Require secured access" enabled -> Secret 1 copied. This secret is stored in Azure Key Vault.
  • Azure Bot Service (teamsapp-prod-botsvc) & OAuth Connection:
    • Purpose: While I'm aiming for direct Direct Line API communication with Copilot Studio for sending user messages, the Azure Bot Service resource is configured primarily to:
      1. Act as the messaging endpoint target for responses coming from Copilot Studio. Copilot Studio sends its replies through the Bot Framework infrastructure, which then POSTs them to the /api/messages endpoint of my App Service (as configured in the Azure Bot Service resource).
      2. Manage OAuth flows if initiated by Copilot Studio (e.g., if the agent uses an "Authenticate" action). It has an OAuth Connection setting (EntraIdOAuthConfig) configured with a Bot App Registration's credentials.
  • Azure Cache for Redis:
    • Purpose: Stores ephemeral user session state and conversation-related data with TTLs.
    • Usage:
      • user:<oid>:session: General user status.
      • conversation:<absConvId>:*: Maps Azure Bot Service conversationId (from /api/messages context) to user OID, sequence numbers for Web PubSub.
      • cs:user:<oid>:session: Stores the csConversationId (Direct Line conversation ID with Copilot Studio), csConversationToken (token for that specific CS conversation), and its expiry. This is populated by copilotService.ts after a successful call to /tokens/generate.
      • cs:serviceToken: Caches the general service token from /tokens/generate if it didn't immediately come with a conversationId.
      • Used by the Bot Framework Adapter (via RedisStorage) for turn state related to /api/messages.
  • Azure Key Vault: Securely stores the CopilotStudioDirectLineSecret and BotAppRegistrationClientSecret.
  • Azure PostgreSQL: For persistent storage of conversation history (not directly involved in the current issue).
  • Microsoft Entra ID: For Teams SSO and Managed Identities.

Envisioned Flows:

1. User Authentication & Web PubSub Connection:

  • Teams Client (SSO via Entra ID) -> Frontend JS (gets AAD token)
  • Frontend JS -> /api/negotiate (on App Service via AFD, sends AAD token)
  • App Service Backend (/api/negotiate) -> Validates AAD token
  • App Service Backend -> Azure Web PubSub Management API (generates client URL/token, embedding user OID)
  • App Service Backend -> Frontend JS (returns WPS URL/token)
  • Frontend JS -> Azure Web PubSub Service (connects via WebSocket)
  • Azure Web PubSub Service -> /api/webpubsub on App Service (upstream connect event)
  • App Service Backend (copilotService.initializeUserConnection):
    • POST https://europe.directline.botframework.com/v3/directline/tokens/generate (using Copilot Studio Web Channel Secret from Key Vault).
    • SUCCESSFUL: Receives token and conversationId (e.g., CS_Conv_A).
    • Stores CS_Conv_A and its token in Redis (cs:user:<oid>:session).

Screenshot 2025-05-19 111135

2. Outbound Message (User to Copilot Studio - WHERE IT FAILS):

  • Frontend JS (user types "hello") -> Azure Web PubSub Service (sends message)
  • Azure Web PubSub Service -> /api/webpubsub on App Service (upstream userMessage event)
  • App Service Backend (chatOrchestrator.handleUserMessage -> copilotService.sendActivityToCopilotStudio):
    • Retrieves CS_Conv_A and its token from Redis.
    • Attempts POST https://europe.directline.botframework.com/v3/directline/conversations/CS_Conv_A/activities (using the retrieved token).
    • FAILS HERE: Consistently receives 401 Unauthorized.

Screenshot 2025-05-19 111342

3. Inbound Message (Copilot Studio to User - Not Reached for User Messages):

  • Copilot Studio -> Bot Framework Infrastructure
  • Bot Framework Infrastructure -> Azure Bot Service (teamsapp-prod-botsvc)
  • Azure Bot Service -> /api/messages on App Service (via AFD, using messaging endpoint config)
  • App Service Backend (/api/messages with Bot Framework Adapter):
    • Validates Bot App ID/Secret.
    • Processes activity (e.g., OAuthCard, message).
    • If message: chatOrchestrator.handleBotActivity -> Looks up user OID (using ABS conversationId mapping in Redis) -> Azure Web PubSub Service (sendToUser)
    • Azure Web PubSub Service -> Frontend JS (receives message)

Screenshot 2025-05-19 111503

The Problem: Persistent 401 Unauthorized on POST /activities

Despite successfully obtaining a token and conversationId from POST /tokens/generate (using the Copilot Studio Web Channel Secret and the correct regional endpoint https://europe.directline.botframework.com), my backend receives a 401 Unauthorized when it attempts to POST an activity to https://europe.directline.botframework.com/v3/directline/conversations/{conversationId}/activities using that same token. The error provides no further specific details in its body.

This makes me question whether the Direct Line Secret from Copilot Studio's "Web channel security" settings is actually intended or authorized for this type of custom backend integration where the backend directly posts activities, or if it's strictly limited to use by the official Microsoft Web Chat control.

Key Code Snippets:

packages/chat-shared/src/lib/server/copilotService.ts - Token Generation & Conversation Start:

// const DIRECTLINE_ENDPOINT = process.env.COPILOT_DIRECTLINE_ENDPOINT_URL || 'https://europe.directline.botframework.com';
// const DIRECTLINE_API_VERSION = 'v3/directline'; // Assuming this is defined elsewhere

async function getServiceToken(): Promise<{ token: string; conversationId?: string; expiresIn: number } | null> {
    // ... fetches CopilotStudioDirectLineSecret from Key Vault ...
    // const secret = await getCopilotStudioDirectLineSecret();
    // if (!secret) return null;
    // const response = await fetch(`${DIRECTLINE_ENDPOINT}/${DIRECTLINE_API_VERSION}/tokens/generate`, {
    //   method: 'POST',
    //   headers: {
    //     Authorization: `Bearer ${secret}`,
    //   },
    // });
    // if (!response.ok) return null;
    // const result = await response.json();
    // return { token: result.token, conversationId: result.conversationId, expiresIn: result.expires_in };
    return null; // Placeholder for actual implementation
}

export async function startNewConversation(oid: string): Promise<boolean> {
  const tokenResult = await getServiceToken();
  if (!tokenResult) { /* ... error ... */ return false; }

  if (tokenResult.conversationId) { // Scenario 1: Token came with a conversationId
    // const csConversationId = tokenResult.conversationId;
    // await stateService.setCsUserSession(oid, {
    //   csConversationId,
    //   csConversationToken: tokenResult.token,
    //   csConversationTokenExpiresAt: Date.now() + tokenResult.expiresIn * 1000,
    // });
    // logger.info(`[CopilotService] Stored pre-created CS conversation ${csConversationId} and token for user ${oid} in Redis.`);
    return true;
  } else { // Scenario 2: Generic service token, need to start conversation
    // const conversationResponse = await fetch(`${DIRECTLINE_ENDPOINT}/${DIRECTLINE_API_VERSION}/conversations`, {
    //   method: 'POST',
    //   headers: {
    //     Authorization: `Bearer ${tokenResult.token}`,
    //   },
    // });
    // if (!conversationResponse.ok) return false;
    // const conversationDetails = await conversationResponse.json();
    // await stateService.setCsUserSession(oid, {
    //   csConversationId: conversationDetails.conversationId,
    //   csConversationToken: conversationDetails.token, // This is a new token specific to the conversation
    //   csConversationTokenExpiresAt: Date.now() + conversationDetails.expires_in * 1000,
    // });
    // logger.info(`[CopilotService] Started new CS conversation ${conversationDetails.conversationId} and stored its token for user ${oid} in Redis.`);
    return true;
  }
}

packages/chat-shared/src/lib/server/copilotService.ts - Sending Activity (Where 401 Occurs):

// Assuming DIRECTLINE_ENDPOINT and DIRECTLINE_API_VERSION are defined
// Assuming getCsUserSession and stateService are defined

export async function sendActivityToCopilotStudio(
    oid: string,
    text: string,
    clientActivityId: string // Example: from client, to track ack
): Promise<{ success: boolean; error?: string; activityId?: string }> {
  const session = await getCsUserSession(oid); // Retrieves csConversationId and csConversationToken from Redis
  if (!session || !session.csConversationId || !session.csConversationToken) {
    // logger.error(`[CopilotService] No valid CS conversation session found for user ${oid} when trying to send activity.`);
    return { success: false, error: 'No valid CS conversation session' };
  }
  const url = `${DIRECTLINE_ENDPOINT}/${DIRECTLINE_API_VERSION}/conversations/${session.csConversationId}/activities`;
  
  // logger.info(`[CopilotService] Attempting to POST activity to CS conversation ${session.csConversationId} at ${url} using token snippet: ${session.csConversationToken.substring(0,10)}...`);

  const activityPayload = {
    type: 'message',
    from: { id: oid, name: 'User' }, // Using OID as user ID in Direct Line
    text: text,
    channelData: { clientActivityID: clientActivityId }, // For tracking
    // timestamp: new Date().toISOString(), // Optional
    // localTimestamp: new Date().toISOString(), // Optional
  };

  const response = await fetch(url, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${session.csConversationToken}`, // This is the token from /tokens/generate or /conversations
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(activityPayload)
  });

  if (!response.ok) { // This is where it becomes 401
    const errorBody = await response.text();
    // logger.error(`[CopilotService] Post activity to CS conversation ${session.csConversationId} failed. Status: ${response.status} ${response.statusText}. Body: ${errorBody}`);
    return { success: false, error: `Post activity failed ${response.status} ${response.statusText}. Details: ${errorBody}` };
  }
  
  const responseData = await response.json();
  // logger.info(`[CopilotService] Successfully POSTed activity to CS conversation ${session.csConversationId}. Activity ID: ${responseData.id}`);
  return { success: true, activityId: responseData.id };
}

packages/app-service/src/app/api/messages/route.ts - Receiving Bot Responses:

import { NextRequest, NextResponse } from 'next/server';
// import { getBotAdapter } from '@chat-shared/botAdapter/adapter';
// import { handleBotActivity } from '@chat-shared/services/chat/chatOrchestrator';
// import { TurnContext, ActivityTypes } from 'botbuilder-core';
// import { Response } from 'botbuilder-core'; // For resAdapter

// Dummy implementations for structure
// const getBotAdapter = async () => ({ processActivity: async (req: any, res: any, handler: (turnContext: any) => Promise<void>) => { await handler({ activity: req.body || { type: 'message', name: ''} }); } });
// const handleBotActivity = async (turnContext: any) => { /* ... */ };

export async function POST(req: NextRequest) {
    // const adapter = await getBotAdapter(); // Uses Bot App ID/Secret from Key Vault
    const resAdapter = new NextResponse(); // Simplified for concept

    try {
        // Simulate adapter processing. In reality, adapter reads from req and writes to res.
        // await adapter.processActivity(req as any, resAdapter as any, async (turnContext: TurnContext) => {
        //     logger.info(`[API Messages] Received activity type: ${turnContext.activity.type}, name: ${turnContext.activity.name}`);
        //     if (turnContext.activity.type === ActivityTypes.Invoke && turnContext.activity.name === 'signin/tokenExchange') {
        //         // await adapter.exchangeToken(turnContext, OAUTH_CONNECTION_NAME, turnContext.activity.from.id, { token: turnContext.activity.value?.token });
        //         // logger.info(`[API Messages] Handled signin/tokenExchange for user ${turnContext.activity.from.id}`);
        //     } else {
        //         await handleBotActivity(turnContext);
        //     }
        // });
        return resAdapter;
    } catch (error) {
        // logger.error('[API Messages] Error processing activity:', error);
        // return NextResponse.json({ error: 'Failed to process activity' }, { status: 500 });
        return new NextResponse('Error', { status: 500});
    }
}

packages/chat-shared/src/services/chat/chatOrchestrator.ts - Handling Bot Activity:

// import { TurnContext, ActivityTypes } from 'botbuilder-core';
// import { stateService } from './stateService'; // Assuming for Redis interactions
// import { sendDataToUserViaWebPubSub } from './webPubSubService'; // Assuming
// import { logger } from '../utils/logger';

export async function handleBotActivity(turnContext: any /*TurnContext*/): Promise<void> {
    const activity = turnContext.activity;
    const absConversationId = activity.conversation?.id; // This is Azure Bot Service conversation ID
    
    if (!absConversationId) {
        // logger.warn('[ChatOrchestrator] Received bot activity without ABS conversation ID.', activity);
        return;
    }

    // IMPORTANT: Need to map absConversationId to user OID.
    // The key `cs:user:${oid}:session` stores CS conversation details.
    // For ABS, a mapping like `absconversation:${absConversationId}:oid` should have been stored
    // when the bot framework initiated a conversation or first interacted via /api/messages.
    // Or, if Copilot Studio can include user OID in channelData back to ABS, that could be used.
    // const userOid = await stateService.getOidForAbsConversation(absConversationId); 
    
    // if (!userOid) {
    //     logger.warn(`[ChatOrchestrator] Could not find user OID for ABS conversation ID: ${absConversationId}`);
    //     return;
    // }

    // if (activity.type === ActivityTypes.Message) {
    //     logger.info(`[ChatOrchestrator] Relaying message from bot (ABS Conv ID: ${absConversationId}) to user OID: ${userOid}`);
    //     await sendDataToUserViaWebPubSub(userOid, { type: 'botMessage', text: activity.text });
    // } else if (activity.type === ActivityTypes.Event && activity.name === 'progressIndicator') {
    //     // Example for custom events
    //     logger.info(`[ChatOrchestrator] Relaying progress indicator event for user OID: ${userOid}`);
    //     await sendDataToUserViaWebPubSub(userOid, { type: 'progress', data: activity.value });
    // } else if (activity.type === 'oauthCard') { // Hypothetical, usually an Attachment of type OAuthCard
        // If Copilot Studio sends an OAuthCard:
        // The Bot Framework Adapter at /api/messages would typically handle this if properly configured.
        // Or, if it needs to be relayed to a custom UI:
        // logger.info(`[ChatOrchestrator] Relaying OAuthCard for user OID: ${userOid}`);
        // await sendDataToUserViaWebPubSub(userOid, { type: 'oauthCard', card: activity.attachments?.[0]?.content });
    // }
    // else {
    //    logger.info(`[ChatOrchestrator] Received unhandled activity type: ${activity.type} for ABS Conv ID: ${absConversationId}`);
    // }
}

Microsoft Copilot | Other
{count} votes

1 answer

Sort by: Most helpful
  1. Smit Agrawal 105 Reputation points Microsoft External Staff
    2025-05-21T11:17:26.83+00:00

    Hi,
    You're encountering a 401 Unauthorized error when posting activities to the Direct Line API, which typically indicates an issue with the Direct Line token used in the request. Since your /tokens/generate call succeeds, the likely causes are:

    --Token Expiry: Direct Line tokens expire after 30 minutes. Ensure you're not reusing an expired token from Redis.

    --Incorrect Token Usage: Confirm that you're using the token (from /tokens/generate), not the secret, in the Authorization: Bearer <token> header when calling /activities.

    --Conversation ID Mismatch: Ensure the conversationId used in the /activities URL matches the one returned with the token.

    --CORS or Domain Mismatch: If you're calling from a domain not allowed in Copilot Studio's Web Channel settings, it may reject the request.

    Also you can check this doc : API reference - Direct Line API 3.0

    Double-check these areas and consider logging the token, conversation ID, and full request headers (excluding secrets) for debugging.

    Thanks,

    Smit Agrawal


    If the response is helpful, please click on "upvote" button. You can share your feedback via Microsoft Copilot Developer Feedback link. Click here to escalate.


Your answer

Answers can be marked as 'Accepted' by the question author and 'Recommended' by moderators, which helps users know the answer solved the author's problem.