Skip to content

Commit

Permalink
[JS] feat: Add enableSso property to toggle SSO in user auth scenar…
Browse files Browse the repository at this point in the history
…ios (#1232)

## Linked issues

closes: #1194  (issue number)

## Details

Introduced `enableSso` property in the `OAuthSettings` to toggle SSO in
`OAuth` adaptive card, bot and message extension scenarios.

### Change details
The authentication feature is broken down into these 3 scenarios, so I
had to update these individual implementations for the `enableSso`
property to work.

**Bot auth**
* In bot auth, to disable sso you simply have to not include the
`tokenExchangeResource` in the oauth card. The original `OAuthPrompt`
didn't allow that so I had to create the `OAuthBotPrompt` to override
the `beginDialog` method and `sendOauthCard`.


**Message Extension auth**
* In ME auth, to disable sso you simply set the `type` to `auth` instead
of `silentAuth`. I introduced a `isSsoSignIn` method to determine
whether to use auth or not. This is because the card creation happens in
the ME auth base class, which is used for `TeamsSSO` auth as well.

**Adaptive Card auth**
* In adaptive card auth, to disable sso you simply don't include the
`tokenExchangeResource` in the card.

## Attestation Checklist

- [x] My code follows the style guidelines of this project

- I have checked for/fixed spelling, linting, and other errors
- I have commented my code for clarity
- I have made corresponding changes to the documentation (updating the
doc strings in the code is sufficient)
- My changes generate no new warnings
- I have added tests that validates my changes, and provides sufficient
test coverage. I have tested with:
  - Local testing
  - E2E testing in Teams
- New and existing unit tests pass locally with my changes

---------

Co-authored-by: Corina <[email protected]>
  • Loading branch information
singhk97 and corinagum authored Feb 2, 2024
1 parent 87fd35c commit b8a7ffa
Show file tree
Hide file tree
Showing 18 changed files with 524 additions and 69 deletions.
32 changes: 27 additions & 5 deletions getting-started/CONCEPTS/USER-AUTH.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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<ApplicationTurnState>()
.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:

Expand Down Expand Up @@ -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:

Expand All @@ -169,4 +191,4 @@ await app.Authentication.SignOutUserAsync(context, state, "graph", cancellationT
**Javascript**
```js
await app.authentication.signOutUser(context, state, 'graph');
```
```
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 7 additions & 2 deletions js/packages/teams-ai/src/authentication/Authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -117,4 +117,11 @@ export abstract class MessageExtensionAuthenticationBase {
* @returns {Promise<string | undefined>} - A promise that resolves to the sign-in link or undefined if no sign-in link available.
*/
public abstract getSignInLink(context: TurnContext): Promise<string | undefined>;

/**
* 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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,16 @@ 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();
}

/**
* 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<TokenResponse | undefined>} A promise that resolves to the token response or undefined if token exchange failed.
*/
public async handleSsoTokenExchange(context: TurnContext): Promise<TokenResponse | undefined> {
const tokenExchangeRequest = context.activity.value.authentication;
Expand All @@ -37,18 +37,18 @@ 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<TokenResponse | undefined>} A promise that resolves to undefined. The parent class will trigger silentAuth again.
*/
public async handleUserSignIn(context: TurnContext, magicCode: string): Promise<TokenResponse | undefined> {
return await UserTokenAccess.getUserToken(context, this.settings, magicCode);
}

/**
* 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<TokenResponse | undefined>} A promise that resolves to the sign-in link or undefined if no sign-in link available.
*/
public async getLoginRequest(context: TurnContext): Promise<AdaptiveCardLoginRequest> {
const signInResource = await UserTokenAccess.getSignInResource(context, this.settings);
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Activity>): Promise<[TurnContext, TurnState]> => {
Expand Down Expand Up @@ -336,7 +330,7 @@ describe('BotAuthentication', () => {
const dialogStateProperty = 'dialogStateProperty';
const accessor = new TurnStateProperty<DialogState>(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
Expand All @@ -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();
});
});
});
Expand Down
45 changes: 25 additions & 20 deletions js/packages/teams-ai/src/authentication/OAuthBotAuthentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,32 +28,36 @@ export class OAuthBotAuthentication<TState extends TurnState> 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<TState>,
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));
}

/**
* 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<DialogTurnResult<TokenResponse>>} A promise that resolves to the dialog turn result containing the token response.
*/
public async runDialog(
context: TurnContext,
Expand All @@ -69,10 +74,10 @@ export class OAuthBotAuthentication<TState extends TurnState> 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<DialogTurnResult<TokenResponse>>} A promise that resolves to the dialog turn result containing the token response.
*/
public async continueDialog(
context: TurnContext,
Expand All @@ -85,10 +90,10 @@ export class OAuthBotAuthentication<TState extends TurnState> 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<DialogContext>} A promise that resolves to the dialog context.
*/
private async createDialogContext(
context: TurnContext,
Expand Down
Loading

0 comments on commit b8a7ffa

Please sign in to comment.