• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

microsoft / botbuilder-js / 13264809812

11 Feb 2025 02:09PM CUT coverage: 84.524%. Remained the same
13264809812

Pull #4856

github

web-flow
Merge 4e615f951 into 7534989ce
Pull Request #4856: fix: [#4786] Clarify createBotFrameworkAuthenticationFromConfiguration usage in yo templates

8205 of 10860 branches covered (75.55%)

Branch coverage included in aggregate %.

20555 of 23166 relevant lines covered (88.73%)

4123.74 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

4.35
/libraries/botbuilder/src/sharepoint/sharePointSSOTokenExchangeMiddleware.ts
1
// Copyright (c) Microsoft Corporation.
2
// Licensed under the MIT License.
3

4
import * as z from 'zod';
1✔
5

6
import {
1✔
7
    ActivityTypes,
8
    Channels,
9
    ExtendedUserTokenProvider,
10
    Middleware,
11
    StatusCodes,
12
    Storage,
13
    StoreItem,
14
    AceRequest,
15
    TokenExchangeInvokeResponse,
16
    TokenResponse,
17
    TurnContext,
18
    sharePointTokenExchange,
19
    CloudAdapterBase,
20
} from 'botbuilder-core';
21
import { UserTokenClient } from 'botframework-connector';
22

23
function getStorageKey(context: TurnContext): string {
24
    const activity = context.activity;
×
25

26
    const channelId = activity.channelId;
×
27
    if (!channelId) {
×
28
        throw new Error('invalid activity. Missing channelId');
×
29
    }
30

31
    const conversationId = activity.conversation?.id;
×
32
    if (!conversationId) {
×
33
        throw new Error('invalid activity. Missing conversation.id');
×
34
    }
35

36
    const value = activity.value;
×
37
    if (!value?.id) {
×
38
        throw new Error('Invalid signin/tokenExchange. Missing activity.value.id.');
×
39
    }
40

41
    return `${channelId}/${conversationId}/${value.id}`;
×
42
}
43

44
async function sendInvokeResponse(context: TurnContext, body: unknown = null, status = StatusCodes.OK): Promise<void> {
×
45
    await context.sendActivity({
×
46
        type: ActivityTypes.InvokeResponse,
47
        value: { body, status },
48
    });
49
}
50

51
const ExchangeToken = z.custom<Pick<ExtendedUserTokenProvider, 'exchangeToken'>>(
1✔
52
    (val: any) => typeof val.exchangeToken === 'function',
×
53
    { message: 'ExtendedUserTokenProvider' },
54
);
55

56
/**
57
 * If the activity name is cardExtension/token, this middleware will attempt to
58
 * exchange the token, and deduplicate the incoming call, ensuring only one
59
 * exchange request is processed.
60
 *
61
 * If a user is signed into multiple devices, the Bot could receive a
62
 * "cardExtension/token" from each device. Each token exchange request for a
63
 * specific user login will have an identical activity.value.id.
64
 *
65
 * Only one of these token exchange requests should be processed by the bot.
66
 * The others return [StatusCodes.PRECONDITION_FAILED](xref:botframework-schema:StatusCodes.PRECONDITION_FAILED).
67
 * For a distributed bot in production, this requires distributed storage
68
 * ensuring only one token exchange is processed. This middleware supports
69
 * CosmosDb storage found in botbuilder-azure, or MemoryStorage for local development.
70
 */
71
export class SharePointSSOTokenExchangeMiddleware implements Middleware {
1✔
72
    /**
73
     * Initializes a new instance of the SharePointSSOTokenExchangeMiddleware class.
74
     *
75
     * @param storage The [Storage](xref:botbuilder-core.Storage) to use for deduplication
76
     * @param oAuthConnectionName The connection name to use for the single sign on token exchange
77
     */
78
    constructor(
79
        private readonly storage: Storage,
×
80
        private readonly oAuthConnectionName: string,
×
81
    ) {
82
        if (!storage) {
×
83
            throw new TypeError('`storage` parameter is required');
×
84
        }
85

86
        if (!oAuthConnectionName) {
×
87
            throw new TypeError('`oAuthConnectionName` parameter is required');
×
88
        }
89
    }
90

91
    /**
92
     * Called each time the bot receives a new request.
93
     *
94
     * @param context Context for current turn of conversation with the user.
95
     * @param _next Function to call to continue execution to the next step in the middleware chain.
96
     */
97
    async onTurn(context: TurnContext, _next: () => Promise<void>): Promise<void> {
98
        if (context.activity.channelId === Channels.M365 && context.activity.name === sharePointTokenExchange) {
×
99
            // If the TokenExchange is NOT successful, the response will have already been sent by exchangedToken
100
            if (!(await this.exchangedToken(context))) {
×
101
                return;
×
102
            }
103

104
            // Only one token exchange should proceed from here. Deduplication is performed second because in the case
105
            // of failure due to consent required, every caller needs to receive a response
106
            if (!(await this.deduplicatedTokenExchangeId(context))) {
×
107
                // If the token is not exchangeable, do not process this activity further.
108
                return;
×
109
            }
110
        }
111

112
        return;
×
113
    }
114

115
    private async deduplicatedTokenExchangeId(context: TurnContext): Promise<boolean> {
116
        // Create a StoreItem with Etag of the unique 'signin/tokenExchange' request
117
        const storeItem: StoreItem = {
×
118
            eTag: context.activity.value?.id,
×
119
        };
120

121
        try {
×
122
            // Writing the IStoreItem with ETag of unique id will succeed only once
123
            await this.storage.write({
×
124
                [getStorageKey(context)]: storeItem,
125
            });
126
        } catch (err) {
127
            const message = err.message?.toLowerCase();
×
128

129
            // Do NOT proceed processing this message, some other thread or machine already has processed it.
130
            // Send 200 invoke response.
131
            if (message.includes('etag conflict') || message.includes('precondition is not met')) {
×
132
                await sendInvokeResponse(context);
×
133
                return false;
×
134
            }
135

136
            throw err;
×
137
        }
138

139
        return true;
×
140
    }
141

142
    private async exchangedToken(context: TurnContext): Promise<boolean> {
143
        let tokenExchangeResponse: TokenResponse;
144
        const aceRequest: AceRequest = context.activity.value;
×
145

146
        try {
×
147
            const userTokenClient = context.turnState.get<UserTokenClient>(
×
148
                (context.adapter as CloudAdapterBase).UserTokenClientKey,
149
            );
150
            const exchangeToken = ExchangeToken.safeParse(context.adapter);
×
151

152
            if (userTokenClient) {
×
153
                tokenExchangeResponse = await userTokenClient.exchangeToken(
×
154
                    context.activity.from.id,
155
                    this.oAuthConnectionName,
156
                    context.activity.channelId,
157
                    { token: aceRequest.data as string },
158
                );
159
            } else if (exchangeToken.success) {
×
160
                tokenExchangeResponse = await exchangeToken.data.exchangeToken(
×
161
                    context,
162
                    this.oAuthConnectionName,
163
                    context.activity.from.id,
164
                    { token: aceRequest.data as string },
165
                );
166
            } else {
167
                new Error('Token Exchange is not supported by the current adapter.');
×
168
            }
169
        } catch (_err) {
170
            // Ignore Exceptions
171
            // If token exchange failed for any reason, tokenExchangeResponse above stays null,
172
            // and hence we send back a failure invoke response to the caller.
173
        }
174

175
        if (!tokenExchangeResponse?.token) {
×
176
            // The token could not be exchanged (which could be due to a consent requirement)
177
            // Notify the sender that PreconditionFailed so they can respond accordingly.
178

179
            const invokeResponse: TokenExchangeInvokeResponse = {
×
180
                id: 'FAKE ID',
181
                connectionName: this.oAuthConnectionName,
182
                failureDetail: 'The bot is unable to exchange token. Proceed with regular login.',
183
            };
184

185
            await sendInvokeResponse(context, invokeResponse, StatusCodes.PRECONDITION_FAILED);
×
186

187
            return false;
×
188
        }
189

190
        return true;
×
191
    }
192
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc