botframework-connector
Version:
Bot Connector is autorest generated connector client.
546 lines (484 loc) • 23.6 kB
text/typescript
// 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';
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
// 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) ??
undefined
);
}
/**
* @internal
* Parameterized [BotFrameworkAuthentication](xref:botframework-connector.BotFrameworkAuthentication) used to authenticate Bot Framework Protocol network calls within this environment.
*/
export class ParameterizedBotFrameworkAuthentication extends BotFrameworkAuthentication {
/**
* @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 an authenticated [Activities](xref:botframework-schema.Activity).
* @param credentialsFactory 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.
*/
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<Response>,
private readonly connectorClientOptions: ConnectorClientOptions = {},
) {
super();
}
/**
* Gets the originating audience from Bot OAuth scope.
*
* @returns The originating audience.
*/
getOriginatingAudience(): string {
return this.toChannelFromBotOAuthScope;
}
/**
* @param authHeader The http auth header received in the skill request.
* @returns The identity validation result.
*/
async authenticateChannelRequest(authHeader: string): Promise<ClaimsIdentity> {
if (await this.credentialsFactory.isAuthenticationDisabled()) {
return SkillValidation.createAnonymousSkillClaim();
} else {
if (!authHeader.trim()) {
throw new AuthenticationError(
'Unauthorized Access. Request is not authorized',
StatusCodes.UNAUTHORIZED,
);
}
return this.JwtTokenValidation_validateAuthHeader(authHeader, 'unknown', null);
}
}
/**
* Validate Bot Framework Protocol requests.
*
* @param activity The inbound Activity.
* @param authHeader The http auth header received in the skill request.
* @returns Promise with AuthenticateRequestResult.
*/
async authenticateRequest(activity: Activity, authHeader: string): Promise<AuthenticateRequestResult> {
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,
this.connectorClientOptions,
);
return {
audience: outboundAudience,
callerId,
claimsIdentity,
connectorFactory,
};
}
/**
* Validate Bot Framework Protocol requests.
*
* @param authHeader The http auth header received in the skill request.
* @param channelIdHeader The channel Id HTTP header.
* @returns Promise with AuthenticateRequestResult.
*/
async authenticateStreamingRequest(
authHeader: string,
channelIdHeader: string,
): Promise<AuthenticateRequestResult> {
if (!channelIdHeader?.trim() && !(await this.credentialsFactory.isAuthenticationDisabled())) {
throw new AuthenticationError("'channelIdHeader' required.", StatusCodes.UNAUTHORIZED);
}
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 };
}
/**
* Creates the appropriate UserTokenClient instance.
*
* @param claimsIdentity The inbound Activity's ClaimsIdentity.
* @returns Promise with UserTokenClient instance.
*/
async createUserTokenClient(claimsIdentity: ClaimsIdentity): Promise<UserTokenClient> {
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);
}
/**
* Creates a ConnectorFactory that can be used to create ConnectorClients that can use credentials from this particular Cloud Environment.
*
* @param claimsIdentity The inbound Activity's ClaimsIdentity.
* @returns A ConnectorFactory.
*/
createConnectorFactory(claimsIdentity: ClaimsIdentity): ConnectorFactory {
return new ConnectorFactoryImpl(
getAppId(claimsIdentity),
this.toChannelFromBotOAuthScope,
this.toChannelFromBotLoginUrl,
this.validateAuthority,
this.credentialsFactory,
this.connectorClientOptions,
);
}
/**
* Creates a BotFrameworkClient used for calling Skills.
*
* @returns A BotFrameworkClient instance to call Skills.
*/
createBotFrameworkClient(): BotFrameworkClient {
return new BotFrameworkClientImpl(
this.credentialsFactory,
this.toChannelFromBotLoginUrl,
this.botFrameworkClientFetch,
this.connectorClientOptions,
);
}
private async JwtTokenValidation_authenticateRequest(
activity: Partial<Activity>,
authHeader: string,
): Promise<ClaimsIdentity> {
if (await this.credentialsFactory.isAuthenticationDisabled()) {
// Check if the activity is for a skill call and is coming from the Emulator.
if (activity.channelId === Channels.Emulator && 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);
} else {
if (!authHeader.trim()) {
throw new AuthenticationError(
'Unauthorized Access. Request is not authorized',
StatusCodes.UNAUTHORIZED,
);
}
const claimsIdentity: ClaimsIdentity = await this.JwtTokenValidation_validateAuthHeader(
authHeader,
activity.channelId,
activity.serviceUrl,
);
return claimsIdentity;
}
}
private async JwtTokenValidation_validateAuthHeader(
authHeader: string,
channelId: string,
serviceUrl = '',
): Promise<ClaimsIdentity> {
const identity = await this.JwtTokenValidation_authenticateToken(authHeader, channelId, serviceUrl);
await this.JwtTokenValidation_validateClaims(identity.claims);
return identity;
}
private async JwtTokenValidation_validateClaims(claims: Claim[] = []): Promise<void> {
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<ClaimsIdentity | undefined> {
if (AseChannelValidation.isTokenFromAseChannel(channelId)) {
return AseChannelValidation.authenticateAseChannelToken(authHeader);
}
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<ClaimsIdentity> {
// Add allowed token issuers from configuration.
const verifyOptions: VerifyOptions = {
...ToBotFromBotOrEmulatorTokenValidationParameters,
issuer: [
...ToBotFromBotOrEmulatorTokenValidationParameters.issuer,
...(this.authConfiguration.validTokenIssuers ?? []),
],
};
const tokenExtractor = new JwtTokenExtractor(
verifyOptions,
this.toBotFromEmulatorOpenIdMetadataUrl,
AuthenticationConstants.AllowedSigningAlgorithms,
this.connectorClientOptions?.proxySettings,
this.connectorClientOptions?.tokenRefreshInterval,
);
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<void> {
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<ClaimsIdentity> {
// Add allowed token issuers from configuration.
const verifyOptions: VerifyOptions = {
...ToBotFromBotOrEmulatorTokenValidationParameters,
issuer: [
...ToBotFromBotOrEmulatorTokenValidationParameters.issuer,
...(this.authConfiguration.validTokenIssuers ?? []),
],
};
const tokenExtractor: JwtTokenExtractor = new JwtTokenExtractor(
verifyOptions,
this.toBotFromEmulatorOpenIdMetadataUrl,
AuthenticationConstants.AllowedSigningAlgorithms,
this.connectorClientOptions?.proxySettings,
this.connectorClientOptions?.tokenRefreshInterval,
);
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<ClaimsIdentity> {
const tokenValidationParameters = this.ChannelValidation_GetTokenValidationParameters();
const tokenExtractor: JwtTokenExtractor = new JwtTokenExtractor(
tokenValidationParameters,
this.toBotFromChannelOpenIdMetadataUrl,
AuthenticationConstants.AllowedSigningAlgorithms,
this.connectorClientOptions?.proxySettings,
this.connectorClientOptions?.tokenRefreshInterval,
);
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<ClaimsIdentity> {
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;
}
}