From 21e7caa0509c501e89662561b608e93ce62d90fd Mon Sep 17 00:00:00 2001 From: fangyangci <133664123+fangyangci@users.noreply.github.com> Date: Thu, 21 Dec 2023 22:56:10 +0800 Subject: [PATCH] feat: Add ASE channel validation. (#4589) * aseChannelValidation * fix usgov single tenant * fix js lint * fix js lint --- ...configurationBotFrameworkAuthentication.ts | 12 ++ .../src/auth/aseChannelValidation.ts | 164 ++++++++++++++++++ .../botframework-connector/src/auth/index.ts | 2 + .../src/auth/jwtTokenValidation.ts | 9 +- ...parameterizedBotFrameworkAuthentication.ts | 5 + .../src/auth/tokenValidationParameters.ts | 11 ++ .../tests/auth/aseChannelValidation.test.js | 67 +++++++ 7 files changed, 267 insertions(+), 3 deletions(-) create mode 100644 libraries/botframework-connector/src/auth/aseChannelValidation.ts create mode 100644 libraries/botframework-connector/tests/auth/aseChannelValidation.test.js diff --git a/libraries/botbuilder-core/src/configurationBotFrameworkAuthentication.ts b/libraries/botbuilder-core/src/configurationBotFrameworkAuthentication.ts index b13b4174f5..f80d6a62e4 100644 --- a/libraries/botbuilder-core/src/configurationBotFrameworkAuthentication.ts +++ b/libraries/botbuilder-core/src/configurationBotFrameworkAuthentication.ts @@ -17,6 +17,7 @@ import { ConnectorFactory, ServiceClientCredentialsFactory, UserTokenClient, + AseChannelValidation, } from 'botframework-connector'; import { @@ -26,6 +27,16 @@ import { const TypedOptions = z .object({ + /** + * The ID assigned to your bot in the [Bot Framework Portal](https://dev.botframework.com/). + */ + MicrosoftAppId: z.string(), + + /** + * The tenant id assigned to your bot in the [Bot Framework Portal](https://dev.botframework.com/). + */ + MicrosoftAppTenantId: z.string(), + /** * (Optional) The OAuth URL used to get a token from OAuthApiClient. The "OAuthUrl" member takes precedence over this value. */ @@ -131,6 +142,7 @@ export class ConfigurationBotFrameworkAuthentication extends BotFrameworkAuthent super(); try { + AseChannelValidation.init(botFrameworkAuthConfig); const typedBotFrameworkAuthConfig = TypedOptions.nonstrict().parse(botFrameworkAuthConfig); const { diff --git a/libraries/botframework-connector/src/auth/aseChannelValidation.ts b/libraries/botframework-connector/src/auth/aseChannelValidation.ts new file mode 100644 index 0000000000..4d326e6279 --- /dev/null +++ b/libraries/botframework-connector/src/auth/aseChannelValidation.ts @@ -0,0 +1,164 @@ +/** + * @module botframework-connector + */ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +/* eslint-disable @typescript-eslint/no-namespace */ + +import { ClaimsIdentity } from './claimsIdentity'; +import { AuthenticationConstants } from './authenticationConstants'; +import { AuthenticationConfiguration } from './authenticationConfiguration'; +import { GovernmentConstants } from './governmentConstants'; +import { ICredentialProvider } from './credentialProvider'; +import { JwtTokenExtractor } from './jwtTokenExtractor'; +import { JwtTokenValidation } from './jwtTokenValidation'; +import { AuthenticationError } from './authenticationError'; +import { SimpleCredentialProvider } from './credentialProvider'; +import { StatusCodes } from 'botframework-schema'; +import { BetweenBotAndAseChannelTokenValidationParameters } from './tokenValidationParameters'; + +/** + * @deprecated Use `ConfigurationBotFrameworkAuthentication` instead to perform AseChannel validation. + * Validates and Examines JWT tokens from the Bot Framework AseChannel + */ +export namespace AseChannelValidation { + const ChannelId = 'AseChannel'; + let _creadentialProvider: ICredentialProvider; + let _channelService: string; + export let MetadataUrl: string; + + /** + * init authentication from user .env configuration. + * + * @param configuration The user .env configuration. + */ + export function init(configuration: any) { + const appId = configuration.MicrosoftAppId; + const tenantId = configuration.MicrosoftAppTenantId; + _channelService = configuration.ChannelService; + MetadataUrl = + _channelService !== undefined && JwtTokenValidation.isGovernment(_channelService) + ? GovernmentConstants.ToBotFromEmulatorOpenIdMetadataUrl + : AuthenticationConstants.ToBotFromEmulatorOpenIdMetadataUrl; + + _creadentialProvider = new SimpleCredentialProvider(appId, ''); + + const tenantIds: string[] = [ + tenantId, + 'f8cdef31-a31e-4b4a-93e4-5f571e91255a', // US Gov MicrosoftServices.onmicrosoft.us + 'd6d49420-f39b-4df7-a1dc-d59a935871db', // Public botframework.com + ]; + const validIssuers: string[] = []; + tenantIds.forEach((tmpId: string) => { + validIssuers.push(`https://sts.windows.net/${tmpId}/`); // Auth Public/US Gov, 1.0 token + validIssuers.push(`https://login.microsoftonline.com/${tmpId}/v2.0`); // Auth Public, 2.0 token + validIssuers.push(`https://login.microsoftonline.us/${tmpId}/v2.0`); // Auth for US Gov, 2.0 token + }); + BetweenBotAndAseChannelTokenValidationParameters.issuer = validIssuers; + } + + /** + * Determines if a given Auth header is from the Bot Framework AseChannel + * + * @param {string} channelId The channelId. + * @returns {boolean} True, if the token was issued by the AseChannel. Otherwise, false. + */ + export function isTokenFromAseChannel(channelId: string): boolean { + return channelId === ChannelId; + } + + /** + * Validate the incoming Auth Header as a token sent from the Bot Framework AseChannel. + * A token issued by the Bot Framework will FAIL this check. Only AseChannel tokens will pass. + * + * @param {string} authHeader The raw HTTP header in the format: 'Bearer [longString]' + * @param {AuthenticationConfiguration} authConfig The authentication configuration. + * @returns {Promise} A valid ClaimsIdentity. + */ + export async function authenticateAseChannelToken( + authHeader: string, + authConfig: AuthenticationConfiguration = new AuthenticationConfiguration() + ): Promise { + const tokenExtractor: JwtTokenExtractor = new JwtTokenExtractor( + BetweenBotAndAseChannelTokenValidationParameters, + MetadataUrl, + AuthenticationConstants.AllowedSigningAlgorithms + ); + + const identity: ClaimsIdentity = await tokenExtractor.getIdentityFromAuthHeader( + authHeader, + ChannelId, + authConfig.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 _creadentialProvider.isValidAppId(appId))) { + throw new AuthenticationError( + `Unauthorized. Invalid AppId passed on token: ${appId}`, + StatusCodes.UNAUTHORIZED + ); + } + + return identity; + } +} diff --git a/libraries/botframework-connector/src/auth/index.ts b/libraries/botframework-connector/src/auth/index.ts index c4c275cb07..5367b217b3 100644 --- a/libraries/botframework-connector/src/auth/index.ts +++ b/libraries/botframework-connector/src/auth/index.ts @@ -22,6 +22,7 @@ export * from './claimsIdentity'; export * from './connectorFactory'; export * from './credentialProvider'; export * from './emulatorValidation'; +export * from './aseChannelValidation'; export * from './endorsementsValidator'; export * from './enterpriseChannelValidation'; export * from './governmentChannelValidation'; @@ -36,6 +37,7 @@ export * from './microsoftGovernmentAppCredentials'; export * from './passwordServiceClientCredentialFactory'; export * from './serviceClientCredentialsFactory'; export * from './skillValidation'; +export * from './tokenValidationParameters'; export * from './userTokenClient'; export { MsalAppCredentials } from './msalAppCredentials'; diff --git a/libraries/botframework-connector/src/auth/jwtTokenValidation.ts b/libraries/botframework-connector/src/auth/jwtTokenValidation.ts index 8cf1858e35..be593f6b6f 100644 --- a/libraries/botframework-connector/src/auth/jwtTokenValidation.ts +++ b/libraries/botframework-connector/src/auth/jwtTokenValidation.ts @@ -19,6 +19,7 @@ import { EnterpriseChannelValidation } from './enterpriseChannelValidation'; import { GovernmentChannelValidation } from './governmentChannelValidation'; import { GovernmentConstants } from './governmentConstants'; import { SkillValidation } from './skillValidation'; +import { AseChannelValidation } from './aseChannelValidation'; /** * @deprecated Use `ConfigurationBotFrameworkAuthentication` instead to perform JWT token validation. @@ -128,6 +129,10 @@ export namespace JwtTokenValidation { authConfig: AuthenticationConfiguration, serviceUrl: string ): Promise { + if (AseChannelValidation.isTokenFromAseChannel(channelId)) { + return AseChannelValidation.authenticateAseChannelToken(authHeader); + } + if (SkillValidation.isSkillToken(authHeader)) { return await SkillValidation.authenticateChannelToken( authHeader, @@ -138,9 +143,7 @@ export namespace JwtTokenValidation { ); } - const usingEmulator = EmulatorValidation.isTokenFromEmulator(authHeader); - - if (usingEmulator) { + if (EmulatorValidation.isTokenFromEmulator(authHeader)) { return await EmulatorValidation.authenticateEmulatorToken( authHeader, credentials, diff --git a/libraries/botframework-connector/src/auth/parameterizedBotFrameworkAuthentication.ts b/libraries/botframework-connector/src/auth/parameterizedBotFrameworkAuthentication.ts index d6e8f20dd9..21d14f02ce 100644 --- a/libraries/botframework-connector/src/auth/parameterizedBotFrameworkAuthentication.ts +++ b/libraries/botframework-connector/src/auth/parameterizedBotFrameworkAuthentication.ts @@ -22,6 +22,7 @@ import { ToBotFromBotOrEmulatorTokenValidationParameters } from './tokenValidati import { UserTokenClientImpl } from './userTokenClientImpl'; import type { UserTokenClient } from './userTokenClient'; import { VerifyOptions } from 'jsonwebtoken'; +import { AseChannelValidation } from './aseChannelValidation'; function getAppId(claimsIdentity: ClaimsIdentity): string | undefined { // For requests from channel App Id is in Audience claim of JWT token. For emulator it is in AppId claim. For @@ -270,6 +271,10 @@ export class ParameterizedBotFrameworkAuthentication extends BotFrameworkAuthent channelId: string, serviceUrl: string ): Promise { + if (AseChannelValidation.isTokenFromAseChannel(channelId)) { + return AseChannelValidation.authenticateAseChannelToken(authHeader); + } + if (SkillValidation.isSkillToken(authHeader)) { return this.SkillValidation_authenticateChannelToken(authHeader, channelId); } diff --git a/libraries/botframework-connector/src/auth/tokenValidationParameters.ts b/libraries/botframework-connector/src/auth/tokenValidationParameters.ts index f0bd53b89f..6bc439402c 100644 --- a/libraries/botframework-connector/src/auth/tokenValidationParameters.ts +++ b/libraries/botframework-connector/src/auth/tokenValidationParameters.ts @@ -23,3 +23,14 @@ export const ToBotFromBotOrEmulatorTokenValidationParameters: VerifyOptions = { clockTolerance: 5 * 60, ignoreExpiration: false, }; + +// Internal +/** + * Default options for validating incoming tokens from the Bot Ase Channel. + */ +export const BetweenBotAndAseChannelTokenValidationParameters: VerifyOptions = { + issuer: [], + audience: undefined, // Audience validation takes place manually in code. + clockTolerance: 5 * 60, + ignoreExpiration: false, +}; diff --git a/libraries/botframework-connector/tests/auth/aseChannelValidation.test.js b/libraries/botframework-connector/tests/auth/aseChannelValidation.test.js new file mode 100644 index 0000000000..5d2c167d1c --- /dev/null +++ b/libraries/botframework-connector/tests/auth/aseChannelValidation.test.js @@ -0,0 +1,67 @@ +const { + AseChannelValidation, + GovernmentConstants, + AuthenticationConstants, + BetweenBotAndAseChannelTokenValidationParameters, +} = require('../..'); +const assert = require('assert'); + +describe('AseChannelTestSuite', function () { + describe('AseChannelTestCase', function () { + it('ValidationMetadataUrlTest_AseChannel_USGov', function () { + const config = { + ChannelService: GovernmentConstants.ChannelService, + }; + AseChannelValidation.init(config); + assert.strictEqual( + AseChannelValidation.MetadataUrl, + GovernmentConstants.ToBotFromEmulatorOpenIdMetadataUrl + ); + }); + + it('ValidationMetadataUrlTest_AseChannel_Public', function () { + const config = {}; + AseChannelValidation.init(config); + assert.strictEqual( + AseChannelValidation.MetadataUrl, + AuthenticationConstants.ToBotFromEmulatorOpenIdMetadataUrl + ); + }); + + it('ValidationIssueUrlTest_AseChannel', function () { + const config = { + MicrosoftAppTenantId: 'testTenantId', + }; + AseChannelValidation.init(config); + const tenantIds = [ + 'testTenantId', + 'f8cdef31-a31e-4b4a-93e4-5f571e91255a', // US Gov MicrosoftServices.onmicrosoft.us + 'd6d49420-f39b-4df7-a1dc-d59a935871db', // Public botframework.com + ]; + tenantIds.forEach(function (tmpId) { + assert.strictEqual( + true, + BetweenBotAndAseChannelTokenValidationParameters.issuer.includes( + `https://sts.windows.net/${tmpId}/` + ) + ); + assert.strictEqual( + true, + BetweenBotAndAseChannelTokenValidationParameters.issuer.includes( + `https://login.microsoftonline.com/${tmpId}/v2.0` + ) + ); + assert.strictEqual( + true, + BetweenBotAndAseChannelTokenValidationParameters.issuer.includes( + `https://login.microsoftonline.us/${tmpId}/v2.0` + ) + ); + }); + }); + + it('ValidationChannelIdTest_AseChannel', function () { + assert.strictEqual(true, AseChannelValidation.isTokenFromAseChannel('AseChannel')); + }); + }); +});