diff --git a/getting-started/CONCEPTS/USER-AUTH.md b/getting-started/CONCEPTS/USER-AUTH.md index 95a751940..6e0ab43e3 100644 --- a/getting-started/CONCEPTS/USER-AUTH.md +++ b/getting-started/CONCEPTS/USER-AUTH.md @@ -52,7 +52,7 @@ The `connectionName` property is what you configure in the Azure Bot Resource, s The `text` property is the titie and the `text` property is the body of the sign in card sent to the user. -### Auto sign in +## Auto sign in With this configuration, the bot will attempt to authenticate the user when they try to interact with it. To control when for which incoming activities the bot should authenticate the user, you can specify configure the auto sign in property in the options. @@ -89,7 +89,7 @@ The `autoSignIn` property takes a callback that triggers the sign in flow if it This is useful if the user should be signed in by default before attempting to interacting with the bot in general. -### Manual Sign In +## Manual Sign In If the user should only be authenticated in certain scenarios, you can disable auto sign in by having the callback always return false and trigger authentication manually. @@ -119,7 +119,29 @@ If multiple settings are configured, then the user can be authenticated into mul **Note:** Once the sign in flow completes when triggered from a message activity or an action handler, the application is NOT redirected back to its previous task. This means that if user authentication is triggered through a message extension, then the same activity will be sent again to the bot after sign in completes. But if sign in is triggered when the incoming activity is a message then the same activity will NOT be sent again to the bot after sign in completes. -### Handling sign in success or failure +## Enable Single Sign-On (SSO) +With Single sign-on (SSO) in Teams, users have the advantage of using Teams to access bot or message extension apps. After logging into Teams using Microsoft or Microsoft 365 account, app users can use your app without needing to sign in again. Your app is available to app users on any device with access granted through Microsoft Entra ID. This means that SSO works only if the user is being authenticated with Azure Active Directory (AAD). It will not work with other authentication providers like Facebook, Google, etc. + +Here's an example of enabling SSO in the `OAuthSettings`: + +**Javascript** +```js +const app = new ApplicationBuilder() + .withStorage(storage) + .withAuthentication(adapter, { + settings: { + graph: { + connectionName: process.env.OAUTH_CONNECTION_NAME ?? '', + title: 'Sign in', + text: 'Please sign in to use the bot.', + enableSso: true // set this to true to enable SSO + } + } + }) + .build(); +``` + +## Handling sign in success or failure To handle the event when the user has signed in successfully or failed to sign in, simply register corresponding handler: @@ -157,7 +179,7 @@ app.authentication.get('graph').onUserSignInFailure(async (context: TurnContext, }); ``` -### Sign out a user +## Sign out a user You can also sign a user out of connection: @@ -169,4 +191,4 @@ await app.Authentication.SignOutUserAsync(context, state, "graph", cancellationT **Javascript** ```js await app.authentication.signOutUser(context, state, 'graph'); -``` \ No newline at end of file +``` diff --git a/js/packages/teams-ai/src/authentication/AdaptiveCardAuthenticationBase.ts b/js/packages/teams-ai/src/authentication/AdaptiveCardAuthenticationBase.ts index cb2996425..30778e48b 100644 --- a/js/packages/teams-ai/src/authentication/AdaptiveCardAuthenticationBase.ts +++ b/js/packages/teams-ai/src/authentication/AdaptiveCardAuthenticationBase.ts @@ -98,8 +98,8 @@ export abstract class AdaptiveCardAuthenticationBase { /** * Checks if the activity is a valid Adaptive Card activity that supports authentication. - * @param context - The turn context. - * @returns A boolean indicating if the activity is valid. + * @param {TurnContext} context - The turn context. + * @returns {boolean} A boolean indicating if the activity is valid. */ public isValidActivity(context: TurnContext): boolean { return context.activity.type == ActivityTypes.Invoke && context.activity.name == ACTION_INVOKE_NAME; diff --git a/js/packages/teams-ai/src/authentication/Authentication.ts b/js/packages/teams-ai/src/authentication/Authentication.ts index 9bac997a1..edc20739a 100644 --- a/js/packages/teams-ai/src/authentication/Authentication.ts +++ b/js/packages/teams-ai/src/authentication/Authentication.ts @@ -339,6 +339,11 @@ export type OAuthSettings = OAuthPromptSettings & { * Optional. Set this to enable SSO when authentication user using adaptive cards. */ tokenExchangeUri?: string; + + /** + * Optional. Set to `true` to enable SSO when authenticating using AAD. + */ + enableSso?: boolean; }; /** @@ -397,8 +402,8 @@ export class AuthError extends Error { /** * Creates a new instance of the `AuthError` class. - * @param message The error message. - * @param reason Optional. Cause of the error. Defaults to `other`. + * @param {string} message The error message. + * @param {AuthErrorReason} reason Optional. Cause of the error. Defaults to `other`. */ constructor(message?: string, reason: AuthErrorReason = 'other') { super(message); diff --git a/js/packages/teams-ai/src/authentication/MessageExtensionAuthenticationBase.ts b/js/packages/teams-ai/src/authentication/MessageExtensionAuthenticationBase.ts index 47871a9db..87e564fbc 100644 --- a/js/packages/teams-ai/src/authentication/MessageExtensionAuthenticationBase.ts +++ b/js/packages/teams-ai/src/authentication/MessageExtensionAuthenticationBase.ts @@ -55,7 +55,7 @@ export abstract class MessageExtensionAuthenticationBase { const signInLink = await this.getSignInLink(context); // Do 'silentAuth' if this is a composeExtension/query request otherwise do normal `auth` flow. - const authType = context.activity.name === MessageExtensionsInvokeNames.QUERY_INVOKE ? 'silentAuth' : 'auth'; + const authType = this.isSsoSignIn(context) ? 'silentAuth' : 'auth'; const response = { composeExtension: { @@ -117,4 +117,11 @@ export abstract class MessageExtensionAuthenticationBase { * @returns {Promise} - A promise that resolves to the sign-in link or undefined if no sign-in link available. */ public abstract getSignInLink(context: TurnContext): Promise; + + /** + * Should sign in using SSO flow. + * @param {TurnContext} context - The turn context. + * @returns {boolean} - A boolean indicating if the sign-in should use SSO flow. + */ + public abstract isSsoSignIn(context: TurnContext): boolean; } diff --git a/js/packages/teams-ai/src/authentication/OAuthAdaptiveCardAuthentication.spec.ts b/js/packages/teams-ai/src/authentication/OAuthAdaptiveCardAuthentication.spec.ts index 4ee744854..46689d227 100644 --- a/js/packages/teams-ai/src/authentication/OAuthAdaptiveCardAuthentication.spec.ts +++ b/js/packages/teams-ai/src/authentication/OAuthAdaptiveCardAuthentication.spec.ts @@ -245,11 +245,12 @@ describe('AdaptiveCardAuthenticaion', () => { assert(result == undefined); }); - it(`should to normal auth flow if tokenExchangeUri is not set`, async () => { + it(`should to normal auth flow if tokenExchangeUri is set and enableSso is true`, async () => { const settings = { connectionName: 'connectionName', title: 'title', - tokenExchangeUri: 'tokenExchangeUri' + tokenExchangeUri: 'tokenExchangeUri', + enableSso: true }; acAuth = new OAuthAdaptiveCardAuthentication(settings); diff --git a/js/packages/teams-ai/src/authentication/OAuthAdaptiveCardAuthentication.ts b/js/packages/teams-ai/src/authentication/OAuthAdaptiveCardAuthentication.ts index cc757ac20..b8ee41bcf 100644 --- a/js/packages/teams-ai/src/authentication/OAuthAdaptiveCardAuthentication.ts +++ b/js/packages/teams-ai/src/authentication/OAuthAdaptiveCardAuthentication.ts @@ -14,7 +14,7 @@ import * as UserTokenAccess from './UserTokenAccess'; export class OAuthAdaptiveCardAuthentication extends AdaptiveCardAuthenticationBase { /** * Creates a new instance of OAuthAdaptiveCardAuthentication. - * @param settings The OAuthSettings. + * @param {OAuthSettings} settings The OAuthSettings. */ public constructor(private readonly settings: OAuthSettings) { super(); @@ -22,8 +22,8 @@ export class OAuthAdaptiveCardAuthentication extends AdaptiveCardAuthenticationB /** * Handles the SSO token exchange. - * @param context The turn context. - * @returns A promise that resolves to the token response or undefined if token exchange failed. + * @param {TurnContext} context The turn context. + * @returns {Promise} A promise that resolves to the token response or undefined if token exchange failed. */ public async handleSsoTokenExchange(context: TurnContext): Promise { const tokenExchangeRequest = context.activity.value.authentication; @@ -37,9 +37,9 @@ export class OAuthAdaptiveCardAuthentication extends AdaptiveCardAuthenticationB /** * Handles the signin/verifyState activity. - * @param context The turn context. - * @param magicCode The magic code from sign-in. - * @returns A promise that resolves to undefined. The parent class will trigger silentAuth again. + * @param {TurnContext} context The turn context. + * @param {string} magicCode The magic code from sign-in. + * @returns {Promise} A promise that resolves to undefined. The parent class will trigger silentAuth again. */ public async handleUserSignIn(context: TurnContext, magicCode: string): Promise { return await UserTokenAccess.getUserToken(context, this.settings, magicCode); @@ -47,8 +47,8 @@ export class OAuthAdaptiveCardAuthentication extends AdaptiveCardAuthenticationB /** * Gets the sign-in link for the user. - * @param context The turn context. - * @returns A promise that resolves to the sign-in link or undefined if no sign-in link available. + * @param {TurnContext} context The turn context. + * @returns {Promise} A promise that resolves to the sign-in link or undefined if no sign-in link available. */ public async getLoginRequest(context: TurnContext): Promise { const signInResource = await UserTokenAccess.getSignInResource(context, this.settings); @@ -75,7 +75,7 @@ export class OAuthAdaptiveCardAuthentication extends AdaptiveCardAuthenticationB } }; - if (this.settings.tokenExchangeUri) { + if (this.settings.tokenExchangeUri && this.settings.enableSso == true) { const botId = context.activity.recipient.id; response.value.tokenExchangeResource = { id: botId, diff --git a/js/packages/teams-ai/src/authentication/OAuthBotAuthentication.spec.ts b/js/packages/teams-ai/src/authentication/OAuthBotAuthentication.spec.ts index 963d63fa9..ac544e525 100644 --- a/js/packages/teams-ai/src/authentication/OAuthBotAuthentication.spec.ts +++ b/js/packages/teams-ai/src/authentication/OAuthBotAuthentication.spec.ts @@ -1,27 +1,21 @@ /* eslint-disable security/detect-object-injection */ import { Activity, MemoryStorage, TestAdapter, TurnContext } from 'botbuilder'; import { Application, RouteSelector } from '../Application'; -import { - DialogSet, - DialogState, - DialogTurnResult, - DialogTurnStatus, - OAuthPrompt, - OAuthPromptSettings -} from 'botbuilder-dialogs'; +import { DialogSet, DialogState, DialogTurnResult, DialogTurnStatus } from 'botbuilder-dialogs'; import { BotAuthenticationBase } from './BotAuthenticationBase'; import * as sinon from 'sinon'; import assert from 'assert'; import { TurnState } from '../TurnState'; -import { AuthError } from './Authentication'; +import { AuthError, OAuthSettings } from './Authentication'; import { FilteredTeamsSSOTokenExchangeMiddleware, OAuthBotAuthentication } from './OAuthBotAuthentication'; import { TurnStateProperty } from '../TurnStateProperty'; +import { OAuthBotPrompt } from './OAuthBotPrompt'; -describe('BotAuthentication', () => { +describe('OAuthBotAuthentication', () => { const adapter = new TestAdapter(); let app: Application; - let settings: OAuthPromptSettings; + let settings: OAuthSettings; const settingName = 'settingName'; const createTurnContextAndState = async (activity: Partial): Promise<[TurnContext, TurnState]> => { @@ -336,7 +330,7 @@ describe('BotAuthentication', () => { const dialogStateProperty = 'dialogStateProperty'; const accessor = new TurnStateProperty(state, 'conversation', dialogStateProperty); const dialogSet = new DialogSet(accessor); - dialogSet.add(new OAuthPrompt('OAuthPrompt', settings)); + dialogSet.add(new OAuthBotPrompt('OAuthPrompt', settings)); const dialogContext = await dialogSet.createContext(context); const beginDialogStub = sinon.stub(dialogContext, 'beginDialog'); const continueDialogStub = sinon @@ -361,12 +355,18 @@ describe('BotAuthentication', () => { name: 'test' } }); - const dialogStateProperty = 'dialogStateProperty2'; + + const stub = sinon.stub(OAuthBotPrompt, 'sendOAuthCard'); + + const dialogStateProperty = 'dialogStateProperty'; + const botAuth = new OAuthBotAuthentication(app, settings, settingName); const result = await botAuth.runDialog(context, state, dialogStateProperty); assert(result.status == DialogTurnStatus.waiting); + + stub.restore(); }); }); }); diff --git a/js/packages/teams-ai/src/authentication/OAuthBotAuthentication.ts b/js/packages/teams-ai/src/authentication/OAuthBotAuthentication.ts index 5b4271742..25d1a28fb 100644 --- a/js/packages/teams-ai/src/authentication/OAuthBotAuthentication.ts +++ b/js/packages/teams-ai/src/authentication/OAuthBotAuthentication.ts @@ -7,14 +7,15 @@ import { DialogState, DialogTurnResult, DialogTurnStatus, - OAuthPrompt, - OAuthPromptSettings + OAuthPrompt } from 'botbuilder-dialogs'; import { Storage, TeamsSSOTokenExchangeMiddleware, TurnContext, TokenResponse } from 'botbuilder'; import { BotAuthenticationBase } from './BotAuthenticationBase'; import { Application } from '../Application'; import { TurnState } from '../TurnState'; import { TurnStateProperty } from '../TurnStateProperty'; +import { OAuthSettings } from './Authentication'; +import { OAuthBotPrompt } from './OAuthBotPrompt'; /** * @internal @@ -27,21 +28,25 @@ export class OAuthBotAuthentication extends BotAuthent /** * Initializes a new instance of the OAuthBotAuthentication class. - * @param app - The application object. - * @param oauthPromptSettings - The settings for OAuthPrompt. - * @param settingName - The name of the setting. - * @param storage - The storage object for storing state. + * @param {Application} app - The application object. + * @param {OAuthSettings} oauthPromptSettings - The settings for OAuthPrompt. + * @param {string} settingName - The name of the setting. + * @param {Storage} storage - The storage object for storing state. */ public constructor( app: Application, - oauthPromptSettings: OAuthPromptSettings, // Child classes will have different types for this + oauthPromptSettings: OAuthSettings, // Child classes will have different types for this settingName: string, storage?: Storage ) { super(app, settingName, storage); + if (oauthPromptSettings.enableSso != true) { + oauthPromptSettings.showSignInLink = true; + } + // Create OAuthPrompt - this._oauthPrompt = new OAuthPrompt('OAuthPrompt', oauthPromptSettings); + this._oauthPrompt = new OAuthBotPrompt('OAuthPrompt', oauthPromptSettings); // Handles deduplication of token exchange event when using SSO with Bot Authentication app.adapter.use(new FilteredTeamsSSOTokenExchangeMiddleware(this._storage, oauthPromptSettings.connectionName)); @@ -49,10 +54,10 @@ export class OAuthBotAuthentication extends BotAuthent /** * Run or continue the OAuthPrompt dialog and returns the result. - * @param context - The turn context object. - * @param state - The turn state object. - * @param dialogStateProperty - The name of the dialog state property. - * @returns A promise that resolves to the dialog turn result containing the token response. + * @param {TurnContext} context - The turn context object. + * @param {TState} state - The turn state object. + * @param {string} dialogStateProperty - The name of the dialog state property. + * @returns {Promise>} A promise that resolves to the dialog turn result containing the token response. */ public async runDialog( context: TurnContext, @@ -69,10 +74,10 @@ export class OAuthBotAuthentication extends BotAuthent /** * Continue the OAuthPrompt dialog and returns the result. - * @param context - The turn context object. - * @param state - The turn state object. - * @param dialogStateProperty - The name of the dialog state property. - * @returns A promise that resolves to the dialog turn result containing the token response. + * @param {TurnContext} context - The turn context object. + * @param {TState} state - The turn state object. + * @param {string} dialogStateProperty - The name of the dialog state property. + * @returns {Promise>} A promise that resolves to the dialog turn result containing the token response. */ public async continueDialog( context: TurnContext, @@ -85,10 +90,10 @@ export class OAuthBotAuthentication extends BotAuthent /** * Creates a new DialogContext for OAuthPrompt. - * @param context - The turn context object. - * @param state - The turn state object. - * @param dialogStateProperty - The name of the dialog state property. - * @returns A promise that resolves to the dialog context. + * @param {TurnContext} context - The turn context object. + * @param {TState} state - The turn state object. + * @param {string} dialogStateProperty - The name of the dialog state property. + * @returns {Promise} A promise that resolves to the dialog context. */ private async createDialogContext( context: TurnContext, diff --git a/js/packages/teams-ai/src/authentication/OAuthBotPrompt.spec.ts b/js/packages/teams-ai/src/authentication/OAuthBotPrompt.spec.ts new file mode 100644 index 000000000..77683595f --- /dev/null +++ b/js/packages/teams-ai/src/authentication/OAuthBotPrompt.spec.ts @@ -0,0 +1,152 @@ +import { Dialog, DialogSet, DialogState } from 'botbuilder-dialogs'; +import { OAuthBotPrompt } from './OAuthBotPrompt'; +import * as UserTokenAccess from './UserTokenAccess'; +import * as sinon from 'sinon'; +import assert from 'assert'; +import { TurnStateProperty } from '../TurnStateProperty'; +import { Activity, CardFactory, InputHints, TestAdapter, TurnContext } from 'botbuilder'; +import { TurnState } from '../TurnState'; + +describe('OAuthBotPrompt', function () { + const adapter = new TestAdapter(); + + const createTurnContextAndState = async (activity: Partial): Promise<[TurnContext, TurnState]> => { + const context = new TurnContext(adapter, { + channelId: 'msteams', + recipient: { + id: 'bot', + name: 'bot' + }, + from: { + id: 'user', + name: 'user' + }, + conversation: { + id: 'convo', + isGroup: false, + conversationType: 'personal', + name: 'convo' + }, + ...activity + }); + const state: TurnState = new TurnState(); + await state.load(context); + state.temp = { + input: '', + inputFiles: [], + lastOutput: '', + actionOutputs: {}, + authTokens: {} + }; + + return [context, state]; + }; + + before(() => { + sinon.restore(); + sinon.stub(UserTokenAccess, 'getSignInResource').callsFake(async () => { + return { + signInLink: 'testlink', + tokenExchangeResource: { + id: 'testid', + uri: 'testuri' + }, + tokenPostResource: { + sasUrl: 'testsasurl' + } + }; + }); + + sinon.stub(UserTokenAccess, 'getUserToken'); + }); + + describe('beginDialog', () => { + let promptMock: sinon.SinonMock; + + before(() => { + promptMock = sinon.mock(OAuthBotPrompt); + }); + + it('should call sendOAuthCard and return Dialog.EndOfTurn', async function () { + const [context, state] = await createTurnContextAndState({ + type: 'message', + from: { + id: 'test', + name: 'test' + } + }); + + const settings = { + connectionName: 'myConnection', + title: 'Login', + timeout: 300000, + enableSso: true + }; + const prompt = new OAuthBotPrompt('OAuthPrompt', settings); + + const dialogStateProperty = 'dialogStateProperty'; + const accessor = new TurnStateProperty(state, 'conversation', dialogStateProperty); + const dialogSet = new DialogSet(accessor); + dialogSet.add(prompt); + const dialogContext = await dialogSet.createContext(context); + console.log(dialogContext); + promptMock.expects('sendOAuthCard').once(); + + const result = await dialogContext.beginDialog('OAuthPrompt'); + + assert(result === Dialog.EndOfTurn); + promptMock.verify(); + }); + + after(() => { + promptMock.restore(); + }); + }); + + describe('sendOAuthCard', () => { + const ssoConfigs = [true, false]; + + ssoConfigs.forEach((enableSso) => { + it(`should return oauth card (enabledSso: ${enableSso})`, async function () { + const [context, _] = await createTurnContextAndState({ + type: 'message', + from: { + id: 'test', + name: 'test' + } + }); + const connectionName = 'myConnection'; + const settings = { + connectionName: connectionName, + title: 'Login', + timeout: 300000, + enableSso: enableSso + }; + + let returnedActivity: Partial = {}; + sinon.stub(context, 'sendActivity').callsFake(async (activity) => { + returnedActivity = activity as Partial; + return undefined; + }); + + await OAuthBotPrompt.sendOAuthCard(settings, context, 'prompt'); + + assert(returnedActivity); + assert(returnedActivity?.attachments?.length === 1); + assert(returnedActivity.inputHint === InputHints.AcceptingInput); + const card = returnedActivity?.attachments[0]; + assert(card.contentType === CardFactory.contentTypes.oauthCard); + assert(card.content?.buttons.length === 1); + assert(card.content?.buttons[0].type === 'signin'); + assert(card.content?.buttons[0].title === 'Login'); + assert(card.content?.buttons[0].value === 'testlink'); + assert(card.content?.connectionName === connectionName); + assert(card.content?.tokenPostResource?.sasUrl === 'testsasurl'); + if (enableSso) { + assert(card.content?.tokenExchangeResource?.id === 'testid'); + assert(card.content?.tokenExchangeResource?.uri === 'testuri'); + } + }); + }); + }); +}); diff --git a/js/packages/teams-ai/src/authentication/OAuthBotPrompt.ts b/js/packages/teams-ai/src/authentication/OAuthBotPrompt.ts new file mode 100644 index 000000000..37edc9cb3 --- /dev/null +++ b/js/packages/teams-ai/src/authentication/OAuthBotPrompt.ts @@ -0,0 +1,143 @@ +import { Dialog, DialogContext, DialogTurnResult, OAuthPrompt, PromptOptions } from 'botbuilder-dialogs'; +import { OAuthSettings } from './Authentication'; + +import { + ActionTypes, + Activity, + CardFactory, + Channels, + InputHints, + MessageFactory, + OAuthCard, + OAuthLoginTimeoutKey, + TurnContext +} from 'botbuilder'; +import * as UserTokenAccess from './UserTokenAccess'; + +/** + * Override the `sendOAuthCard` method to add support for disabling SSO and showing the sign-in card instead. + */ +export class OAuthBotPrompt extends OAuthPrompt { + private oauthSettings: OAuthSettings; + + constructor(dialogId: string, settings: OAuthSettings) { + super(dialogId, settings); + this.oauthSettings = settings; + } + + async beginDialog(dc: DialogContext): Promise { + // Ensure prompts have input hint set + const options: Partial = { + prompt: { + inputHint: InputHints.AcceptingInput + }, + retryPrompt: { + inputHint: InputHints.AcceptingInput + } + }; + + // Initialize prompt state + const timeout = typeof this.oauthSettings.timeout === 'number' ? this.oauthSettings.timeout : 900000; + const state = dc.activeDialog!.state as OAuthPromptState; + state.state = {}; + state.options = options; + state.expires = new Date().getTime() + timeout; + + // Prompt user to login + await OAuthBotPrompt.sendOAuthCard(this.oauthSettings, dc.context, state.options.prompt); + return Dialog.EndOfTurn; + } + + /** + * Sends an OAuth card. + * @param {OAuthSettings} settings OAuth settings. + * @param {TurnContext} turnContext Turn context. + * @param {string | Partial} prompt Message activity. + */ + static override async sendOAuthCard( + settings: OAuthSettings, + turnContext: TurnContext, + prompt?: string | Partial + ): Promise { + // Initialize outgoing message + const msg: Partial = + typeof prompt === 'object' + ? { ...prompt } + : MessageFactory.text(prompt ?? '', undefined, InputHints.AcceptingInput); + + if (!Array.isArray(msg.attachments)) { + msg.attachments = []; + } + + // Append appropriate card if missing + const msgHasOAuthCardAttachment = msg.attachments.some( + (a) => a.contentType === CardFactory.contentTypes.oauthCard + ); + + if (!msgHasOAuthCardAttachment) { + const cardActionType = ActionTypes.Signin; + const signInResource = await UserTokenAccess.getSignInResource(turnContext, settings); + + let link = signInResource.signInLink; + + if ( + settings.showSignInLink === false || + !this.signInLinkRequiredByChannel(turnContext.activity.channelId) + ) { + link = undefined; + } + + let tokenExchangeResource; + if (settings.enableSso === true) { + // Send the token exchange resource only if enableSso is true. + tokenExchangeResource = signInResource.tokenExchangeResource; + } + + // Append oauth card + const card = CardFactory.oauthCard( + settings.connectionName, + settings.title, + settings.text, + link, + 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 Turn Context's turn state 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); + } + + private static signInLinkRequiredByChannel(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. +} diff --git a/js/packages/teams-ai/src/authentication/OAuthMessageExtensionAuthentication.spec.ts b/js/packages/teams-ai/src/authentication/OAuthMessageExtensionAuthentication.spec.ts index b4fa05782..f0edfa0ee 100644 --- a/js/packages/teams-ai/src/authentication/OAuthMessageExtensionAuthentication.spec.ts +++ b/js/packages/teams-ai/src/authentication/OAuthMessageExtensionAuthentication.spec.ts @@ -217,7 +217,7 @@ describe('OAuthPromptMessageExtensionAuthentication', () => { ]; testCases.forEach(([name, authType]) => { - it(`should send type '${authType}' suggestion action when couldn't retrieve token from token store for invoke name ${name}`, async () => { + it(`should send type '${authType}' suggestion action when couldn't retrieve token from token store for invoke name ${name} when sso is enabled`, async () => { const magicCode = 'OAuth flow magic code'; const [context, _] = await createTurnContextAndState({ type: ActivityTypes.Invoke, @@ -235,6 +235,12 @@ describe('OAuthPromptMessageExtensionAuthentication', () => { }) ); + const settings = { + connectionName: 'connectionName', + title: 'title', + enableSso: true + }; + const meAuth = new OAuthPromptMessageExtensionAuthentication(settings); const signInResourceStub = sinon.stub(UserTokenAccess, 'getSignInResource').returns( @@ -355,4 +361,63 @@ describe('OAuthPromptMessageExtensionAuthentication', () => { assert(exchangeTokenStub.calledWith(context, settings, { token: 'token' })); }); }); + + describe('isSsoSignIn()', () => { + it('should return true if the activity is `composeExtension/query` and sso is enabled', async () => { + const [context, _] = await createTurnContextAndState({ + type: ActivityTypes.Invoke, + name: 'composeExtension/query' + }); + + const settings = { + connectionName: 'connectionName', + title: 'title', + enableSso: true + }; + + const meAuth = new OAuthPromptMessageExtensionAuthentication(settings); + + const result = meAuth.isSsoSignIn(context); + + assert(result === true); + }); + + it('should return false if the activity is not `composeExtension/query`', async () => { + const [context, _] = await createTurnContextAndState({ + type: ActivityTypes.Invoke, + name: 'NOT composeExtension/query' + }); + + const settings = { + connectionName: 'connectionName', + title: 'title', + enableSso: true + }; + + const meAuth = new OAuthPromptMessageExtensionAuthentication(settings); + + const result = meAuth.isSsoSignIn(context); + + assert(result === false); + }); + + it('should return false if sso is not enabled', async () => { + const [context, _] = await createTurnContextAndState({ + type: ActivityTypes.Invoke, + name: 'composeExtension/query' + }); + + const settings = { + connectionName: 'connectionName', + title: 'title', + enableSso: false + }; + + const meAuth = new OAuthPromptMessageExtensionAuthentication(settings); + + const result = meAuth.isSsoSignIn(context); + + assert(result === false); + }); + }); }); diff --git a/js/packages/teams-ai/src/authentication/OAuthMessageExtensionAuthentication.ts b/js/packages/teams-ai/src/authentication/OAuthMessageExtensionAuthentication.ts index 7d434dfbe..0f4e90c47 100644 --- a/js/packages/teams-ai/src/authentication/OAuthMessageExtensionAuthentication.ts +++ b/js/packages/teams-ai/src/authentication/OAuthMessageExtensionAuthentication.ts @@ -2,9 +2,10 @@ // Licensed under the MIT License. import { TokenResponse, TurnContext } from 'botbuilder'; -import { OAuthPromptSettings } from 'botbuilder-dialogs'; import { MessageExtensionAuthenticationBase } from './MessageExtensionAuthenticationBase'; import * as UserTokenAccess from './UserTokenAccess'; +import { OAuthSettings } from './Authentication'; +import { MessageExtensionsInvokeNames } from '../MessageExtensions'; /** * @internal @@ -14,16 +15,16 @@ import * as UserTokenAccess from './UserTokenAccess'; export class OAuthPromptMessageExtensionAuthentication extends MessageExtensionAuthenticationBase { /** * Creates a new instance of OAuthPromptMessageExtensionAuthentication. - * @param settings The OAuthPromptSettings. + * @param {OAuthSettings} settings The OAuthPromptSettings. */ - public constructor(private readonly settings: OAuthPromptSettings) { + public constructor(private readonly settings: OAuthSettings) { super(); } /** * Handles the SSO token exchange. - * @param context The turn context. - * @returns A promise that resolves to the token response or undefined if token exchange failed. + * @param {TurnContext} context The turn context. + * @returns {Promise} A promise that resolves to the token response or undefined if token exchange failed. */ public async handleSsoTokenExchange(context: TurnContext): Promise { const tokenExchangeRequest = context.activity.value.authentication; @@ -37,9 +38,9 @@ export class OAuthPromptMessageExtensionAuthentication extends MessageExtensionA /** * Handles the signin/verifyState activity. - * @param context The turn context. - * @param magicCode The magic code from sign-in. - * @returns A promise that resolves to undefined. The parent class will trigger silentAuth again. + * @param {TurnContext} context The turn context. + * @param {string} magicCode The magic code from sign-in. + * @returns {Promise} A promise that resolves to undefined. The parent class will trigger silentAuth again. */ public async handleUserSignIn(context: TurnContext, magicCode: string): Promise { return await UserTokenAccess.getUserToken(context, this.settings, magicCode); @@ -47,11 +48,23 @@ export class OAuthPromptMessageExtensionAuthentication extends MessageExtensionA /** * Gets the sign-in link for the user. - * @param context The turn context. - * @returns A promise that resolves to the sign-in link or undefined if no sign-in link available. + * @param {TurnContext} context The turn context. + * @returns {Promise} A promise that resolves to the sign-in link or undefined if no sign-in link available. */ public async getSignInLink(context: TurnContext): Promise { const signInResource = await UserTokenAccess.getSignInResource(context, this.settings); return signInResource.signInLink; } + + /** + * Should sign in using SSO flow. + * @param {TurnContext} context - The turn context. + * @returns {boolean} - A boolean indicating if the sign-in should use SSO flow. + */ + public isSsoSignIn(context: TurnContext): boolean { + if (context.activity.name === MessageExtensionsInvokeNames.QUERY_INVOKE && this.settings.enableSso == true) { + return true; + } + return false; + } } diff --git a/js/packages/teams-ai/src/authentication/TeamsSsoMessageExtensionAuthentication.spec.ts b/js/packages/teams-ai/src/authentication/TeamsSsoMessageExtensionAuthentication.spec.ts index d8571c9f4..aa1ae23b7 100644 --- a/js/packages/teams-ai/src/authentication/TeamsSsoMessageExtensionAuthentication.spec.ts +++ b/js/packages/teams-ai/src/authentication/TeamsSsoMessageExtensionAuthentication.spec.ts @@ -287,4 +287,30 @@ describe('TeamsSsoMessageExtensionAuthentication', () => { ); }); }); + + describe('isSsoSignIn()', async () => { + it('should return true if activity is composeExtension/query', async () => { + const [context, _] = await createTurnContextAndState({ + type: ActivityTypes.Invoke, + name: 'composeExtension/query' + }); + + const msal = new ConfidentialClientApplication(settings.msalConfig); + const auth = new TeamsSsoMessageExtensionAuthentication(settings, msal); + + assert.equal(auth.isSsoSignIn(context), true); + }); + + it('should return false if activity is not composeExtension/query', async () => { + const [context, _] = await createTurnContextAndState({ + type: ActivityTypes.Invoke, + name: 'not compose extension query' + }); + + const msal = new ConfidentialClientApplication(settings.msalConfig); + const auth = new TeamsSsoMessageExtensionAuthentication(settings, msal); + + assert.equal(auth.isSsoSignIn(context), false); + }); + }); }); diff --git a/js/packages/teams-ai/src/authentication/TeamsSsoMessageExtensionAuthentication.ts b/js/packages/teams-ai/src/authentication/TeamsSsoMessageExtensionAuthentication.ts index 2aad821e2..5190d911c 100644 --- a/js/packages/teams-ai/src/authentication/TeamsSsoMessageExtensionAuthentication.ts +++ b/js/packages/teams-ai/src/authentication/TeamsSsoMessageExtensionAuthentication.ts @@ -87,4 +87,16 @@ export class TeamsSsoMessageExtensionAuthentication extends MessageExtensionAuth return signInLink; } + + /** + * Should sign in using SSO flow. + * @param {TurnContext} context - The turn context. + * @returns {boolean} - A boolean indicating if the sign-in should use SSO flow. + */ + public isSsoSignIn(context: TurnContext): boolean { + if (context.activity.name === MessageExtensionsInvokeNames.QUERY_INVOKE) { + return true; + } + return false; + } } diff --git a/js/samples/06.auth.oauth.adaptiveCard/src/index.ts b/js/samples/06.auth.oauth.adaptiveCard/src/index.ts index d39f0a8d3..a1f48c61f 100644 --- a/js/samples/06.auth.oauth.adaptiveCard/src/index.ts +++ b/js/samples/06.auth.oauth.adaptiveCard/src/index.ts @@ -75,7 +75,8 @@ const app = new ApplicationBuilder() title: 'Sign in', text: 'Please sign in to use the bot.', endOnInvalidMessage: true, - tokenExchangeUri: process.env.TokenExchangeUri ?? '' + tokenExchangeUri: process.env.TOKEN_EXCHANGE_URI ?? '', // this is required for SSO + enableSso: true } }, autoSignIn: (context: TurnContext) => { diff --git a/js/samples/06.auth.oauth.adaptiveCard/teamsapp.local.yml b/js/samples/06.auth.oauth.adaptiveCard/teamsapp.local.yml index 4df538dee..ac9359e40 100644 --- a/js/samples/06.auth.oauth.adaptiveCard/teamsapp.local.yml +++ b/js/samples/06.auth.oauth.adaptiveCard/teamsapp.local.yml @@ -109,4 +109,5 @@ deploy: BOT_ID: ${{BOT_ID}} BOT_PASSWORD: ${{SECRET_BOT_PASSWORD}} # an arbitrary name for the connection - OAUTH_CONNECTION_NAME: graph-connection \ No newline at end of file + OAUTH_CONNECTION_NAME: graph-connection + TOKEN_EXCHANGE_URI: api://botid-${{BOT_ID}} \ No newline at end of file diff --git a/js/samples/06.auth.oauth.bot/src/index.ts b/js/samples/06.auth.oauth.bot/src/index.ts index e3f6f050e..82745617b 100644 --- a/js/samples/06.auth.oauth.bot/src/index.ts +++ b/js/samples/06.auth.oauth.bot/src/index.ts @@ -75,7 +75,8 @@ const app = new ApplicationBuilder() connectionName: process.env.OAUTH_CONNECTION_NAME ?? '', title: 'Sign in', text: 'Please sign in to use the bot.', - endOnInvalidMessage: true + endOnInvalidMessage: true, + enableSso: true // Set this to false to disable SSO } } }) diff --git a/js/samples/06.auth.oauth.messageExtension/src/index.ts b/js/samples/06.auth.oauth.messageExtension/src/index.ts index 46615be43..cd59f75dc 100644 --- a/js/samples/06.auth.oauth.messageExtension/src/index.ts +++ b/js/samples/06.auth.oauth.messageExtension/src/index.ts @@ -83,7 +83,8 @@ const app = new ApplicationBuilder() connectionName: process.env.OAUTH_CONNECTION_NAME ?? '', title: 'Sign in', text: 'Please sign in to use the bot.', - endOnInvalidMessage: true + endOnInvalidMessage: true, + enableSso: true // Set this to false to disable SSO } }, autoSignIn: (context: TurnContext) => {