diff --git a/libraries/botbuilder-core/package.json b/libraries/botbuilder-core/package.json index d417b42c46..ac5796f0e3 100644 --- a/libraries/botbuilder-core/package.json +++ b/libraries/botbuilder-core/package.json @@ -32,6 +32,7 @@ "botbuilder-stdlib": "4.1.6", "botframework-connector": "4.1.6", "botframework-schema": "4.1.6", + "runtypes": "~6.3.0", "uuid": "^8.3.2" }, "devDependencies": { diff --git a/libraries/botbuilder-core/src/configurationBotFrameworkAuthentication.ts b/libraries/botbuilder-core/src/configurationBotFrameworkAuthentication.ts new file mode 100644 index 0000000000..f993450015 --- /dev/null +++ b/libraries/botbuilder-core/src/configurationBotFrameworkAuthentication.ts @@ -0,0 +1,221 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Activity } from 'botframework-schema'; +import { + AuthenticateRequestResult, + AuthenticationConfiguration, + AuthenticationConstants, + BotFrameworkAuthentication, + BotFrameworkAuthenticationFactory, + BotFrameworkClient, + ClaimsIdentity, + ConnectorClientOptions, + ConnectorFactory, + ServiceClientCredentialsFactory, + UserTokenClient, +} from 'botframework-connector'; +import { Configuration } from 'botbuilder-dialogs-adaptive-runtime-core'; +import { + ConfigurationServiceClientCredentialFactory, + ConfigurationServiceClientCredentialFactoryOptions, +} from './configurationServiceClientCredentialFactory'; +import * as t from 'runtypes'; +import { ValidationError } from 'runtypes'; + +const TypedOptions = t.Record({ + /** + * (Optional) The OAuth URL used to get a token from OAuthApiClient. The "OAuthUrl" member takes precedence over this value. + */ + [AuthenticationConstants.OAuthUrlKey]: t.String.nullable().optional(), + + /** + * (Optional) The OpenID metadata document used for authenticating tokens coming from the channel. The "ToBotFromChannelOpenIdMetadataUrl" member takes precedence over this value. + */ + [AuthenticationConstants.BotOpenIdMetadataKey]: t.String.nullable().optional(), + + /** + * A string used to indicate if which cloud the bot is operating in (e.g. Public Azure or US Government). + * + * @remarks + * A `null` or `''` value indicates Public Azure, whereas [GovernmentConstants.ChannelService](xref:botframework-connector.GovernmentConstants.ChannelService) indicates the bot is operating in the US Government cloud. + * + * Other values result in a custom authentication configuration derived from the values passed in on the [ConfigurationBotFrameworkAuthenticationOptions](xef:botbuilder-core.ConfigurationBotFrameworkAuthenticationOptions) instance. + */ + [AuthenticationConstants.ChannelService]: t.String.nullable().optional(), + + /** + * Flag indicating whether or not to validate the address. + */ + ValidateAuthority: t.String.Or(t.Boolean), + + /** + * The Login URL used to specify the tenant from which the bot should obtain access tokens from. + */ + ToChannelFromBotLoginUrl: t.String, + + /** + * The Oauth scope to request. + * + * @remarks + * This value is used when fetching a token to indicate the ultimate recipient or `audience` of an activity sent using these credentials. + */ + ToChannelFromBotOAuthScope: t.String, + + /** + * The Token issuer for signed requests to the channel. + */ + ToBotFromChannelTokenIssuer: t.String, + + /** + * The OAuth URL used to get a token from OAuthApiClient. + */ + OAuthUrl: t.String, + + /** + * The OpenID metadata document used for authenticating tokens coming from the channel. + */ + ToBotFromChannelOpenIdMetadataUrl: t.String, + + /** + * The The OpenID metadata document used for authenticating tokens coming from the Emulator. + */ + ToBotFromEmulatorOpenIdMetadataUrl: t.String, + + /** + * A value for the CallerId. + */ + CallerId: t.String, +}); + +/** + * Contains settings used to configure a [ConfigurationBotFrameworkAuthentication](xref:botbuilder-core.ConfigurationBotFrameworkAuthentication) instance. + */ +export type ConfigurationBotFrameworkAuthenticationOptions = t.Static; + +/** + * Creates a [BotFrameworkAuthentication](xref:botframework-connector.BotFrameworkAuthentication) instance from an object with the authentication values or a [Configuration](xref:botbuilder-dialogs-adaptive-runtime-core.Configuration) instance. + */ +export class ConfigurationBotFrameworkAuthentication extends BotFrameworkAuthentication { + private readonly inner: BotFrameworkAuthentication; + + /** + * Initializes a new instance of the [ConfigurationBotFrameworkAuthentication](xref:botbuilder-core.ConfigurationBotFrameworkAuthentication) class. + * + * @param botFrameworkAuthConfig A [ConfigurationBotFrameworkAuthenticationOptions](xref:botbuilder-core.ConfigurationBotFrameworkAuthenticationOptions) object. + * @param credentialsFactory A [ServiceClientCredentialsFactory](xref:botframework-connector.ServiceClientCredentialsFactory) instance. + * @param authConfiguration A [Configuration](xref:botframework-connector.AuthenticationConfiguration) object. + * @param botFrameworkClientFetch A custom Fetch implementation to be used in the [BotFrameworkClient](xref:botframework-connector.BotFrameworkClient). + * @param connectorClientOptions A [ConnectorClientOptions](xref:botframework-connector.ConnectorClientOptions) object. + */ + constructor( + botFrameworkAuthConfig: Partial, + credentialsFactory?: ServiceClientCredentialsFactory, + authConfiguration?: AuthenticationConfiguration, + botFrameworkClientFetch?: (input: RequestInfo, init?: RequestInit) => Promise, + connectorClientOptions: ConnectorClientOptions = {} + ) { + super(); + try { + TypedOptions.check(botFrameworkAuthConfig); + } catch (err) { + // Throw a new error with the validation details prominently featured. + if (err instanceof ValidationError && err.details) { + throw new Error(JSON.stringify(err.details, undefined, 2)); + } + throw err; + } + const { + ChannelService, + ToChannelFromBotLoginUrl, + ToChannelFromBotOAuthScope, + ToBotFromChannelTokenIssuer, + ToBotFromEmulatorOpenIdMetadataUrl, + CallerId, + } = botFrameworkAuthConfig; + + const ToBotFromChannelOpenIdMetadataUrl = + botFrameworkAuthConfig.ToBotFromChannelOpenIdMetadataUrl ?? + botFrameworkAuthConfig[AuthenticationConstants.BotOpenIdMetadataKey]; + const OAuthUrl = botFrameworkAuthConfig.OAuthUrl ?? botFrameworkAuthConfig[AuthenticationConstants.OAuthUrlKey]; + let ValidateAuthority = true; + try { + ValidateAuthority = Boolean(JSON.parse(botFrameworkAuthConfig.ValidateAuthority as string)); + } catch (_err) { + // no-op + } + + this.inner = BotFrameworkAuthenticationFactory.create( + ChannelService, + ValidateAuthority, + ToChannelFromBotLoginUrl, + ToChannelFromBotOAuthScope, + ToBotFromChannelTokenIssuer, + OAuthUrl, + ToBotFromChannelOpenIdMetadataUrl, + ToBotFromEmulatorOpenIdMetadataUrl, + CallerId, + credentialsFactory ?? + new ConfigurationServiceClientCredentialFactory( + botFrameworkAuthConfig as ConfigurationServiceClientCredentialFactoryOptions + ), + authConfiguration ?? ({} as AuthenticationConfiguration), + botFrameworkClientFetch, + connectorClientOptions + ); + } + + authenticateChannelRequest(authHeader: string): Promise { + return this.inner.authenticateChannelRequest(authHeader); + } + + authenticateRequest(activity: Activity, authHeader: string): Promise { + return this.inner.authenticateRequest(activity, authHeader); + } + + authenticateStreamingRequest(authHeader: string, channelIdHeader: string): Promise { + return this.inner.authenticateStreamingRequest(authHeader, channelIdHeader); + } + + createBotFrameworkClient(): BotFrameworkClient { + return this.inner.createBotFrameworkClient(); + } + + createConnectorFactory(claimsIdentity: ClaimsIdentity): ConnectorFactory { + return this.inner.createConnectorFactory(claimsIdentity); + } + + createUserTokenClient(claimsIdentity: ClaimsIdentity): Promise { + return this.inner.createUserTokenClient(claimsIdentity); + } +} + +/** + * Creates a new instance of the [ConfigurationBotFrameworkAuthentication](xref:botbuilder-core.ConfigurationBotFrameworkAuthentication) class. + * + * @remarks + * The [Configuration](xref:botbuilder-dialogs-adaptive-runtime-core.Configuration) instance provided to the constructor should + * have the desired authentication values available at the root, using the properties of [ConfigurationBotFrameworkAuthenticationOptions](xref:botbuilder-core.ConfigurationBotFrameworkAuthenticationOptions) as its keys. + * @param configuration A [Configuration](xref:botbuilder-dialogs-adaptive-runtime-core.Configuration) instance. + * @param credentialsFactory A [ServiceClientCredentialsFactory](xref:botframework-connector.ServiceClientCredentialsFactory) instance. + * @param authConfiguration A [Configuration](xref:botframework-connector.AuthenticationConfiguration) object. + * @param botFrameworkClientFetch A custom Fetch implementation to be used in the [BotFrameworkClient](xref:botframework-connector.BotFrameworkClient). + * @param connectorClientOptions A [ConnectorClientOptions](xref:botframework-connector.ConnectorClientOptions) object. + * @returns A [ConfigurationBotFrameworkAuthentication](xref:botbuilder-core.ConfigurationBotFrameworkAuthentication) instance. + */ +export function createBotFrameworkAuthenticationFromConfiguration( + configuration: Configuration, + credentialsFactory: ServiceClientCredentialsFactory = null, + authConfiguration: AuthenticationConfiguration = null, + botFrameworkClientFetch?: (input: RequestInfo, init?: RequestInit) => Promise, + connectorClientOptions: ConnectorClientOptions = {} +): BotFrameworkAuthentication { + const botFrameworkAuthConfig = configuration?.get(); + return new ConfigurationBotFrameworkAuthentication( + botFrameworkAuthConfig, + credentialsFactory, + authConfiguration, + botFrameworkClientFetch, + connectorClientOptions + ); +} diff --git a/libraries/botbuilder-core/src/configurationServiceClientCredentialFactory.ts b/libraries/botbuilder-core/src/configurationServiceClientCredentialFactory.ts new file mode 100644 index 0000000000..a5b9e8ddb9 --- /dev/null +++ b/libraries/botbuilder-core/src/configurationServiceClientCredentialFactory.ts @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Configuration } from 'botbuilder-dialogs-adaptive-runtime-core'; +import { PasswordServiceClientCredentialFactory } from 'botframework-connector'; +import * as t from 'runtypes'; +import { ValidationError } from 'runtypes'; + +const TypedConfig = t.Record({ + /** + * The ID assigned to your bot in the [Bot Framework Portal](https://dev.botframework.com/). + */ + MicrosoftAppId: t.String.nullable().optional(), + + /** + * The password assigned to your bot in the [Bot Framework Portal](https://dev.botframework.com/). + */ + MicrosoftAppPassword: t.String.nullable().optional(), +}); + +/** + * Contains settings used to configure a [ConfigurationServiceClientCredentialFactory](xref:botbuilder-core.ConfigurationServiceClientCredentialFactory) instance. + */ +export type ConfigurationServiceClientCredentialFactoryOptions = t.Static; + +const isValidationErrorWithDetails = (val: unknown): val is ValidationError => { + return !!(val as ValidationError)?.details; +}; + +/** + * ServiceClientCredentialsFactory that uses a [ConfigurationServiceClientCredentialFactoryOptions](xref:botbuilder-core.ConfigurationServiceClientCredentialFactoryOptions) or a [Configuration](xref:botbuilder-dialogs-adaptive-runtime-core.Configuration) instance to build ServiceClientCredentials with an AppId and App Password. + */ +export class ConfigurationServiceClientCredentialFactory extends PasswordServiceClientCredentialFactory { + /** + * Initializes a new instance of the [ConfigurationServiceClientCredentialFactory](xref:botbuilder-core.ConfigurationServiceClientCredentialFactory) class. + * + * @param factoryOptions A [ConfigurationServiceClientCredentialFactoryOptions](xref:botbuilder-core.ConfigurationServiceClientCredentialFactoryOptions) object. + */ + constructor(factoryOptions: ConfigurationServiceClientCredentialFactoryOptions) { + super(null, null); + try { + const { MicrosoftAppId, MicrosoftAppPassword } = TypedConfig.check(factoryOptions); + this.appId = MicrosoftAppId ?? this.appId; + this.password = MicrosoftAppPassword ?? this.password; + } catch (err) { + if (isValidationErrorWithDetails(err)) { + const e = new Error(JSON.stringify(err.details, undefined, 2)); + e.stack = err.stack; + throw e; + } + throw err; + } + } +} + +/** + * Creates a new instance of the [ConfigurationServiceClientCredentialFactory](xref:botbuilder-core.ConfigurationServiceClientCredentialFactory) class. + * + * @remarks + * The [Configuration](xref:botbuilder-dialogs-adaptive-runtime-core.Configuration) instance provided to the constructor should + * have the desired authentication values available at the root, using the properties of [ConfigurationServiceClientCredentialFactoryOptions](xref:botbuilder-core.ConfigurationServiceClientCredentialFactoryOptions) as its keys. + * @param configuration A [Configuration](xref:botbuilder-dialogs-adaptive-runtime-core.Configuration) instance. + * @returns A [ConfigurationServiceClientCredentialFactory](xref:botbuilder-core.ConfigurationServiceClientCredentialFactory) instance. + */ +export function createServiceClientCredentialFactoryFromConfiguration( + configuration: Configuration +): ConfigurationServiceClientCredentialFactory { + const factoryOptions = configuration.get(); + return new ConfigurationServiceClientCredentialFactory(factoryOptions); +} diff --git a/libraries/botbuilder-core/src/index.ts b/libraries/botbuilder-core/src/index.ts index bac5fa687e..378c919322 100644 --- a/libraries/botbuilder-core/src/index.ts +++ b/libraries/botbuilder-core/src/index.ts @@ -20,6 +20,8 @@ export * from './botTelemetryClient'; export * from './browserStorage'; export * from './cardFactory'; export * from './componentRegistration'; +export * from './configurationBotFrameworkAuthentication'; +export * from './configurationServiceClientCredentialFactory'; export * from './conversationState'; export * from './coreAppCredentials'; export * from './extendedUserTokenProvider'; diff --git a/libraries/botbuilder-core/tests/configurationBotFrameworkAuthentication.test.js b/libraries/botbuilder-core/tests/configurationBotFrameworkAuthentication.test.js new file mode 100644 index 0000000000..250f99896c --- /dev/null +++ b/libraries/botbuilder-core/tests/configurationBotFrameworkAuthentication.test.js @@ -0,0 +1,46 @@ +const assert = require('assert'); +const { AuthenticationConstants } = require('botframework-connector'); +const { + CallerIdConstants, + ConfigurationBotFrameworkAuthentication, + createBotFrameworkAuthenticationFromConfiguration, +} = require('../'); + +describe('ConfigurationBotFrameworkAuthentication', function () { + class TestConfiguration { + static DefaultConfig = { + // [AuthenticationConstants.ChannelService]: undefined, + ValidateAuthority: true, + ToChannelFromBotLoginUrl: AuthenticationConstants.ToChannelFromBotLoginUrl, + ToChannelFromBotOAuthScope: AuthenticationConstants.ToChannelFromBotOAuthScope, + ToBotFromChannelTokenIssuer: AuthenticationConstants.ToBotFromChannelTokenIssuer, + ToBotFromEmulatorOpenIdMetadataUrl: AuthenticationConstants.ToBotFromEmulatorOpenIdMetadataUrl, + CallerId: CallerIdConstants.PublicAzureChannel, + ToBotFromChannelOpenIdMetadataUrl: AuthenticationConstants.ToBotFromChannelOpenIdMetadataUrl, + OAuthUrl: AuthenticationConstants.OAuthUrl, + // [AuthenticationConstants.OAuthUrlKey]: 'test', + [AuthenticationConstants.BotOpenIdMetadataKey]: null, + }; + + constructor(config = {}) { + this.configuration = Object.assign({}, TestConfiguration.DefaultConfig, config); + } + + get(_path) { + return this.configuration; + } + + set(_path, _val) {} + } + + it('constructor should work', function () { + const bfAuth = new ConfigurationBotFrameworkAuthentication(TestConfiguration.DefaultConfig); + assert(bfAuth.inner); + }); + + it('createBotFrameworkAuthenticationFromConfiguration should work', function () { + const config = new TestConfiguration(); + const bfAuth = createBotFrameworkAuthenticationFromConfiguration(config); + assert(bfAuth.inner); + }); +}); diff --git a/libraries/botbuilder-core/tests/configurationServiceClientCredentialFactory.test.js b/libraries/botbuilder-core/tests/configurationServiceClientCredentialFactory.test.js new file mode 100644 index 0000000000..04bf7e5eff --- /dev/null +++ b/libraries/botbuilder-core/tests/configurationServiceClientCredentialFactory.test.js @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +const assert = require('assert'); +const { + ConfigurationServiceClientCredentialFactory, + createServiceClientCredentialFactoryFromConfiguration, +} = require('../'); + +describe('ConfigurationServiceClientCredentialFactory', function () { + class TestConfiguration { + static DefaultConfig = { + MicrosoftAppId: 'appId', + MicrosoftAppPassword: 'appPassword', + }; + + constructor(config = {}) { + this.configuration = Object.assign({}, TestConfiguration.DefaultConfig, config); + } + + get(_path) { + return this.configuration; + } + + set(_path, _val) {} + } + + it('constructor should work', function () { + const bfAuth = new ConfigurationServiceClientCredentialFactory(TestConfiguration.DefaultConfig); + assert.strictEqual(bfAuth.appId, TestConfiguration.DefaultConfig.MicrosoftAppId); + assert.strictEqual(bfAuth.password, TestConfiguration.DefaultConfig.MicrosoftAppPassword); + }); + + it('createServiceClientCredentialFactoryFromConfiguration should work', function () { + const config = new TestConfiguration(); + const bfAuth = createServiceClientCredentialFactoryFromConfiguration(config); + assert.strictEqual(bfAuth.appId, TestConfiguration.DefaultConfig.MicrosoftAppId); + assert.strictEqual(bfAuth.password, TestConfiguration.DefaultConfig.MicrosoftAppPassword); + }); + + it('undefined or null configuration values should result in null values', function () { + const config = new TestConfiguration({ + MicrosoftAppId: undefined, + MicrosoftAppPassword: undefined, + }); + const bfAuth = createServiceClientCredentialFactoryFromConfiguration(config); + assert.strictEqual(bfAuth.appId, null); + assert.strictEqual(bfAuth.password, null); + }); + + it('non-string values should fail', function () { + const config = new TestConfiguration({ + MicrosoftAppId: 1, + MicrosoftAppPassword: true, + }); + assert.throws( + () => createServiceClientCredentialFactoryFromConfiguration(config), + /(?:MicrosoftAppId).*\s.*(?:MicrosoftAppPassword)/ + ); + }); +}); diff --git a/libraries/botbuilder-dialogs-adaptive-runtime-core/src/configuration.ts b/libraries/botbuilder-dialogs-adaptive-runtime-core/src/configuration.ts index ee0a8e3f23..d1fd6f4150 100644 --- a/libraries/botbuilder-dialogs-adaptive-runtime-core/src/configuration.ts +++ b/libraries/botbuilder-dialogs-adaptive-runtime-core/src/configuration.ts @@ -11,7 +11,7 @@ export interface Configuration { * @param path path to get * @returns the value, or undefined */ - get(path: string[]): unknown | undefined; + get(path?: string[]): T | undefined; /** * Set a value by path. @@ -26,10 +26,10 @@ export interface Configuration { * Useful for shimming BotComponents into ComponentRegistrations */ export const noOpConfiguration: Configuration = { - get(_path: string[]): unknown | undefined { + get(_path) { return undefined; }, - set(_path: string[], _value: unknown): void { + set(_path, _value) { // no-op }, }; diff --git a/libraries/botbuilder-dialogs-adaptive-runtime/src/configuration.ts b/libraries/botbuilder-dialogs-adaptive-runtime/src/configuration.ts index c6b4f0ca56..ddc7e262b1 100644 --- a/libraries/botbuilder-dialogs-adaptive-runtime/src/configuration.ts +++ b/libraries/botbuilder-dialogs-adaptive-runtime/src/configuration.ts @@ -40,9 +40,9 @@ export class Configuration implements CoreConfiguration { * @param path path to value * @returns the value, or undefined */ - get(path: string[]): unknown | undefined { + get(path?: string[]): T | undefined { // Note: empty path should yield the entire configuration - if (!path.length) { + if (!path?.length) { return this.provider.get(); } diff --git a/libraries/botbuilder-dialogs/src/dialogsBotComponent.ts b/libraries/botbuilder-dialogs/src/dialogsBotComponent.ts index 5c8d75d293..65fa68ce13 100644 --- a/libraries/botbuilder-dialogs/src/dialogsBotComponent.ts +++ b/libraries/botbuilder-dialogs/src/dialogsBotComponent.ts @@ -31,7 +31,7 @@ const InitialSettings = t.Dictionary(t.Unknown, t.String); export class DialogsBotComponent extends BotComponent { configureServices(services: ServiceCollection, configuration: Configuration): void { services.composeFactory('memoryScopes', (memoryScopes) => { - const rootConfiguration = configuration.get([]); + const rootConfiguration = configuration.get(); const initialSettings = InitialSettings.guard(rootConfiguration) ? rootConfiguration : undefined; return memoryScopes.concat( diff --git a/libraries/botbuilder-stdlib/src/types.ts b/libraries/botbuilder-stdlib/src/types.ts index 0c595f2490..1a4824e399 100644 --- a/libraries/botbuilder-stdlib/src/types.ts +++ b/libraries/botbuilder-stdlib/src/types.ts @@ -390,6 +390,18 @@ function isString(val: unknown): val is string { return typeof val === 'string'; } +/** + * Test if `val` is of type `string` with zero length or `Nil`. + * + * @remarks + * Implementation of string.IsNullOrEmpty(): https://docs.microsoft.com/en-us/dotnet/api/system.string.isnullorempty?view=netcore-3.1 + * @param {any} val value to test + * @returns {boolean} true if `val` is of `string` with zero length or `Nil` + */ +function isStringNullOrEmpty(val: unknown): val is Maybe { + return tests.isNil(val) || (tests.isString(val) && !val.length); +} + /** * Assert that `val` is of type `string`. * @@ -553,6 +565,7 @@ export const tests = { isNumber, isObject, isString, + isStringNullOrEmpty, isUnknown, isError, diff --git a/libraries/botbuilder-stdlib/tests/types.test.js b/libraries/botbuilder-stdlib/tests/types.test.js index 74f1ebca05..918b4d2b81 100644 --- a/libraries/botbuilder-stdlib/tests/types.test.js +++ b/libraries/botbuilder-stdlib/tests/types.test.js @@ -110,6 +110,12 @@ describe('assertType', function () { new TypeError('`val` is of wrong type') ); }); + + it('isStringNullOrEmpty works', function () { + assert(tests.isStringNullOrEmpty('')); + assert(tests.isStringNullOrEmpty(undefined)); + assert(!tests.isStringNullOrEmpty(2)); + }); }); describe('partial', function () { diff --git a/libraries/botbuilder/src/botFrameworkAdapter.ts b/libraries/botbuilder/src/botFrameworkAdapter.ts index fa3b5e4493..bd24494378 100644 --- a/libraries/botbuilder/src/botFrameworkAdapter.ts +++ b/libraries/botbuilder/src/botFrameworkAdapter.ts @@ -84,7 +84,7 @@ import { import { BotFrameworkHttpAdapter } from './botFrameworkHttpAdapter'; import { BotLogic, ConnectorClientBuilder, Emitter, Request, Response, WebRequest, WebResponse } from './interfaces'; import { delay, retry } from 'botbuilder-stdlib'; -import { userAgentPolicy } from '@azure/ms-rest-js'; +import { HttpClient, userAgentPolicy } from '@azure/ms-rest-js'; import { validateAndFixActivity } from './activityValidator'; /** @@ -1482,7 +1482,7 @@ export class BotFrameworkAdapter return new ConnectorClient(credentials, clientOptions); } - private getClientOptions(serviceUrl: string, httpClient?: any): ConnectorClientOptions { + private getClientOptions(serviceUrl: string, httpClient?: HttpClient): ConnectorClientOptions { const { requestPolicyFactories, ...clientOptions } = this.settings.clientOptions ?? {}; const options: ConnectorClientOptions = Object.assign({}, { baseUri: serviceUrl }, clientOptions); diff --git a/libraries/botframework-connector/package.json b/libraries/botframework-connector/package.json index 201e12ad1c..eaf5a46878 100644 --- a/libraries/botframework-connector/package.json +++ b/libraries/botframework-connector/package.json @@ -31,12 +31,14 @@ "@types/jsonwebtoken": "7.2.8", "@types/node": "^10.17.27", "adal-node": "0.2.2", + "axios": "^0.21.1", "base64url": "^3.0.0", "botbuilder-stdlib": "4.1.6", "botframework-schema": "4.1.6", "cross-fetch": "^3.0.5", "jsonwebtoken": "8.0.1", - "rsa-pem-from-mod-exp": "^0.8.4" + "rsa-pem-from-mod-exp": "^0.8.4", + "runtypes": "~6.3.0" }, "devDependencies": { "botbuilder-test-utils": "0.0.0", diff --git a/libraries/botframework-connector/src/auth/authenticateRequestResult.ts b/libraries/botframework-connector/src/auth/authenticateRequestResult.ts index a78a6c8041..a5bc2db8a3 100644 --- a/libraries/botframework-connector/src/auth/authenticateRequestResult.ts +++ b/libraries/botframework-connector/src/auth/authenticateRequestResult.ts @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { ClaimsIdentity } from './claimsIdentity'; -import { ConnectorFactory } from '../connectorFactory'; +import type { ClaimsIdentity } from './claimsIdentity'; +import type { ConnectorFactory } from './connectorFactory'; // TODO: Make these descriptions more informative in the JS and .NET SDKs. /** @@ -24,5 +24,5 @@ export type AuthenticateRequestResult = { /** * A value for the ConnectorFactory. */ - connectorFactory: ConnectorFactory; + connectorFactory?: ConnectorFactory; }; diff --git a/libraries/botframework-connector/src/auth/botFrameworkAuthentication.ts b/libraries/botframework-connector/src/auth/botFrameworkAuthentication.ts index 2af0f542a1..0273aff0a2 100644 --- a/libraries/botframework-connector/src/auth/botFrameworkAuthentication.ts +++ b/libraries/botframework-connector/src/auth/botFrameworkAuthentication.ts @@ -3,13 +3,13 @@ import { Activity, CallerIdConstants } from 'botframework-schema'; import { AuthenticateRequestResult } from './authenticateRequestResult'; -import { BotFrameworkClient } from '../skills'; +import type { BotFrameworkClient } from '../skills'; import { ClaimsIdentity } from './claimsIdentity'; -import { ConnectorFactory } from '../connectorFactory'; +import type { ConnectorFactory } from './connectorFactory'; import { JwtTokenValidation } from './jwtTokenValidation'; -import { ServiceClientCredentialsFactory } from './serviceClientCredentialsFactory'; +import type { ServiceClientCredentialsFactory } from './serviceClientCredentialsFactory'; import { SkillValidation } from './skillValidation'; -import { UserTokenClient } from './userTokenClient'; +import type { UserTokenClient } from './userTokenClient'; /** * Represents a Cloud Environment used to authenticate Bot Framework Protocol network calls within this environment. diff --git a/libraries/botframework-connector/src/auth/botFrameworkAuthenticationFactory.ts b/libraries/botframework-connector/src/auth/botFrameworkAuthenticationFactory.ts new file mode 100644 index 0000000000..cb481fa64c --- /dev/null +++ b/libraries/botframework-connector/src/auth/botFrameworkAuthenticationFactory.ts @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { CallerIdConstants } from 'botframework-schema'; +import { ConnectorClientOptions } from '../connectorApi/models'; +import type { AuthenticationConfiguration } from './authenticationConfiguration'; +import { AuthenticationConstants } from './authenticationConstants'; +import type { BotFrameworkAuthentication } from './botFrameworkAuthentication'; +import { GovernmentConstants } from './governmentConstants'; +import { ParameterizedBotFrameworkAuthentication } from './parameterizedBotFrameworkAuthentication'; +import type { ServiceClientCredentialsFactory } from './serviceClientCredentialsFactory'; +import { tests } from 'botbuilder-stdlib'; + +/** + * A factory for [BotFrameworkAuthentication](xref:botframework-connector.BotFrameworkAuthentication) which encapsulates the environment specific Bot Framework Protocol auth code. + */ +export class BotFrameworkAuthenticationFactory { + /** + * Creates a new [BotFrameworkAuthentication](xref:botframework-connector.BotFrameworkAuthentication) instance for anonymous testing scenarios. + * + * @returns A new [BotFrameworkAuthentication](xref:botframework-connector.BotFrameworkAuthentication) instance. + */ + static create(): BotFrameworkAuthentication; + + /** + * Creates the appropriate [BotFrameworkAuthentication](xref:botframework-connector.BotFrameworkAuthentication) instance. + * + * @param channelService The Channel Service. + * @param validateAuthority The validate authority value to use. + * @param toChannelFromBotLoginUrl The to Channel from bot login url. + * @param toChannelFromBotOAuthScope The to Channel from bot oauth scope. + * @param toBotFromChannelTokenIssuer The to bot from Channel Token Issuer. + * @param oAuthUrl The OAuth url. + * @param toBotFromChannelOpenIdMetadataUrl The to bot from Channel Open Id Metadata url. + * @param toBotFromEmulatorOpenIdMetadataUrl The to bot from Emulator Open Id Metadata url. + * @param callerId The callerId set on on authenticated [Activities](xref:botframework-schema.Activity). + * @param credentialFactory The [ServiceClientCredentialsFactory](xref:botframework-connector.ServiceClientCredentialsFactory) to use to create credentials. + * @param authConfiguration The [AuthenticationConfiguration](xref:botframework-connector.AuthenticationConfiguration) to use. + * @param botFrameworkClientFetch The fetch to use in BotFrameworkClient. + * @param connectorClientOptions The [ConnectorClientOptions](xref:botframework-connector.ConnectorClientOptions) to use when creating ConnectorClients. + */ + static create( + channelService: string, + validateAuthority: boolean, + toChannelFromBotLoginUrl: string, + toChannelFromBotOAuthScope: string, + toBotFromChannelTokenIssuer: string, + oAuthUrl: string, + toBotFromChannelOpenIdMetadataUrl: string, + toBotFromEmulatorOpenIdMetadataUrl: string, + callerId: string, + credentialFactory: ServiceClientCredentialsFactory, + authConfiguration: AuthenticationConfiguration, + botFrameworkClientFetch?: (input: RequestInfo, init?: RequestInit) => Promise, + connectorClientOptions?: ConnectorClientOptions + ): BotFrameworkAuthentication; + + static create( + maybeChannelService?: string, + maybeValidateAuthority?: boolean, + maybeToChannelFromBotLoginUrl?: string, + maybeToChannelFromBotOAuthScope?: string, + maybeToBotFromChannelTokenIssuer?: string, + maybeOAuthUrl?: string, + maybeToBotFromChannelOpenIdMetadataUrl?: string, + maybeToBotFromEmulatorOpenIdMetadataUrl?: string, + maybeCallerId?: string, + maybeCredentialFactory?: ServiceClientCredentialsFactory, + maybeAuthConfiguration?: AuthenticationConfiguration, + maybeBotFrameworkClientFetch?: (input: RequestInfo, init?: RequestInit) => Promise, + maybeConnectorClientOptions: ConnectorClientOptions = {} + ): BotFrameworkAuthentication { + if ( + !tests.isStringNullOrEmpty(maybeToChannelFromBotLoginUrl) || + !tests.isStringNullOrEmpty(maybeToChannelFromBotOAuthScope) || + !tests.isStringNullOrEmpty(maybeToBotFromChannelTokenIssuer) || + !tests.isStringNullOrEmpty(maybeOAuthUrl) || + !tests.isStringNullOrEmpty(maybeToBotFromChannelOpenIdMetadataUrl) || + !tests.isStringNullOrEmpty(maybeToBotFromEmulatorOpenIdMetadataUrl) || + !tests.isStringNullOrEmpty(maybeCallerId) + ) { + // If any of the 'parameterized' properties are defined, assume all parameters are intentional. + return new ParameterizedBotFrameworkAuthentication( + maybeValidateAuthority, + maybeToChannelFromBotLoginUrl, + maybeToChannelFromBotOAuthScope, + maybeToBotFromChannelTokenIssuer, + maybeOAuthUrl, + maybeToBotFromChannelOpenIdMetadataUrl, + maybeToBotFromEmulatorOpenIdMetadataUrl, + maybeCallerId, + maybeCredentialFactory, + maybeAuthConfiguration, + maybeBotFrameworkClientFetch, + maybeConnectorClientOptions + ); + } else { + // else apply the built in default behavior, which is either the public cloud or the gov cloud depending on whether we have a channelService value present + if (tests.isStringNullOrEmpty(maybeChannelService)) { + return new ParameterizedBotFrameworkAuthentication( + true, + AuthenticationConstants.ToChannelFromBotLoginUrl, + AuthenticationConstants.ToChannelFromBotOAuthScope, + AuthenticationConstants.ToBotFromChannelTokenIssuer, + AuthenticationConstants.OAuthUrl, + AuthenticationConstants.ToBotFromChannelOpenIdMetadataUrl, + AuthenticationConstants.ToBotFromEmulatorOpenIdMetadataUrl, + CallerIdConstants.PublicAzureChannel, + maybeCredentialFactory, + maybeAuthConfiguration, + maybeBotFrameworkClientFetch, + maybeConnectorClientOptions + ); + } else if (maybeChannelService === GovernmentConstants.ChannelService) { + return new ParameterizedBotFrameworkAuthentication( + true, + GovernmentConstants.ToChannelFromBotLoginUrl, + GovernmentConstants.ToChannelFromBotOAuthScope, + GovernmentConstants.ToBotFromChannelTokenIssuer, + GovernmentConstants.OAuthUrl, + GovernmentConstants.ToBotFromChannelOpenIdMetadataUrl, + GovernmentConstants.ToBotFromEmulatorOpenIdMetadataUrl, + CallerIdConstants.USGovChannel, + maybeCredentialFactory, + maybeAuthConfiguration, + maybeBotFrameworkClientFetch, + maybeConnectorClientOptions + ); + } else { + // The ChannelService value is used an indicator of which built in set of constants to use. If it is not recognized, a full configuration is expected. + throw new Error('The provided ChannelService value is not supported.'); + } + } + } +} diff --git a/libraries/botframework-connector/src/auth/botFrameworkClientImpl.ts b/libraries/botframework-connector/src/auth/botFrameworkClientImpl.ts new file mode 100644 index 0000000000..0f59b34bbd --- /dev/null +++ b/libraries/botframework-connector/src/auth/botFrameworkClientImpl.ts @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import axios from 'axios'; +import { Activity, ChannelAccount, InvokeResponse, RoleTypes } from 'botframework-schema'; +import { BotFrameworkClient } from '../skills'; +import { ServiceClientCredentialsFactory } from './serviceClientCredentialsFactory'; +import { USER_AGENT } from './connectorFactoryImpl'; +import { WebResource } from '@azure/ms-rest-js'; +import { assert } from 'botbuilder-stdlib'; + +const botFrameworkClientFetchImpl = async (input: RequestInfo, init?: RequestInit): Promise => { + const config = { + headers: init.headers as Record, + validateStatus: (): boolean => true, + }; + + assert.string(input, ['input']); + assert.string(init.body, ['init']); + const activity = JSON.parse(init.body) as Activity; + + const response = await axios.post(input, activity, config); + return { + status: response.status, + json: async () => response.data, + } as Response; +}; + +// Internal +export class BotFrameworkClientImpl implements BotFrameworkClient { + constructor( + private readonly credentialsFactory: ServiceClientCredentialsFactory, + private readonly loginEndpoint: string, + private readonly botFrameworkClientFetch: ( + input: RequestInfo, + init?: RequestInit + ) => Promise = botFrameworkClientFetchImpl + ) { + assert.maybeFunc(botFrameworkClientFetch, ['botFrameworkClientFetch']); + } + + async postActivity( + fromBotId: string, + toBotId: string, + toUrl: string, + serviceUrl: string, + conversationId: string, + activity: Activity + ): Promise> { + assert.maybeString(fromBotId, ['fromBotId']); + assert.maybeString(toBotId, ['toBotId']); + assert.string(toUrl, ['toUrl']); + assert.string(serviceUrl, ['serviceUrl']); + assert.string(conversationId, ['conversationId']); + assert.object(activity, ['activity']); + + const credentials = await this.credentialsFactory.createCredentials( + fromBotId, + toBotId, + this.loginEndpoint, + true + ); + + // Capture current activity settings before changing them. + // TODO: DO we need to set the activity ID? (events that are created manually don't have it). + const originalConversationId = activity.conversation.id; + const originalServiceUrl = activity.serviceUrl; + const originalRelatesTo = activity.relatesTo; + const originalRecipient = activity.recipient; + + try { + activity.relatesTo = { + serviceUrl: activity.serviceUrl, + activityId: activity.id, + channelId: activity.channelId, + conversation: { + id: activity.conversation.id, + name: activity.conversation.name, + conversationType: activity.conversation.conversationType, + aadObjectId: activity.conversation.aadObjectId, + isGroup: activity.conversation.isGroup, + properties: activity.conversation.properties, + role: activity.conversation.role, + tenantId: activity.conversation.tenantId, + }, + bot: null, + }; + activity.conversation.id = conversationId; + activity.serviceUrl = serviceUrl; + + // Fixes: https://github.com/microsoft/botframework-sdk/issues/5785 + if (!activity.recipient) { + activity.recipient = {} as ChannelAccount; + } + activity.recipient.role = RoleTypes.Skill; + + const webRequest = new WebResource(toUrl, 'POST', JSON.stringify(activity), undefined, { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': USER_AGENT, + }); + const request = await credentials.signRequest(webRequest); + + const config: RequestInit = { + body: request.body, + headers: request.headers.rawHeaders(), + }; + const response = await this.botFrameworkClientFetch(request.url, config); + + return { status: response.status, body: await response.json() }; + } finally { + // Restore activity properties. + activity.conversation.id = originalConversationId; + activity.serviceUrl = originalServiceUrl; + activity.relatesTo = originalRelatesTo; + activity.recipient = originalRecipient; + } + } +} diff --git a/libraries/botframework-connector/src/connectorFactory.ts b/libraries/botframework-connector/src/auth/connectorFactory.ts similarity index 90% rename from libraries/botframework-connector/src/connectorFactory.ts rename to libraries/botframework-connector/src/auth/connectorFactory.ts index 954cf1a7f1..b84f166b42 100644 --- a/libraries/botframework-connector/src/connectorFactory.ts +++ b/libraries/botframework-connector/src/auth/connectorFactory.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { ConnectorClient } from './connectorApi/connectorClient'; +import { ConnectorClient } from '../connectorApi/connectorClient'; export abstract class ConnectorFactory { /** diff --git a/libraries/botframework-connector/src/auth/connectorFactoryImpl.ts b/libraries/botframework-connector/src/auth/connectorFactoryImpl.ts new file mode 100644 index 0000000000..cd2183d2ab --- /dev/null +++ b/libraries/botframework-connector/src/auth/connectorFactoryImpl.ts @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { getDefaultUserAgentValue, userAgentPolicy } from '@azure/ms-rest-js'; +import { ConnectorClient } from '../connectorApi/connectorClient'; +import { ConnectorClientOptions } from '../connectorApi/models'; +import { ConnectorFactory } from './connectorFactory'; +import type { ServiceClientCredentialsFactory } from './serviceClientCredentialsFactory'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const packageInfo: Record<'name' | 'version', string> = require('../../package.json'); +export const USER_AGENT = `Microsoft-BotFramework/3.1 ${packageInfo.name}/${ + packageInfo.version +} ${getDefaultUserAgentValue()} `; + +// Internal +export class ConnectorFactoryImpl extends ConnectorFactory { + constructor( + private readonly appId: string, + private readonly toChannelFromBotOAuthScope: string, + private readonly loginEndpoint: string, + private readonly validateAuthority: boolean, + private readonly credentialFactory: ServiceClientCredentialsFactory, + private readonly connectorClientOptions: ConnectorClientOptions = {} + ) { + super(); + } + + async create(serviceUrl: string, audience: string): Promise { + // Use the credentials factory to create credentails specific to this particular cloud environment. + const credentials = await this.credentialFactory.createCredentials( + this.appId, + audience ?? this.toChannelFromBotOAuthScope, + this.loginEndpoint, + this.validateAuthority + ); + + // A new connector client for making calls against this serviceUrl using credentials derived from the current appId and the specified audience. + const options = this.getClientOptions(serviceUrl); + return new ConnectorClient(credentials, options); + } + + private getClientOptions(serviceUrl: string): ConnectorClientOptions { + const { requestPolicyFactories, ...clientOptions } = this.connectorClientOptions; + + const options: ConnectorClientOptions = Object.assign({}, { baseUri: serviceUrl }, clientOptions); + + const userAgent = typeof options.userAgent === 'function' ? options.userAgent(USER_AGENT) : options.userAgent; + const setUserAgent = userAgentPolicy({ + value: `${USER_AGENT}${userAgent ?? ''}`, + }); + + // Resolve any user request policy factories, then include our user agent via a factory policy + options.requestPolicyFactories = (defaultRequestPolicyFactories) => { + let defaultFactories = []; + + if (requestPolicyFactories) { + if (typeof requestPolicyFactories === 'function') { + const newDefaultFactories = requestPolicyFactories(defaultRequestPolicyFactories); + if (newDefaultFactories) { + defaultFactories = newDefaultFactories; + } + } else if (requestPolicyFactories) { + defaultFactories = [...requestPolicyFactories]; + } + + // If the user has supplied custom factories, allow them to optionally set user agent + // before we do. + defaultFactories = [...defaultFactories, setUserAgent]; + } else { + // In the case that there are no user supplied factories, inject our user agent as + // the first policy to ensure none of the default policies override it. + defaultFactories = [setUserAgent, ...defaultRequestPolicyFactories]; + } + + return defaultFactories; + }; + + return options; + } +} diff --git a/libraries/botframework-connector/src/auth/emulatorValidation.ts b/libraries/botframework-connector/src/auth/emulatorValidation.ts index d4e359711e..1fc541977d 100644 --- a/libraries/botframework-connector/src/auth/emulatorValidation.ts +++ b/libraries/botframework-connector/src/auth/emulatorValidation.ts @@ -8,7 +8,7 @@ /* eslint-disable @typescript-eslint/no-namespace */ -import { decode, VerifyOptions } from 'jsonwebtoken'; +import { decode } from 'jsonwebtoken'; import { ClaimsIdentity } from './claimsIdentity'; import { AuthenticationConstants } from './authenticationConstants'; import { AuthenticationConfiguration } from './authenticationConfiguration'; @@ -18,6 +18,7 @@ import { JwtTokenExtractor } from './jwtTokenExtractor'; import { JwtTokenValidation } from './jwtTokenValidation'; import { AuthenticationError } from './authenticationError'; import { StatusCodes } from 'botframework-schema'; +import { ToBotFromBotOrEmulatorTokenValidationParameters } from './tokenValidationParameters'; /** * Validates and Examines JWT tokens from the Bot Framework Emulator @@ -26,22 +27,7 @@ export namespace EmulatorValidation { /** * TO BOT FROM EMULATOR: Token validation parameters when connecting to a channel. */ - export const ToBotFromEmulatorTokenValidationParameters: VerifyOptions = { - issuer: [ - 'https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/', // Auth v3.1, 1.0 token - 'https://login.microsoftonline.com/d6d49420-f39b-4df7-a1dc-d59a935871db/v2.0', // Auth v3.1, 2.0 token - 'https://sts.windows.net/f8cdef31-a31e-4b4a-93e4-5f571e91255a/', // Auth v3.2, 1.0 token - 'https://login.microsoftonline.com/f8cdef31-a31e-4b4a-93e4-5f571e91255a/v2.0', // Auth v3.2, 2.0 token - 'https://sts.windows.net/72f988bf-86f1-41af-91ab-2d7cd011db47/', // ??? - 'https://sts.windows.net/cab8a31a-1906-4287-a0d8-4eef66b95f6e/', // US Gov Auth, 1.0 token - 'https://login.microsoftonline.us/cab8a31a-1906-4287-a0d8-4eef66b95f6e/v2.0', // US Gov Auth, 2.0 token - 'https://login.microsoftonline.us/f8cdef31-a31e-4b4a-93e4-5f571e91255a/', // Auth for US Gov, 1.0 token - 'https://login.microsoftonline.us/f8cdef31-a31e-4b4a-93e4-5f571e91255a/v2.0', // Auth for US Gov, 2.0 token - ], - audience: undefined, // Audience validation takes place manually in code. - clockTolerance: 5 * 60, - ignoreExpiration: false, - }; + export const ToBotFromEmulatorTokenValidationParameters = ToBotFromBotOrEmulatorTokenValidationParameters; /** * Determines if a given Auth header is from the Bot Framework Emulator diff --git a/libraries/botframework-connector/src/auth/index.ts b/libraries/botframework-connector/src/auth/index.ts index cc4ae2ef68..6706c3ff75 100644 --- a/libraries/botframework-connector/src/auth/index.ts +++ b/libraries/botframework-connector/src/auth/index.ts @@ -13,9 +13,11 @@ export * from './authenticationConstants'; export * from './authenticationError'; export * from './authenticateRequestResult'; export * from './botFrameworkAuthentication'; +export * from './botFrameworkAuthenticationFactory'; export * from './certificateAppCredentials'; export * from './channelValidation'; export * from './claimsIdentity'; +export * from './connectorFactory'; export * from './credentialProvider'; export * from './emulatorValidation'; export * from './endorsementsValidator'; @@ -24,6 +26,7 @@ export * from './governmentChannelValidation'; export * from './governmentConstants'; export * from './jwtTokenValidation'; export * from './microsoftAppCredentials'; +export * from './passwordServiceClientCredentialFactory'; export * from './skillValidation'; export * from './serviceClientCredentialsFactory'; export * from './userTokenClient'; diff --git a/libraries/botframework-connector/src/auth/microsoftAppCredentials.ts b/libraries/botframework-connector/src/auth/microsoftAppCredentials.ts index 0d3def0667..e357d4ea41 100644 --- a/libraries/botframework-connector/src/auth/microsoftAppCredentials.ts +++ b/libraries/botframework-connector/src/auth/microsoftAppCredentials.ts @@ -23,6 +23,11 @@ function isErrorResponse(value: unknown): value is adal.ErrorResponse { * MicrosoftAppCredentials auth implementation */ export class MicrosoftAppCredentials extends AppCredentials { + /** + * An empty set of credentials. + */ + public static readonly Empty = new MicrosoftAppCredentials(null, null); + /** * Initializes a new instance of the [MicrosoftAppCredentials](xref:botframework-connector.MicrosoftAppCredentials) class. * diff --git a/libraries/botframework-connector/src/auth/parameterizedBotFrameworkAuthentication.ts b/libraries/botframework-connector/src/auth/parameterizedBotFrameworkAuthentication.ts new file mode 100644 index 0000000000..714fc90176 --- /dev/null +++ b/libraries/botframework-connector/src/auth/parameterizedBotFrameworkAuthentication.ts @@ -0,0 +1,458 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Activity, Channels, RoleTypes, StatusCodes } from 'botframework-schema'; +import { AuthenticateRequestResult } from './authenticateRequestResult'; +import type { AuthenticationConfiguration } from './authenticationConfiguration'; +import { AuthenticationConstants } from './authenticationConstants'; +import { AuthenticationError } from './authenticationError'; +import { BotFrameworkAuthentication } from './botFrameworkAuthentication'; +import { ConnectorClientOptions } from '../connectorApi/models'; +import type { ConnectorFactory } from './connectorFactory'; +import { ConnectorFactoryImpl } from './connectorFactoryImpl'; +import type { BotFrameworkClient } from '../skills'; +import { BotFrameworkClientImpl } from './botFrameworkClientImpl'; +import { Claim, ClaimsIdentity } from './claimsIdentity'; +import { EmulatorValidation } from './emulatorValidation'; +import { JwtTokenExtractor } from './jwtTokenExtractor'; +import { JwtTokenValidation } from './jwtTokenValidation'; +import type { ServiceClientCredentialsFactory } from './serviceClientCredentialsFactory'; +import { SkillValidation } from './skillValidation'; +import { ToBotFromBotOrEmulatorTokenValidationParameters } from './tokenValidationParameters'; +import { UserTokenClientImpl } from './userTokenClientImpl'; +import type { UserTokenClient } from './userTokenClient'; +import { VerifyOptions } from 'jsonwebtoken'; + +function getAppId(claimsIdentity: ClaimsIdentity): string | null { + // For requests from channel App Id is in Audience claim of JWT token. For emulator it is in AppId claim. For + // unauthenticated requests we have anonymous claimsIdentity provided auth is disabled. + // For Activities coming from Emulator AppId claim contains the Bot's AAD AppId. + return ( + claimsIdentity.getClaimValue(AuthenticationConstants.AudienceClaim) ?? + claimsIdentity.getClaimValue(AuthenticationConstants.AppIdClaim) + ); +} + +// Internal +export class ParameterizedBotFrameworkAuthentication extends BotFrameworkAuthentication { + constructor( + private readonly validateAuthority: boolean, + private readonly toChannelFromBotLoginUrl: string, + private readonly toChannelFromBotOAuthScope: string, + private readonly toBotFromChannelTokenIssuer: string, + private readonly oAuthUrl: string, + private readonly toBotFromChannelOpenIdMetadataUrl: string, + private readonly toBotFromEmulatorOpenIdMetadataUrl: string, + private readonly callerId: string, + private readonly credentialsFactory: ServiceClientCredentialsFactory, + private readonly authConfiguration: AuthenticationConfiguration, + private readonly botFrameworkClientFetch?: (input: RequestInfo, init?: RequestInit) => Promise, + private readonly connectorClientOptions: ConnectorClientOptions = {} + ) { + super(); + } + + getOriginatingAudience(): string { + return this.toChannelFromBotOAuthScope; + } + + async authenticateChannelRequest(authHeader: string): Promise { + return this.JwtTokenValidation_validateAuthHeader(authHeader, 'unknown', null); + } + + async authenticateRequest(activity: Activity, authHeader: string): Promise { + const claimsIdentity = await this.JwtTokenValidation_authenticateRequest(activity, authHeader); + + const outboundAudience = SkillValidation.isSkillClaim(claimsIdentity.claims) + ? JwtTokenValidation.getAppIdFromClaims(claimsIdentity.claims) + : this.toChannelFromBotOAuthScope; + + const callerId = await this.generateCallerId(this.credentialsFactory, claimsIdentity, this.callerId); + + const connectorFactory = new ConnectorFactoryImpl( + getAppId(claimsIdentity), + this.toChannelFromBotOAuthScope, + this.toChannelFromBotLoginUrl, + this.validateAuthority, + this.credentialsFactory + ); + + return { + audience: outboundAudience, + callerId, + claimsIdentity, + connectorFactory, + }; + } + + async authenticateStreamingRequest( + authHeader: string, + channelIdHeader: string + ): Promise { + if ( + (typeof channelIdHeader !== 'string' || channelIdHeader.trim() === '') && + !(await this.credentialsFactory.isAuthenticationDisabled()) + ) { + throw new AuthenticationError("'authHeader' required.", StatusCodes.BAD_REQUEST); + } + + const claimsIdentity = await this.JwtTokenValidation_validateAuthHeader(authHeader, channelIdHeader, null); + const outboundAudience = SkillValidation.isSkillClaim(claimsIdentity.claims) + ? JwtTokenValidation.getAppIdFromClaims(claimsIdentity.claims) + : this.toChannelFromBotOAuthScope; + const callerId = await this.generateCallerId(this.credentialsFactory, claimsIdentity, this.callerId); + + return { audience: outboundAudience, callerId, claimsIdentity }; + } + + async createUserTokenClient(claimsIdentity: ClaimsIdentity): Promise { + const appId = getAppId(claimsIdentity); + const credentials = await this.credentialsFactory.createCredentials( + appId, + this.toChannelFromBotOAuthScope, + this.toChannelFromBotLoginUrl, + this.validateAuthority + ); + + return new UserTokenClientImpl(appId, credentials, this.oAuthUrl, this.connectorClientOptions); + } + + createConnectorFactory(claimsIdentity: ClaimsIdentity): ConnectorFactory { + return new ConnectorFactoryImpl( + getAppId(claimsIdentity), + this.toChannelFromBotOAuthScope, + this.toChannelFromBotLoginUrl, + this.validateAuthority, + this.credentialsFactory + ); + } + + createBotFrameworkClient(): BotFrameworkClient { + return new BotFrameworkClientImpl( + this.credentialsFactory, + this.toChannelFromBotLoginUrl, + this.botFrameworkClientFetch + ); + } + + private async JwtTokenValidation_authenticateRequest( + activity: Partial, + authHeader: string + ): Promise { + if (!authHeader.trim()) { + const isAuthDisabled = await this.credentialsFactory.isAuthenticationDisabled(); + if (!isAuthDisabled) { + throw new AuthenticationError( + 'Unauthorized Access. Request is not authorized', + StatusCodes.UNAUTHORIZED + ); + } + + // Check if the activity is for a skill call and is coming from the Emulator. + if ( + activity.channelId === Channels.Emulator && + activity.recipient && + activity.recipient.role === RoleTypes.Skill + ) { + return SkillValidation.createAnonymousSkillClaim(); + } + + // In the scenario where Auth is disabled, we still want to have the + // IsAuthenticated flag set in the ClaimsIdentity. To do this requires + // adding in an empty claim. + return new ClaimsIdentity([], AuthenticationConstants.AnonymousAuthType); + } + + const claimsIdentity: ClaimsIdentity = await this.JwtTokenValidation_validateAuthHeader( + authHeader, + activity.channelId, + activity.serviceUrl + ); + + return claimsIdentity; + } + + private async JwtTokenValidation_validateAuthHeader( + authHeader: string, + channelId: string, + serviceUrl = '' + ): Promise { + if (!authHeader.trim()) { + throw new AuthenticationError("'authHeader' required.", StatusCodes.BAD_REQUEST); + } + + const identity = await this.JwtTokenValidation_authenticateToken(authHeader, channelId, serviceUrl); + + await this.JwtTokenValidation_validateClaims(identity.claims); + + return identity; + } + + private async JwtTokenValidation_validateClaims(claims: Claim[] = []): Promise { + if (this.authConfiguration.validateClaims) { + // Call the validation method if defined (it should throw an exception if the validation fails) + await this.authConfiguration.validateClaims(claims); + } else if (SkillValidation.isSkillClaim(claims)) { + // Skill claims must be validated using AuthenticationConfiguration validateClaims + throw new AuthenticationError( + 'Unauthorized Access. Request is not authorized. Skill Claims require validation.', + StatusCodes.UNAUTHORIZED + ); + } + } + + private async JwtTokenValidation_authenticateToken( + authHeader: string, + channelId: string, + serviceUrl: string + ): Promise { + if (SkillValidation.isSkillToken(authHeader)) { + return this.SkillValidation_authenticateChannelToken(authHeader, channelId); + } + + if (EmulatorValidation.isTokenFromEmulator(authHeader)) { + return this.EmulatorValidation_authenticateEmulatorToken(authHeader, channelId); + } + + // Handle requests from BotFramework Channels + return this.ChannelValidation_authenticateChannelToken(authHeader, serviceUrl, channelId); + } + + private async SkillValidation_authenticateChannelToken( + authHeader: string, + channelId: string + ): Promise { + const tokenExtractor = new JwtTokenExtractor( + ToBotFromBotOrEmulatorTokenValidationParameters, + this.toBotFromEmulatorOpenIdMetadataUrl, + AuthenticationConstants.AllowedSigningAlgorithms + ); + + const parts: string[] = authHeader.split(' '); + const identity = await tokenExtractor.getIdentity( + parts[0], + parts[1], + channelId, + this.authConfiguration.requiredEndorsements + ); + + await this.SkillValidation_ValidateIdentity(identity); + + return identity; + } + + private async SkillValidation_ValidateIdentity(identity: ClaimsIdentity): Promise { + if (!identity) { + // No valid identity. Not Authorized. + throw new AuthenticationError( + 'SkillValidation.validateIdentity(): Invalid identity', + StatusCodes.UNAUTHORIZED + ); + } + + if (!identity.isAuthenticated) { + // The token is in some way invalid. Not Authorized. + throw new AuthenticationError( + 'SkillValidation.validateIdentity(): Token not authenticated', + StatusCodes.UNAUTHORIZED + ); + } + + const versionClaim = identity.getClaimValue(AuthenticationConstants.VersionClaim); + if (!versionClaim) { + // No version claim + throw new AuthenticationError( + `SkillValidation.validateIdentity(): '${AuthenticationConstants.VersionClaim}' claim is required on skill Tokens.`, + StatusCodes.UNAUTHORIZED + ); + } + + // Look for the "aud" claim, but only if issued from the Bot Framework + const audienceClaim = identity.getClaimValue(AuthenticationConstants.AudienceClaim); + if (!audienceClaim) { + // Claim is not present or doesn't have a value. Not Authorized. + throw new AuthenticationError( + `SkillValidation.validateIdentity(): '${AuthenticationConstants.AudienceClaim}' claim is required on skill Tokens.`, + StatusCodes.UNAUTHORIZED + ); + } + + if (!(await this.credentialsFactory.isValidAppId(audienceClaim))) { + // The AppId is not valid. Not Authorized. + throw new AuthenticationError( + 'SkillValidation.validateIdentity(): Invalid audience.', + StatusCodes.UNAUTHORIZED + ); + } + + const appId = JwtTokenValidation.getAppIdFromClaims(identity.claims); + if (!appId) { + // Invalid appId + throw new AuthenticationError( + 'SkillValidation.validateIdentity(): Invalid appId.', + StatusCodes.UNAUTHORIZED + ); + } + } + + private async EmulatorValidation_authenticateEmulatorToken( + authHeader: string, + channelId: string + ): Promise { + const tokenExtractor: JwtTokenExtractor = new JwtTokenExtractor( + ToBotFromBotOrEmulatorTokenValidationParameters, + this.toBotFromEmulatorOpenIdMetadataUrl, + AuthenticationConstants.AllowedSigningAlgorithms + ); + + const identity: ClaimsIdentity = await tokenExtractor.getIdentityFromAuthHeader( + authHeader, + channelId, + this.authConfiguration.requiredEndorsements + ); + if (!identity) { + // No valid identity. Not Authorized. + throw new AuthenticationError('Unauthorized. No valid identity.', StatusCodes.UNAUTHORIZED); + } + + if (!identity.isAuthenticated) { + // The token is in some way invalid. Not Authorized. + throw new AuthenticationError('Unauthorized. Is not authenticated', StatusCodes.UNAUTHORIZED); + } + + // Now check that the AppID in the claimset matches + // what we're looking for. Note that in a multi-tenant bot, this value + // comes from developer code that may be reaching out to a service, hence the + // Async validation. + const versionClaim: string = identity.getClaimValue(AuthenticationConstants.VersionClaim); + if (versionClaim === null) { + throw new AuthenticationError( + 'Unauthorized. "ver" claim is required on Emulator Tokens.', + StatusCodes.UNAUTHORIZED + ); + } + + let appId = ''; + + // The Emulator, depending on Version, sends the AppId via either the + // appid claim (Version 1) or the Authorized Party claim (Version 2). + if (!versionClaim || versionClaim === '1.0') { + // either no Version or a version of "1.0" means we should look for + // the claim in the "appid" claim. + const appIdClaim: string = identity.getClaimValue(AuthenticationConstants.AppIdClaim); + if (!appIdClaim) { + // No claim around AppID. Not Authorized. + throw new AuthenticationError( + 'Unauthorized. "appid" claim is required on Emulator Token version "1.0".', + StatusCodes.UNAUTHORIZED + ); + } + + appId = appIdClaim; + } else if (versionClaim === '2.0') { + // Emulator, "2.0" puts the AppId in the "azp" claim. + const appZClaim: string = identity.getClaimValue(AuthenticationConstants.AuthorizedParty); + if (!appZClaim) { + // No claim around AppID. Not Authorized. + throw new AuthenticationError( + 'Unauthorized. "azp" claim is required on Emulator Token version "2.0".', + StatusCodes.UNAUTHORIZED + ); + } + + appId = appZClaim; + } else { + // Unknown Version. Not Authorized. + throw new AuthenticationError( + `Unauthorized. Unknown Emulator Token version "${versionClaim}".`, + StatusCodes.UNAUTHORIZED + ); + } + + if (!(await this.credentialsFactory.isValidAppId(appId))) { + throw new AuthenticationError( + `Unauthorized. Invalid AppId passed on token: ${appId}`, + StatusCodes.UNAUTHORIZED + ); + } + + return identity; + } + + private async ChannelValidation_authenticateChannelToken( + authHeader: string, + serviceUrl: string, + channelId: string + ): Promise { + const tokenValidationParameters = this.ChannelValidation_GetTokenValidationParameters(); + const tokenExtractor: JwtTokenExtractor = new JwtTokenExtractor( + tokenValidationParameters, + this.toBotFromChannelOpenIdMetadataUrl, + AuthenticationConstants.AllowedSigningAlgorithms + ); + + const identity: ClaimsIdentity = await tokenExtractor.getIdentityFromAuthHeader( + authHeader, + channelId, + this.authConfiguration.requiredEndorsements + ); + + return this.governmentChannelValidation_ValidateIdentity(identity, serviceUrl); + } + + private ChannelValidation_GetTokenValidationParameters(): VerifyOptions { + return { + issuer: [this.toBotFromChannelTokenIssuer], + audience: undefined, // Audience validation takes place manually in code. + clockTolerance: 5 * 60, + ignoreExpiration: false, + }; + } + + private async governmentChannelValidation_ValidateIdentity( + identity: ClaimsIdentity, + serviceUrl: string + ): Promise { + if (!identity) { + // No valid identity. Not Authorized. + throw new AuthenticationError('Unauthorized. No valid identity.', StatusCodes.UNAUTHORIZED); + } + + if (!identity.isAuthenticated) { + // The token is in some way invalid. Not Authorized. + throw new AuthenticationError('Unauthorized. Is not authenticated', StatusCodes.UNAUTHORIZED); + } + + // Now check that the AppID in the claimset matches + // what we're looking for. Note that in a multi-tenant bot, this value + // comes from developer code that may be reaching out to a service, hence the + // Async validation. + + // Look for the "aud" claim, but only if issued from the Bot Framework + if (identity.getClaimValue(AuthenticationConstants.IssuerClaim) !== this.toBotFromChannelTokenIssuer) { + // The relevant Audiance Claim MUST be present. Not Authorized. + throw new AuthenticationError('Unauthorized. Issuer Claim MUST be present.', StatusCodes.UNAUTHORIZED); + } + + // The AppId from the claim in the token must match the AppId specified by the developer. + // In this case, the token is destined for the app, so we find the app ID in the audience claim. + const audClaim: string = identity.getClaimValue(AuthenticationConstants.AudienceClaim); + if (!(await this.credentialsFactory.isValidAppId(audClaim || ''))) { + // The AppId is not valid or not present. Not Authorized. + throw new AuthenticationError( + `Unauthorized. Invalid AppId passed on token: ${audClaim}`, + StatusCodes.UNAUTHORIZED + ); + } + + if (serviceUrl) { + const serviceUrlClaim = identity.getClaimValue(AuthenticationConstants.ServiceUrlClaim); + if (serviceUrlClaim !== serviceUrl) { + // Claim must match. Not Authorized. + throw new AuthenticationError('Unauthorized. ServiceUrl claim do not match.', StatusCodes.UNAUTHORIZED); + } + } + + return identity; + } +} diff --git a/libraries/botframework-connector/src/auth/passwordServiceClientCredentialFactory.ts b/libraries/botframework-connector/src/auth/passwordServiceClientCredentialFactory.ts new file mode 100644 index 0000000000..9038f3fa65 --- /dev/null +++ b/libraries/botframework-connector/src/auth/passwordServiceClientCredentialFactory.ts @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as adal from 'adal-node'; +import type { ServiceClientCredentials } from '@azure/ms-rest-js'; +import { tests } from 'botbuilder-stdlib'; +import { MicrosoftAppCredentials } from './microsoftAppCredentials'; +import { ServiceClientCredentialsFactory } from './serviceClientCredentialsFactory'; +import { AuthenticationConstants } from './authenticationConstants'; +import { GovernmentConstants } from './governmentConstants'; + +/** + * A simple implementation of the [ServiceClientCredentialsFactory](xref:botframework-connector.ServiceClientCredentialsFactory) interface. + */ +export class PasswordServiceClientCredentialFactory implements ServiceClientCredentialsFactory { + /** + * The app ID for this credential. + */ + appId: string | null; + + /** + * The app password for this credential. + */ + password: string | null; + + constructor(appId: string, password: string) { + this.appId = appId; + this.password = password; + } + + async isValidAppId(appId: string): Promise { + return appId === this.appId; + } + + async isAuthenticationDisabled(): Promise { + return tests.isStringNullOrEmpty(this.appId); + } + + async createCredentials( + appId: string, + audience: string, + loginEndpoint: string, + validateAuthority: boolean + ): Promise { + if (!(await this.isValidAppId(appId))) { + throw new Error('appId did not match'); + } + + let credentials: MicrosoftAppCredentials; + let normalizedEndpoint = loginEndpoint?.toLowerCase(); + if (normalizedEndpoint?.startsWith(AuthenticationConstants.ToChannelFromBotLoginUrlPrefix)) { + credentials = + this.appId == null + ? MicrosoftAppCredentials.Empty + : new MicrosoftAppCredentials(this.appId, this.password, undefined, audience); + } else if (normalizedEndpoint == GovernmentConstants.ToChannelFromBotLoginUrl) { + credentials = + this.appId == null + ? new MicrosoftAppCredentials( + undefined, + undefined, + undefined, + GovernmentConstants.ToChannelFromBotOAuthScope + ) + : new MicrosoftAppCredentials(this.appId, this.password, undefined, audience); + normalizedEndpoint = loginEndpoint; + } else { + credentials = + this.appId == null + ? new PrivateCloudAppCredentials( + undefined, + undefined, + undefined, + normalizedEndpoint, + validateAuthority + ) + : new PrivateCloudAppCredentials( + this.appId, + this.password, + audience, + normalizedEndpoint, + validateAuthority + ); + } + credentials.oAuthEndpoint = normalizedEndpoint; + return credentials; + } +} + +class PrivateCloudAppCredentials extends MicrosoftAppCredentials { + private readonly _validateAuthority: boolean; + private __oAuthEndpoint: string; + + constructor( + appId: string, + password: string, + oAuthScope: string, + oAuthEndpoint: string, + validateAuthority: boolean + ) { + super(appId, password, undefined, oAuthScope); + this.oAuthEndpoint = oAuthEndpoint; + this._validateAuthority = validateAuthority; + } + + /** + * Gets a value indicating whether to validate the Authority. + * + * @returns The ValidateAuthority value to use. + */ + get validateAuthority(): boolean { + return this._validateAuthority; + } + + /** + * Gets the OAuth endpoint to use. + * + * @returns The OAuthEndpoint to use. + */ + public get oAuthEndpoint(): string { + return this.__oAuthEndpoint; + } + + /** + * Sets the OAuth endpoint to use. + */ + public set oAuthEndpoint(value: string) { + // aadApiVersion is set to '1.5' to avoid the "spn:" concatenation on the audience claim + // For more info, see https://github.com/AzureAD/azure-activedirectory-library-for-nodejs/issues/128 + this.__oAuthEndpoint = value; + this.authenticationContext = new adal.AuthenticationContext(value, this.validateAuthority, undefined, '1.5'); + } +} diff --git a/libraries/botframework-connector/src/auth/skillValidation.ts b/libraries/botframework-connector/src/auth/skillValidation.ts index 1c7728b3fe..7c15e00738 100644 --- a/libraries/botframework-connector/src/auth/skillValidation.ts +++ b/libraries/botframework-connector/src/auth/skillValidation.ts @@ -17,27 +17,8 @@ import { ICredentialProvider } from './credentialProvider'; import { JwtTokenExtractor } from './jwtTokenExtractor'; import { JwtTokenValidation } from './jwtTokenValidation'; import { StatusCodes } from 'botframework-schema'; -import { decode, VerifyOptions } from 'jsonwebtoken'; - -/** - * TO SKILL FROM BOT and TO BOT FROM SKILL: Token validation parameters when connecting a bot to a skill. - */ -const verifyOptions: VerifyOptions = { - issuer: [ - 'https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/', // Auth v3.1, 1.0 token - 'https://login.microsoftonline.com/d6d49420-f39b-4df7-a1dc-d59a935871db/v2.0', // Auth v3.1, 2.0 token - 'https://sts.windows.net/f8cdef31-a31e-4b4a-93e4-5f571e91255a/', // Auth v3.2, 1.0 token - 'https://login.microsoftonline.com/f8cdef31-a31e-4b4a-93e4-5f571e91255a/v2.0', // Auth v3.2, 2.0 token - 'https://sts.windows.net/72f988bf-86f1-41af-91ab-2d7cd011db47/', // ??? - 'https://sts.windows.net/cab8a31a-1906-4287-a0d8-4eef66b95f6e/', // US Gov Auth, 1.0 token - 'https://login.microsoftonline.us/cab8a31a-1906-4287-a0d8-4eef66b95f6e/v2.0', // US Gov Auth, 2.0 token - 'https://login.microsoftonline.us/f8cdef31-a31e-4b4a-93e4-5f571e91255a/', // Auth for US Gov, 1.0 token - 'https://login.microsoftonline.us/f8cdef31-a31e-4b4a-93e4-5f571e91255a/v2.0', // Auth for US Gov, 2.0 token - ], - audience: undefined, // Audience validation takes place manually in code. - clockTolerance: 5 * 60, - ignoreExpiration: false, -}; +import { ToBotFromBotOrEmulatorTokenValidationParameters } from './tokenValidationParameters'; +import { decode } from 'jsonwebtoken'; /** * Validates JWT tokens sent to and from a Skill. @@ -156,7 +137,7 @@ export namespace SkillValidation { : AuthenticationConstants.ToBotFromEmulatorOpenIdMetadataUrl; const tokenExtractor = new JwtTokenExtractor( - verifyOptions, + ToBotFromBotOrEmulatorTokenValidationParameters, openIdMetadataUrl, AuthenticationConstants.AllowedSigningAlgorithms ); diff --git a/libraries/botframework-connector/src/auth/tokenValidationParameters.ts b/libraries/botframework-connector/src/auth/tokenValidationParameters.ts new file mode 100644 index 0000000000..f0bd53b89f --- /dev/null +++ b/libraries/botframework-connector/src/auth/tokenValidationParameters.ts @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { VerifyOptions } from 'jsonwebtoken'; + +// Internal +/** + * Default options for validating incoming tokens from the Bot Framework Emulator and Skills. + */ +export const ToBotFromBotOrEmulatorTokenValidationParameters: VerifyOptions = { + issuer: [ + 'https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/', // Auth v3.1, 1.0 token + 'https://login.microsoftonline.com/d6d49420-f39b-4df7-a1dc-d59a935871db/v2.0', // Auth v3.1, 2.0 token + 'https://sts.windows.net/f8cdef31-a31e-4b4a-93e4-5f571e91255a/', // Auth v3.2, 1.0 token + 'https://login.microsoftonline.com/f8cdef31-a31e-4b4a-93e4-5f571e91255a/v2.0', // Auth v3.2, 2.0 token + 'https://sts.windows.net/72f988bf-86f1-41af-91ab-2d7cd011db47/', // ??? + 'https://sts.windows.net/cab8a31a-1906-4287-a0d8-4eef66b95f6e/', // US Gov Auth, 1.0 token + 'https://login.microsoftonline.us/cab8a31a-1906-4287-a0d8-4eef66b95f6e/v2.0', // US Gov Auth, 2.0 token + 'https://login.microsoftonline.us/f8cdef31-a31e-4b4a-93e4-5f571e91255a/', // Auth for US Gov, 1.0 token + 'https://login.microsoftonline.us/f8cdef31-a31e-4b4a-93e4-5f571e91255a/v2.0', // Auth for US Gov, 2.0 token + ], + audience: undefined, // Audience validation takes place manually in code. + clockTolerance: 5 * 60, + ignoreExpiration: false, +}; diff --git a/libraries/botframework-connector/src/auth/userTokenClientImpl.ts b/libraries/botframework-connector/src/auth/userTokenClientImpl.ts index 1f5f0ba63d..c669092be9 100644 --- a/libraries/botframework-connector/src/auth/userTokenClientImpl.ts +++ b/libraries/botframework-connector/src/auth/userTokenClientImpl.ts @@ -2,17 +2,26 @@ // Licensed under the MIT License. import { Activity, SignInUrlResponse, TokenExchangeRequest, TokenResponse, TokenStatus } from 'botframework-schema'; -import { ServiceClientCredentials } from '@azure/ms-rest-js'; +import type { ServiceClientCredentials } from '@azure/ms-rest-js'; import { TokenApiClient } from '../tokenApi/tokenApiClient'; import { UserTokenClient } from './userTokenClient'; import { assert } from 'botbuilder-stdlib'; +import { ConnectorClientOptions } from '../connectorApi/models'; // Internal export class UserTokenClientImpl extends UserTokenClient { private readonly client: TokenApiClient; - constructor(private readonly appId: string, credentials: ServiceClientCredentials, oauthEndpoint: string) { + constructor( + private readonly appId: string, + credentials: ServiceClientCredentials, + oauthEndpoint: string, + connectorClientOptions: ConnectorClientOptions = {} + ) { super(); - this.client = new TokenApiClient(credentials, { baseUri: oauthEndpoint }); + this.client = new TokenApiClient( + credentials, + Object.assign({ baseUri: oauthEndpoint }, connectorClientOptions) + ); } async getUserToken( @@ -57,7 +66,10 @@ export class UserTokenClientImpl extends UserTokenClient { assert.string(userId, ['userId']); assert.string(channelId, ['channelId']); - const result = await this.client.userToken.getTokenStatus(userId, { channelId, include: includeFilter }); + const result = await this.client.userToken.getTokenStatus(userId, { + channelId, + include: includeFilter, + }); return result._response.parsedBody; } diff --git a/libraries/botframework-connector/tests/botFrameworkAuthenticationFactory.test.js b/libraries/botframework-connector/tests/botFrameworkAuthenticationFactory.test.js new file mode 100644 index 0000000000..9ab6201a96 --- /dev/null +++ b/libraries/botframework-connector/tests/botFrameworkAuthenticationFactory.test.js @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +const assert = require('assert'); +const { + AuthenticationConstants, + BotFrameworkAuthentication, + BotFrameworkAuthenticationFactory, + GovernmentConstants, +} = require('../'); + +describe('BotFrameworkAuthenticationFactory', function () { + it('should create anonymous BotFrameworkAuthentication', function () { + const bfA = BotFrameworkAuthenticationFactory.create(); + assert(bfA instanceof BotFrameworkAuthentication); + }); + + it('should create BotFrameworkAuthentication configured for valid channel services', function () { + const bfA = BotFrameworkAuthenticationFactory.create(''); + assert.strictEqual(bfA.getOriginatingAudience(), AuthenticationConstants.ToChannelFromBotOAuthScope); + + const gBfA = BotFrameworkAuthenticationFactory.create(GovernmentConstants.ChannelService); + assert.strictEqual(gBfA.getOriginatingAudience(), GovernmentConstants.ToChannelFromBotOAuthScope); + }); + + it('should throw with an unknown channel service', function () { + assert.throws( + () => BotFrameworkAuthenticationFactory.create('unknown'), + new Error('The provided ChannelService value is not supported.') + ); + }); +}); diff --git a/libraries/botframework-connector/tests/passwordServiceClientCredentialFactory.test.js b/libraries/botframework-connector/tests/passwordServiceClientCredentialFactory.test.js new file mode 100644 index 0000000000..f2f6b2a1a5 --- /dev/null +++ b/libraries/botframework-connector/tests/passwordServiceClientCredentialFactory.test.js @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +const { AuthenticationConstants, PasswordServiceClientCredentialFactory, GovernmentConstants } = require('../'); +const assert = require('assert'); + +const APP_ID = '2cd87869-38a0-4182-9251-d056e8f0ac24'; +const APP_PASSWORD = 'password'; + +describe('PasswordServiceClientCredentialFactory', function () { + it('should set appId and password during construction', function () { + const credFactory = new PasswordServiceClientCredentialFactory(APP_ID, APP_PASSWORD); + assert.strictEqual(credFactory.appId, APP_ID); + assert.strictEqual(credFactory.password, APP_PASSWORD); + }); + + it('isValidAppId() should work', async function () { + const credFactory = new PasswordServiceClientCredentialFactory(APP_ID, APP_PASSWORD); + assert(await credFactory.isValidAppId(APP_ID)); + assert(!(await credFactory.isValidAppId('invalid-app-id'))); + }); + + it('isAuthenticationDisabled() should work', async function () { + const credFactory = new PasswordServiceClientCredentialFactory(APP_ID, APP_PASSWORD); + assert(!(await credFactory.isAuthenticationDisabled())); + credFactory.appId = null; + assert(await credFactory.isAuthenticationDisabled()); + }); + + it('createCredentials() should throw with an invalid appId', async function () { + const credFactory = new PasswordServiceClientCredentialFactory(APP_ID, APP_PASSWORD); + await assert.rejects(() => credFactory.createCredentials('invalid-app-id'), new Error('appId did not match')); + }); + + it('createCredentials() should work', async function () { + const credFactory = new PasswordServiceClientCredentialFactory(APP_ID, APP_PASSWORD); + const testArgs = [ + [ + APP_ID, + AuthenticationConstants.ToChannelFromBotOAuthScope, + AuthenticationConstants.ToChannelFromBotLoginUrlPrefix + + AuthenticationConstants.DefaultChannelAuthTenant, + ], + [ + APP_ID, + AuthenticationConstants.ToChannelFromBotOAuthScope, + AuthenticationConstants.ToChannelFromBotLoginUrlPrefix + + AuthenticationConstants.DefaultChannelAuthTenant, + ], + [APP_ID, GovernmentConstants.ToChannelFromBotOAuthScope, GovernmentConstants.ToChannelFromBotLoginUrl], + [APP_ID, 'CustomAudience', 'https://custom.login-endpoint.com/custom-tenant'], + ]; + + const credentials = await Promise.all( + testArgs.map((args) => credFactory.createCredentials(args[0], args[1], args[2])) + ); + credentials.forEach((cred, idx) => { + // The PasswordServiceClientCredentialFactory generates subclasses of the AppCredentials class. + assert.strictEqual(cred.appId, APP_ID); + assert.strictEqual(cred.oAuthScope, testArgs[idx][1]); + assert.strictEqual(cred.oAuthEndpoint, testArgs[idx][2].toLowerCase()); + }); + }); +}); diff --git a/libraries/botframework-schema/src/index.ts b/libraries/botframework-schema/src/index.ts index 1a869034eb..18cb0c8b16 100644 --- a/libraries/botframework-schema/src/index.ts +++ b/libraries/botframework-schema/src/index.ts @@ -2127,7 +2127,7 @@ export enum SemanticActionStateTypes { /** * Defines values for ChannelIds for Channels. - * Possible values include: 'console', 'cortana', 'directline', 'directlinespeech', 'email', + * Possible values include: 'alexa', 'console', 'cortana', 'directline', 'directlinespeech', 'email', * 'emulator', 'facebook', 'groupme', 'kik', 'line', 'msteams', 'skype', 'skypeforbusiness', * 'slack', 'sms', 'telegram', 'test', 'twilio-sms', 'webchat' * @@ -2135,6 +2135,7 @@ export enum SemanticActionStateTypes { * @enum {string} */ export enum Channels { + Alexa = 'alexa', Console = 'console', Cortana = 'cortana', Directline = 'directline',