/
oauthPrompt.ts
642 lines (579 loc) · 24.3 KB
/
oauthPrompt.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
/**
* @module botbuilder-dialogs
*/
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import {
ActionTypes,
Activity,
ActivityTypes,
CardFactory,
Channels,
CoreAppCredentials,
InputHints,
MessageFactory,
OAuthCard,
OAuthLoginTimeoutKey,
StatusCodes,
TokenExchangeInvokeRequest,
TokenResponse,
TurnContext,
tokenExchangeOperationName,
tokenResponseEventName,
verifyStateOperationName,
} from 'botbuilder-core';
import * as UserTokenAccess from './userTokenAccess';
import { ClaimsIdentity, JwtTokenValidation, SkillValidation } from 'botframework-connector';
import { Dialog, DialogTurnResult } from '../dialog';
import { DialogContext } from '../dialogContext';
import { PromptOptions, PromptRecognizerResult, PromptValidator } from './prompt';
/**
* Response body returned for a token exchange invoke activity.
*/
class TokenExchangeInvokeResponse {
id: string;
connectionName: string;
failureDetail: string;
constructor(id: string, connectionName: string, failureDetail: string) {
this.id = id;
this.connectionName = connectionName;
this.failureDetail = failureDetail;
}
}
/**
* Settings used to configure an `OAuthPrompt` instance.
*/
export interface OAuthPromptSettings {
/**
* AppCredentials for OAuth.
*/
oAuthAppCredentials?: CoreAppCredentials;
/**
* Name of the OAuth connection being used.
*/
connectionName: string;
/**
* Title of the cards signin button.
*/
title: string;
/**
* (Optional) additional text to include on the signin card.
*/
text?: string;
/**
* (Optional) number of milliseconds the prompt will wait for the user to authenticate.
* Defaults to a value `900,000` (15 minutes.)
*/
timeout?: number;
/**
* (Optional) value indicating whether the OAuthPrompt should end upon
* receiving an invalid message. Generally the OAuthPrompt will ignore
* incoming messages from the user during the auth flow, if they are not related to the
* auth flow. This flag enables ending the OAuthPrompt rather than
* ignoring the user's message. Typically, this flag will be set to 'true', but is 'false'
* by default for backwards compatibility.
*/
endOnInvalidMessage?: boolean;
/**
* (Optional) value to force the display of a Sign In link overriding the default behavior.
* True to display the SignInLink.
*/
showSignInLink?: boolean;
}
/**
* Creates a new prompt that asks the user to sign in using the Bot Frameworks Single Sign On (SSO)
* service.
*
* @remarks
* The prompt will attempt to retrieve the users current token and if the user isn't signed in, it
* will send them an `OAuthCard` containing a button they can press to signin. Depending on the
* channel, the user will be sent through one of two possible signin flows:
*
* - The automatic signin flow where once the user signs in and the SSO service will forward the bot
* the users access token using either an `event` or `invoke` activity.
* - The "magic code" flow where where once the user signs in they will be prompted by the SSO
* service to send the bot a six digit code confirming their identity. This code will be sent as a
* standard `message` activity.
*
* Both flows are automatically supported by the `OAuthPrompt` and the only thing you need to be
* careful of is that you don't block the `event` and `invoke` activities that the prompt might
* be waiting on.
*
* > [!NOTE]
* > You should avoid persisting the access token with your bots other state. The Bot Frameworks
* > SSO service will securely store the token on your behalf. If you store it in your bots state
* > it could expire or be revoked in between turns.
* >
* > When calling the prompt from within a waterfall step you should use the token within the step
* > following the prompt and then let the token go out of scope at the end of your function.
*
* #### Prompt Usage
*
* When used with your bots `DialogSet` you can simply add a new instance of the prompt as a named
* dialog using `DialogSet.add()`. You can then start the prompt from a waterfall step using either
* `DialogContext.beginDialog()` or `DialogContext.prompt()`. The user will be prompted to signin as
* needed and their access token will be passed as an argument to the callers next waterfall step:
*
* ```JavaScript
* const { ConversationState, MemoryStorage, OAuthLoginTimeoutMsValue } = require('botbuilder');
* const { DialogSet, OAuthPrompt, WaterfallDialog } = require('botbuilder-dialogs');
*
* const convoState = new ConversationState(new MemoryStorage());
* const dialogState = convoState.createProperty('dialogState');
* const dialogs = new DialogSet(dialogState);
*
* dialogs.add(new OAuthPrompt('loginPrompt', {
* connectionName: 'GitConnection',
* title: 'Login To GitHub',
* timeout: OAuthLoginTimeoutMsValue // User has 15 minutes to login
* }));
*
* dialogs.add(new WaterfallDialog('taskNeedingLogin', [
* async (step) => {
* return await step.beginDialog('loginPrompt');
* },
* async (step) => {
* const token = step.result;
* if (token) {
*
* // ... continue with task needing access token ...
*
* } else {
* await step.context.sendActivity(`Sorry... We couldn't log you in. Try again later.`);
* return await step.endDialog();
* }
* }
* ]));
* ```
*/
export class OAuthPrompt extends Dialog {
private readonly PersistedCaller: string = 'botbuilder-dialogs.caller';
/**
* Creates a new OAuthPrompt instance.
*
* @param dialogId Unique ID of the dialog within its parent `DialogSet` or `ComponentDialog`.
* @param settings Settings used to configure the prompt.
* @param validator (Optional) validator that will be called each time the user responds to the prompt.
*/
constructor(
dialogId: string,
private settings: OAuthPromptSettings,
private validator?: PromptValidator<TokenResponse>
) {
super(dialogId);
}
/**
* Called when a prompt dialog is pushed onto the dialog stack and is being activated.
*
* @param dc The [DialogContext](xref:botbuilder-dialogs.DialogContext) for the current
* turn of the conversation.
* @param options Optional. [PromptOptions](xref:botbuilder-dialogs.PromptOptions),
* additional information to pass to the prompt being started.
* @returns A `Promise` representing the asynchronous operation.
* @remarks
* If the task is successful, the result indicates whether the prompt is still
* active after the turn has been processed by the prompt.
*/
async beginDialog(dc: DialogContext, options?: PromptOptions): Promise<DialogTurnResult> {
// Ensure prompts have input hint set
const o: Partial<PromptOptions> = { ...options };
if (o.prompt && typeof o.prompt === 'object' && typeof o.prompt.inputHint !== 'string') {
o.prompt.inputHint = InputHints.AcceptingInput;
}
if (o.retryPrompt && typeof o.retryPrompt === 'object' && typeof o.retryPrompt.inputHint !== 'string') {
o.retryPrompt.inputHint = InputHints.AcceptingInput;
}
// Initialize prompt state
const timeout = typeof this.settings.timeout === 'number' ? this.settings.timeout : 900000;
const state = dc.activeDialog.state as OAuthPromptState;
state.state = {};
state.options = o;
state.expires = new Date().getTime() + timeout;
state[this.PersistedCaller] = OAuthPrompt.createCallerInfo(dc.context);
// Attempt to get the users token
const output = await UserTokenAccess.getUserToken(dc.context, this.settings, undefined);
if (output) {
// Return token
return await dc.endDialog(output);
}
// Prompt user to login
await OAuthPrompt.sendOAuthCard(this.settings, dc.context, state.options.prompt);
return Dialog.EndOfTurn;
}
/**
* Called when a prompt dialog is the active dialog and the user replied with a new activity.
*
* @param dc The [DialogContext](xref:botbuilder-dialogs.DialogContext) for the current turn
* of the conversation.
* @returns A `Promise` representing the asynchronous operation.
* @remarks
* If the task is successful, the result indicates whether the dialog is still
* active after the turn has been processed by the dialog.
* The prompt generally continues to receive the user's replies until it accepts the
* user's reply as valid input for the prompt.
*/
async continueDialog(dc: DialogContext): Promise<DialogTurnResult> {
// Check for timeout
const state: OAuthPromptState = dc.activeDialog.state as OAuthPromptState;
const isMessage: boolean = dc.context.activity.type === ActivityTypes.Message;
const isTimeoutActivityType: boolean =
isMessage ||
OAuthPrompt.isTokenResponseEvent(dc.context) ||
OAuthPrompt.isTeamsVerificationInvoke(dc.context) ||
OAuthPrompt.isTokenExchangeRequestInvoke(dc.context);
// If the incoming Activity is a message, or an Activity Type normally handled by OAuthPrompt,
// check to see if this OAuthPrompt Expiration has elapsed, and end the dialog if so.
const hasTimedOut: boolean = isTimeoutActivityType && new Date().getTime() > state.expires;
if (hasTimedOut) {
return await dc.endDialog(undefined);
} else {
// Recognize token
const recognized: PromptRecognizerResult<TokenResponse> = await this.recognizeToken(dc);
if (state.state['attemptCount'] === undefined) {
state.state['attemptCount'] = 0;
}
// Validate the return value
let isValid = false;
if (this.validator) {
isValid = await this.validator({
context: dc.context,
recognized: recognized,
state: state.state,
options: state.options,
attemptCount: ++state.state['attemptCount'],
});
} else if (recognized.succeeded) {
isValid = true;
}
// Return recognized value or re-prompt
if (isValid) {
return await dc.endDialog(recognized.value);
}
if (isMessage && this.settings.endOnInvalidMessage) {
return await dc.endDialog(undefined);
}
// Send retry prompt
if (!dc.context.responded && isMessage && state.options.retryPrompt) {
await dc.context.sendActivity(state.options.retryPrompt);
}
return Dialog.EndOfTurn;
}
}
/**
* Attempts to retrieve the stored token for the current user.
*
* @param context Context reference the user that's being looked up.
* @param code (Optional) login code received from the user.
* @returns The token response.
*/
async getUserToken(context: TurnContext, code?: string): Promise<TokenResponse | undefined> {
return UserTokenAccess.getUserToken(context, this.settings, code);
}
/**
* Signs the user out of the service.
*
* @remarks
* This example shows creating an instance of the prompt and then signing out the user.
*
* ```JavaScript
* const prompt = new OAuthPrompt({
* connectionName: 'GitConnection',
* title: 'Login To GitHub'
* });
* await prompt.signOutUser(context);
* ```
* @param context Context referencing the user that's being signed out.
* @returns A promise representing the asynchronous operation.
*/
async signOutUser(context: TurnContext): Promise<void> {
return UserTokenAccess.signOutUser(context, this.settings);
}
/**
* Sends an OAuth card.
*
* @param {OAuthPromptSettings} settings OAuth settings.
* @param {TurnContext} turnContext Turn context.
* @param {string | Partial<Activity>} prompt Message activity.
*/
static async sendOAuthCard(
settings: OAuthPromptSettings,
turnContext: TurnContext,
prompt?: string | Partial<Activity>
): Promise<void> {
// Initialize outgoing message
const msg: Partial<Activity> =
typeof prompt === 'object'
? { ...prompt }
: MessageFactory.text(prompt, undefined, InputHints.AcceptingInput);
if (!Array.isArray(msg.attachments)) {
msg.attachments = [];
}
// Append appropriate card if missing
if (!this.isOAuthCardSupported(turnContext)) {
if (!msg.attachments.some((a) => a.contentType === CardFactory.contentTypes.signinCard)) {
const signInResource = await UserTokenAccess.getSignInResource(turnContext, settings);
msg.attachments.push(CardFactory.signinCard(settings.title, signInResource.signInLink, settings.text));
}
} else if (!msg.attachments.some((a) => a.contentType === CardFactory.contentTypes.oauthCard)) {
let cardActionType = ActionTypes.Signin;
const signInResource = await UserTokenAccess.getSignInResource(turnContext, settings);
let link = signInResource.signInLink;
const identity = turnContext.turnState.get<ClaimsIdentity>(turnContext.adapter.BotIdentityKey);
// use the SignInLink when
// in speech channel or
// bot is a skill or
// an extra OAuthAppCredentials is being passed in
if (
OAuthPrompt.isFromStreamingConnection(turnContext.activity) ||
(identity && SkillValidation.isSkillClaim(identity.claims)) ||
settings.oAuthAppCredentials
) {
if (turnContext.activity.channelId === Channels.Emulator) {
cardActionType = ActionTypes.OpenUrl;
}
} else if (
settings.showSignInLink === false ||
(!settings.showSignInLink && !this.channelRequiresSignInLink(turnContext.activity.channelId))
) {
link = undefined;
}
// Append oauth card
const card = CardFactory.oauthCard(
settings.connectionName,
settings.title,
settings.text,
link,
signInResource.tokenExchangeResource,
signInResource.tokenPostResource
);
// Set the appropriate ActionType for the button.
(card.content as OAuthCard).buttons[0].type = cardActionType;
msg.attachments.push(card);
}
// Add the login timeout specified in OAuthPromptSettings to TurnState so it can be referenced if polling is needed
if (!turnContext.turnState.get(OAuthLoginTimeoutKey) && settings.timeout) {
turnContext.turnState.set(OAuthLoginTimeoutKey, settings.timeout);
}
// Set input hint
if (!msg.inputHint) {
msg.inputHint = InputHints.AcceptingInput;
}
// Send prompt
await turnContext.sendActivity(msg);
}
/**
* Shared implementation of the RecognizeTokenAsync function. This is intended for internal use, to consolidate
* the implementation of the OAuthPrompt and OAuthInput. Application logic should use those dialog classes.
*
* @param dc The [DialogContext](xref:botbuilder-dialogs.DialogContext) for the current turn of the conversation.
* @returns A Promise that resolves to the result
*/
async recognizeToken(dc: DialogContext): Promise<PromptRecognizerResult<TokenResponse>> {
const context = dc.context;
let token: TokenResponse | undefined;
if (OAuthPrompt.isTokenResponseEvent(context)) {
token = context.activity.value as TokenResponse;
// Fix-up the DialogContext's state context if this was received from a skill host caller.
const state: CallerInfo = dc.activeDialog.state[this.PersistedCaller];
if (state) {
// Set the ServiceUrl to the skill host's Url
context.activity.serviceUrl = state.callerServiceUrl;
const claimsIdentity = context.turnState.get<ClaimsIdentity>(context.adapter.BotIdentityKey);
const connectorClient = await UserTokenAccess.createConnectorClient(
context,
context.activity.serviceUrl,
claimsIdentity,
state.scope
);
context.turnState.set(context.adapter.ConnectorClientKey, connectorClient);
}
} else if (OAuthPrompt.isTeamsVerificationInvoke(context)) {
const magicCode = context.activity.value.state;
try {
token = await UserTokenAccess.getUserToken(context, this.settings, magicCode);
if (token) {
await context.sendActivity({ type: 'invokeResponse', value: { status: StatusCodes.OK } });
} else {
await context.sendActivity({ type: 'invokeResponse', value: { status: 404 } });
}
} catch (_err) {
await context.sendActivity({ type: 'invokeResponse', value: { status: 500 } });
}
} else if (OAuthPrompt.isTokenExchangeRequestInvoke(context)) {
// Received activity is not a token exchange request
if (!(context.activity.value && OAuthPrompt.isTokenExchangeRequest(context.activity.value))) {
await context.sendActivity(
this.getTokenExchangeInvokeResponse(
StatusCodes.BAD_REQUEST,
'The bot received an InvokeActivity that is missing a TokenExchangeInvokeRequest value. This is required to be sent with the InvokeActivity.'
)
);
} else if (context.activity.value.connectionName != this.settings.connectionName) {
// Connection name on activity does not match that of setting
await context.sendActivity(
this.getTokenExchangeInvokeResponse(
StatusCodes.BAD_REQUEST,
'The bot received an InvokeActivity with a TokenExchangeInvokeRequest containing a ConnectionName that does not match the ConnectionName' +
'expected by the bots active OAuthPrompt. Ensure these names match when sending the InvokeActivityInvalid ConnectionName in the TokenExchangeInvokeRequest'
)
);
} else {
let tokenExchangeResponse: TokenResponse;
try {
tokenExchangeResponse = await UserTokenAccess.exchangeToken(context, this.settings, {
token: context.activity.value.token,
});
} catch (_err) {
// Ignore errors.
// If the token exchange failed for any reason, the tokenExchangeResponse stays undefined
// and we send back a failure invoke response to the caller.
}
if (!tokenExchangeResponse || !tokenExchangeResponse.token) {
await context.sendActivity(
this.getTokenExchangeInvokeResponse(
StatusCodes.PRECONDITION_FAILED,
'The bot is unable to exchange token. Proceed with regular login.'
)
);
} else {
await context.sendActivity(
this.getTokenExchangeInvokeResponse(StatusCodes.OK, null, context.activity.value.id)
);
token = {
channelId: tokenExchangeResponse.channelId,
connectionName: tokenExchangeResponse.connectionName,
token: tokenExchangeResponse.token,
expiration: null,
};
}
}
} else if (context.activity.type === ActivityTypes.Message) {
const [, magicCode] = /(\d{6})/.exec(context.activity.text) ?? [];
if (magicCode) {
token = await UserTokenAccess.getUserToken(context, this.settings, magicCode);
}
}
return token !== undefined ? { succeeded: true, value: token } : { succeeded: false };
}
/**
* @private
*/
private static createCallerInfo(context: TurnContext) {
const botIdentity = context.turnState.get<ClaimsIdentity>(context.adapter.BotIdentityKey);
if (botIdentity && SkillValidation.isSkillClaim(botIdentity.claims)) {
return {
callerServiceUrl: context.activity.serviceUrl,
scope: JwtTokenValidation.getAppIdFromClaims(botIdentity.claims),
};
}
return null;
}
/**
* @private
*/
private getTokenExchangeInvokeResponse(status: number, failureDetail: string, id?: string): Activity {
const invokeResponse: Partial<Activity> = {
type: 'invokeResponse',
value: { status, body: new TokenExchangeInvokeResponse(id, this.settings.connectionName, failureDetail) },
};
return invokeResponse as Activity;
}
/**
* @private
*/
private static isFromStreamingConnection(activity: Activity): boolean {
return activity && activity.serviceUrl && !activity.serviceUrl.toLowerCase().startsWith('http');
}
/**
* @private
*/
private static isTokenResponseEvent(context: TurnContext): boolean {
const activity: Activity = context.activity;
return activity.type === ActivityTypes.Event && activity.name === tokenResponseEventName;
}
/**
* @private
*/
private static isTeamsVerificationInvoke(context: TurnContext): boolean {
const activity: Activity = context.activity;
return activity.type === ActivityTypes.Invoke && activity.name === verifyStateOperationName;
}
/**
* @private
*/
private static isOAuthCardSupported(context: TurnContext): boolean {
// Azure Bot Service OAuth cards are not supported in the community adapters. Since community adapters
// have a 'name' in them, we cast the adapter to 'any' to check for the name.
const adapter: any = context.adapter;
if (adapter.name) {
switch (adapter.name) {
case 'Facebook Adapter':
case 'Google Hangouts Adapter':
case 'Slack Adapter':
case 'Twilio SMS Adapter':
case 'Web Adapter':
case 'Webex Adapter':
case 'Botkit CMS':
return false;
default:
}
}
return this.channelSupportsOAuthCard(context.activity.channelId);
}
/**
* @private
*/
private static isTokenExchangeRequestInvoke(context: TurnContext): boolean {
const activity: Activity = context.activity;
return activity.type === ActivityTypes.Invoke && activity.name === tokenExchangeOperationName;
}
/**
* @private
*/
private static isTokenExchangeRequest(obj: unknown): obj is TokenExchangeInvokeRequest {
if (Object.prototype.hasOwnProperty.call(obj, 'token')) {
return true;
}
return false;
}
/**
* @private
*/
private static channelSupportsOAuthCard(channelId: string): boolean {
switch (channelId) {
case Channels.Skype:
case Channels.Skypeforbusiness:
return false;
default:
}
return true;
}
/**
* @private
*/
private static channelRequiresSignInLink(channelId: string): boolean {
switch (channelId) {
case Channels.Msteams:
return true;
default:
}
return false;
}
}
/**
* @private
*/
interface OAuthPromptState {
state: any;
options: PromptOptions;
expires: number; // Timestamp of when the prompt will timeout.
}
/**
* @private
*/
interface CallerInfo {
callerServiceUrl: string;
scope: string;
}