Skip to content

Commit

Permalink
feat: Add ASE channel validation. (#4589)
Browse files Browse the repository at this point in the history
* aseChannelValidation

* fix usgov single tenant

* fix js lint

* fix js lint
  • Loading branch information
fangyangci authored and Tracy Boehrer committed Jan 24, 2024
1 parent aa83fbe commit 21e7caa
Show file tree
Hide file tree
Showing 7 changed files with 267 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
ConnectorFactory,
ServiceClientCredentialsFactory,
UserTokenClient,
AseChannelValidation,
} from 'botframework-connector';

import {
Expand All @@ -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.
*/
Expand Down Expand Up @@ -131,6 +142,7 @@ export class ConfigurationBotFrameworkAuthentication extends BotFrameworkAuthent
super();

try {
AseChannelValidation.init(botFrameworkAuthConfig);
const typedBotFrameworkAuthConfig = TypedOptions.nonstrict().parse(botFrameworkAuthConfig);

const {
Expand Down
164 changes: 164 additions & 0 deletions libraries/botframework-connector/src/auth/aseChannelValidation.ts
Original file line number Diff line number Diff line change
@@ -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<ClaimsIdentity>} A valid ClaimsIdentity.
*/
export async function authenticateAseChannelToken(
authHeader: string,
authConfig: AuthenticationConfiguration = new AuthenticationConfiguration()
): Promise<ClaimsIdentity> {
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;
}
}
2 changes: 2 additions & 0 deletions libraries/botframework-connector/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -128,6 +129,10 @@ export namespace JwtTokenValidation {
authConfig: AuthenticationConfiguration,
serviceUrl: string
): Promise<ClaimsIdentity> {
if (AseChannelValidation.isTokenFromAseChannel(channelId)) {
return AseChannelValidation.authenticateAseChannelToken(authHeader);
}

if (SkillValidation.isSkillToken(authHeader)) {
return await SkillValidation.authenticateChannelToken(
authHeader,
Expand All @@ -138,9 +143,7 @@ export namespace JwtTokenValidation {
);
}

const usingEmulator = EmulatorValidation.isTokenFromEmulator(authHeader);

if (usingEmulator) {
if (EmulatorValidation.isTokenFromEmulator(authHeader)) {
return await EmulatorValidation.authenticateEmulatorToken(
authHeader,
credentials,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -270,6 +271,10 @@ export class ParameterizedBotFrameworkAuthentication extends BotFrameworkAuthent
channelId: string,
serviceUrl: string
): Promise<ClaimsIdentity | undefined> {
if (AseChannelValidation.isTokenFromAseChannel(channelId)) {
return AseChannelValidation.authenticateAseChannelToken(authHeader);
}

if (SkillValidation.isSkillToken(authHeader)) {
return this.SkillValidation_authenticateChannelToken(authHeader, channelId);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Original file line number Diff line number Diff line change
@@ -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'));
});
});
});

0 comments on commit 21e7caa

Please sign in to comment.