Skip to content

Commit

Permalink
Merge pull request #1284 from microsoft/se/oauth
Browse files Browse the repository at this point in the history
Adding OAuthCard support for Streaming Extensions
  • Loading branch information
Jeffders committed Oct 10, 2019
2 parents 7a75aec + a29c60a commit 7d86155
Show file tree
Hide file tree
Showing 11 changed files with 627 additions and 7 deletions.
5 changes: 3 additions & 2 deletions libraries/botbuilder-core/src/cardFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,11 +168,12 @@ export class CardFactory {
* @param connectionName The name of the OAuth connection to use.
* @param title Title of the cards signin button.
* @param text (Optional) additional text to include on the card.
* @param link (Optional) the sign in link to follow
*/
public static oauthCard(connectionName: string, title: string, text?: string): Attachment {
public static oauthCard(connectionName: string, title: string, text?: string, link?: string): Attachment {
const card: Partial<OAuthCard> = {
buttons: [
{ type: ActionTypes.Signin, title: title, value: undefined, channelData: undefined }
{ type: ActionTypes.Signin, title: title, value: link, channelData: undefined }
],
connectionName: connectionName
};
Expand Down
1 change: 1 addition & 0 deletions libraries/botbuilder-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,4 @@ export * from './transcriptLogger';
export * from './turnContext';
export * from './userState';
export * from './userTokenProvider';
export * from './userTokenSettings';
12 changes: 12 additions & 0 deletions libraries/botbuilder-core/src/turnContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export type DeleteActivityHandler = (
next: () => Promise<void>
) => Promise<void>;

export const BotCallbackHandlerKey = 'botCallbackHandler';

// tslint:disable-next-line:no-empty-interface
export interface TurnContext {}

Expand Down Expand Up @@ -537,4 +539,14 @@ export class TurnContext {
return emitNext(0);
}

/**
* Determine if the Activity was sent via an Http/Https connection or Streaming
* This can be determined by looking at the ServiceUrl property:
* (1) All channels that send messages via http/https are not streaming
* (2) Channels that send messages via streaming have a ServiceUrl that does not begin with http/https.
* @param activity
*/
public static isFromStreamingConnection(activity: Activity): boolean {
return activity && activity.serviceUrl && !activity.serviceUrl.toLowerCase().startsWith('http');
}
}
42 changes: 42 additions & 0 deletions libraries/botbuilder-core/src/userTokenSettings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* @module botbuilder
*/
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/

/**
* Provides details for token polling.
*/
export interface TokenPollingSettings
{
/**
* Polling timeout time in milliseconds. This is equivalent to login flow timeout.
*/
timeout?: number;

/**
* Time Interval in milliseconds between token polling requests.
*/
interval?: number;
}

/**
* TurnState key for the OAuth login timeout
*/
export const OAuthLoginTimeoutKey: string = 'loginTimeout';

/**
* Name of the token polling settings key.
*/
export const TokenPollingSettingsKey: string = "tokenPollingSettings";


/**
* Default amount of time an OAuthCard will remain active (clickable and actively waiting for a token).
* After this time:
* (1) the OAuthCard will not allow the user to click on it.
* (2) any polling triggered by the OAuthCard will stop.
*/
export const OAuthLoginTimeoutMsValue: number = 900000;
42 changes: 42 additions & 0 deletions libraries/botbuilder-core/tests/turnContext.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -394,4 +394,46 @@ describe(`TurnContext`, function () {
assert(text,' test activity');
assert(activity.text,' test activity');
});

it ('should identify streaming connections', function() {
let activity = {
type: 'message',
text: '<at>TestOAuth619</at> test activity',
recipient: { id: 'TestOAuth619' },
};

const streaming = [
'urn:botframework:WebSocket:wss://beep.com',
'urn:botframework:WebSocket:http://beep.com',
'URN:botframework:WebSocket:wss://beep.com',
'URN:botframework:WebSocket:http://beep.com',
];

streaming.forEach(s =>
{
activity.serviceUrl = s;
assert(TurnContext.isFromStreamingConnection(activity), 'did not detect streaming');
});
});

it ('should identify http connections', function() {
let activity = {
type: 'message',
text: '<at>TestOAuth619</at> test activity',
recipient: { id: 'TestOAuth619' },
};

const streaming = [
'http://yayay.com',
'https://yayay.com',
'HTTP://yayay.com',
'HTTPS://yayay.com',
];

streaming.forEach(s =>
{
activity.serviceUrl = s;
assert(!TurnContext.isFromStreamingConnection(activity), 'incorrectly detected streaming');
});
});
});
19 changes: 15 additions & 4 deletions libraries/botbuilder-dialogs/src/prompts/oauthPrompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* Licensed under the MIT License.
*/
import { Token } from '@microsoft/recognizers-text-date-time';
import { Activity, ActivityTypes, Attachment, CardFactory, InputHints, MessageFactory, TokenResponse, TurnContext, IUserTokenProvider } from 'botbuilder-core';
import { Activity, ActivityTypes, Attachment, CardFactory, InputHints, MessageFactory, OAuthLoginTimeoutKey, TokenResponse, TurnContext, IUserTokenProvider, } from 'botbuilder-core';
import { Dialog, DialogTurnResult } from '../dialog';
import { DialogContext } from '../dialogContext';
import { PromptOptions, PromptRecognizerResult, PromptValidator } from './prompt';
Expand Down Expand Up @@ -73,7 +73,7 @@ export interface OAuthPromptSettings {
* needed and their access token will be passed as an argument to the callers next waterfall step:
*
* ```JavaScript
* const { ConversationState, MemoryStorage } = require('botbuilder');
* const { ConversationState, MemoryStorage, OAuthLoginTimeoutMsValue } = require('botbuilder');
* const { DialogSet, OAuthPrompt, WaterfallDialog } = require('botbuilder-dialogs');
*
* const convoState = new ConversationState(new MemoryStorage());
Expand All @@ -83,7 +83,7 @@ export interface OAuthPromptSettings {
* dialogs.add(new OAuthPrompt('loginPrompt', {
* connectionName: 'GitConnection',
* title: 'Login To GitHub',
* timeout: 300000 // User has 5 minutes to login
* timeout: OAuthLoginTimeoutMsValue // User has 15 minutes to login
* }));
*
* dialogs.add(new WaterfallDialog('taskNeedingLogin', [
Expand Down Expand Up @@ -249,11 +249,16 @@ export class OAuthPrompt extends Dialog {
if (this.channelSupportsOAuthCard(context.activity.channelId)) {
const cards: Attachment[] = msg.attachments.filter((a: Attachment) => a.contentType === CardFactory.contentTypes.oauthCard);
if (cards.length === 0) {
let link: string = undefined;
if (TurnContext.isFromStreamingConnection(context.activity)) {
link = await (context.adapter as any).getSignInLink(context, this.settings.connectionName);
}
// Append oauth card
msg.attachments.push(CardFactory.oauthCard(
this.settings.connectionName,
this.settings.title,
this.settings.text
this.settings.text,
link
));
}
} else {
Expand All @@ -269,6 +274,12 @@ export class OAuthPrompt extends Dialog {
}
}

// Add the login timeout specified in OAuthPromptSettings to TurnState so it can be referenced if polling is needed
if (!context.turnState.get(OAuthLoginTimeoutKey) && this.settings.timeout)
{
context.turnState.set(OAuthLoginTimeoutKey, this.settings.timeout);
}

// Send prompt
await context.sendActivity(msg);
}
Expand Down
78 changes: 78 additions & 0 deletions libraries/botbuilder-dialogs/tests/oauthPrompt.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ describe('OAuthPrompt', function () {
assert(activity.attachments.length === 1);
assert(activity.attachments[0].contentType === CardFactory.contentTypes.oauthCard);
assert(activity.inputHint === InputHints.AcceptingInput);
assert(!activity.attachments[0].content.buttons[0].value);

// send a mock EventActivity back to the bot with the token
adapter.addUserToken(connectionName, activity.channelId, activity.recipient.id, token);
Expand Down Expand Up @@ -114,6 +115,83 @@ describe('OAuthPrompt', function () {
.send(magicCode)
.assertReply('Logged in.');
});

it('should call OAuthPrompt for streaming connection', async function () {
var connectionName = "myConnection";
var token = "abc123";

// Initialize TestAdapter.
const adapter = new TestAdapter(async (turnContext) => {
const dc = await dialogs.createContext(turnContext);

const results = await dc.continueDialog();
if (results.status === DialogTurnStatus.empty) {
await dc.prompt('prompt', { });
} else if (results.status === DialogTurnStatus.complete) {
if (results.result.token) {
await turnContext.sendActivity(`Logged in.`);
}
else {
await turnContext.sendActivity(`Failed`);
}
}
await convoState.saveChanges(turnContext);
});

// Create new ConversationState with MemoryStorage and register the state as middleware.
const convoState = new ConversationState(new MemoryStorage());

// Create a DialogState property, DialogSet and AttachmentPrompt.
const dialogState = convoState.createProperty('dialogState');
const dialogs = new DialogSet(dialogState);
dialogs.add(new OAuthPrompt('prompt', {
connectionName,
title: 'Login',
timeout: 300000
}));

const streamingActivity = {
activityId: '1234',
channelId: 'directlinespeech',
serviceUrl: 'urn:botframework.com:websocket:wss://channel.com/blah',
user: { id: 'user', name: 'User Name' },
bot: { id: 'bot', name: 'Bot Name' },
conversation: {
id: 'convo1',
properties: {
'foo': 'bar'
}
},
attachments: [],
type: 'message',
text: 'Hello'
};

await adapter.send(streamingActivity)
.assertReply(activity => {
assert(activity.attachments.length === 1);
assert(activity.attachments[0].contentType === CardFactory.contentTypes.oauthCard);
assert(activity.inputHint === InputHints.AcceptingInput);
assert(activity.attachments[0].content.buttons[0].value);

// send a mock EventActivity back to the bot with the token
adapter.addUserToken(connectionName, activity.channelId, activity.recipient.id, token);

var eventActivity = createReply(activity);
eventActivity.type = ActivityTypes.Event;
var from = eventActivity.from;
eventActivity.from = eventActivity.recipient;
eventActivity.recipient = from;
eventActivity.name = "tokens/response";
eventActivity.value = {
connectionName,
token
};

adapter.send(eventActivity);
})
.assertReply('Logged in.');
});
});

function createReply(activity) {
Expand Down
53 changes: 52 additions & 1 deletion libraries/botbuilder/src/botFrameworkAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
* Licensed under the MIT License.
*/

import { Activity, ActivityTypes, BotAdapter, ChannelAccount, ConversationAccount, ConversationParameters, ConversationReference, ConversationsResult, IUserTokenProvider, ResourceResponse, TokenResponse, TurnContext } from 'botbuilder-core';
import { Activity, ActivityTypes, BotAdapter, BotCallbackHandlerKey, ChannelAccount, ConversationAccount, ConversationParameters, ConversationReference, ConversationsResult, IUserTokenProvider, ResourceResponse, TokenResponse, TurnContext } from 'botbuilder-core';
import { AuthenticationConstants, ChannelValidation, ConnectorClient, EmulatorApiClient, GovernmentConstants, GovernmentChannelValidation, JwtTokenValidation, MicrosoftAppCredentials, SimpleCredentialProvider, TokenApiClient, TokenStatus, TokenApiModels } from 'botframework-connector';
import * as os from 'os';
import { TokenResolver } from './tokenResolver';

/**
* Represents an Express or Restify request object.
Expand Down Expand Up @@ -706,6 +707,7 @@ export class BotFrameworkAdapter extends BotAdapter implements IUserTokenProvide
// Process received activity
status = 500;
const context: TurnContext = this.createContext(request);
context.turnState.set(BotCallbackHandlerKey, logic);
await this.runMiddleware(context, logic);

// Retrieve cached invoke response.
Expand Down Expand Up @@ -742,6 +744,52 @@ export class BotFrameworkAdapter extends BotAdapter implements IUserTokenProvide
}
}

/**
* An asynchronous method that creates a turn context and runs the middleware pipeline for an incoming activity.
*
* @param activity The activity to process.
* @param logic The function to call at the end of the middleware pipeline.
*
* @remarks
* This is the main way a bot receives incoming messages and defines a turn in the conversation. This method:
*
* 1. Creates a [TurnContext](xref:botbuilder-core.TurnContext) object for the received activity.
* - This object is wrapped with a [revocable proxy](https://www.ecma-international.org/ecma-262/6.0/#sec-proxy.revocable).
* - When this method completes, the proxy is revoked.
* 1. Sends the turn context through the adapter's middleware pipeline.
* 1. Sends the turn context to the `logic` function.
* - The bot may perform additional routing or processing at this time.
* Returning a promise (or providing an `async` handler) will cause the adapter to wait for any asynchronous operations to complete.
* - After the `logic` function completes, the promise chain set up by the middleware is resolved.
*
* Middleware can _short circuit_ a turn. When this happens, subsequent middleware and the
* `logic` function is not called; however, all middleware prior to this point still run to completion.
* For more information about the middleware pipeline, see the
* [how bots work](https://docs.microsoft.com/azure/bot-service/bot-builder-basics) and
* [middleware](https://docs.microsoft.com/azure/bot-service/bot-builder-concept-middleware) articles.
* Use the adapter's [use](xref:botbuilder-core.BotAdapter.use) method to add middleware to the adapter.
*/
public async processActivityDirect(activity: Activity, logic: (context: TurnContext) => Promise<any>): Promise<void> {
let processError: Error;
try {
// Process activity
const context: TurnContext = this.createContext(activity);
context.turnState.set(BotCallbackHandlerKey, logic);
await this.runMiddleware(context, logic);
} catch (err) {
// Catch the error to try and throw the stacktrace out of processActivity()
processError = err;
}

if (processError) {
if (processError && (processError as Error).stack) {
throw new Error(`BotFrameworkAdapter.processActivity(): ${ status } ERROR\n ${ processError.stack }`);
} else {
throw new Error(`BotFrameworkAdapter.processActivity(): ${ status } ERROR`);
}
}
}

/**
* An asynchronous method that sends a set of outgoing activities to a channel server.
* > [!NOTE] This method supports the framework and is not intended to be called directly for your code.
Expand Down Expand Up @@ -780,6 +828,9 @@ export class BotFrameworkAdapter extends BotAdapter implements IUserTokenProvide
if (!activity.conversation || !activity.conversation.id) {
throw new Error(`BotFrameworkAdapter.sendActivity(): missing conversation id.`);
}
if (TurnContext.isFromStreamingConnection(activity as Activity)) {
TokenResolver.checkForOAuthCards(this, context, activity as Activity);
}
const client: ConnectorClient = this.createConnectorClient(activity.serviceUrl);
if (activity.type === 'trace' && activity.channelId !== 'emulator') {
// Just eat activity
Expand Down
Loading

0 comments on commit 7d86155

Please sign in to comment.