UNPKG

botframework-connector

Version:

Bot Connector is autorest generated connector client.

225 lines (197 loc) 9.03 kB
/** * @module botframework-connector */ /** * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. */ import { Algorithm, decode, JwtHeader, verify, VerifyOptions } from 'jsonwebtoken'; import { Claim, ClaimsIdentity } from './claimsIdentity'; import { EndorsementsValidator } from './endorsementsValidator'; import { OpenIdMetadata } from './openIdMetadata'; import { AuthenticationError } from './authenticationError'; import { StatusCodes } from 'botframework-schema'; import { ProxySettings } from 'botbuilder-stdlib/lib/azureCoreHttpCompat'; /** * A JWT token processing class that gets identity information and performs security token validation. */ export class JwtTokenExtractor { // Cache for OpenIdConnect configuration managers (one per metadata URL) private static openIdMetadataCache: Map<string, OpenIdMetadata> = new Map<string, OpenIdMetadata>(); // Token validation parameters for this instance readonly tokenValidationParameters: VerifyOptions; // OpenIdMetadata for this instance readonly openIdMetadata: OpenIdMetadata; /** * Initializes a new instance of the [JwtTokenExtractor](xref:botframework-connector.JwtTokenExtractor) class. Extracts relevant data from JWT Tokens. * * @param tokenValidationParameters Token validation parameters. * @param metadataUrl Metadata Url. * @param allowedSigningAlgorithms Allowed signing algorithms. * @param proxySettings The proxy settings for the request. * @param tokenRefreshInterval The token refresh interval in hours. The default value is 24 hours. */ constructor( tokenValidationParameters: VerifyOptions, metadataUrl: string, allowedSigningAlgorithms: string[] | Algorithm[], proxySettings?: ProxySettings, tokenRefreshInterval?: number, ) { this.tokenValidationParameters = { ...tokenValidationParameters }; this.tokenValidationParameters.algorithms = allowedSigningAlgorithms as Algorithm[]; this.openIdMetadata = JwtTokenExtractor.getOrAddOpenIdMetadata( metadataUrl, proxySettings, tokenRefreshInterval, ); } private static getOrAddOpenIdMetadata( metadataUrl: string, proxySettings?: ProxySettings, tokenRefreshInterval?: number, ): OpenIdMetadata { let metadata = this.openIdMetadataCache.get(metadataUrl); if (!metadata) { metadata = new OpenIdMetadata(metadataUrl, proxySettings, tokenRefreshInterval); this.openIdMetadataCache.set(metadataUrl, metadata); } return metadata; } /** * Gets the claims identity associated with a request. * * @param authorizationHeader The raw HTTP header in the format: "Bearer [longString]". * @param channelId The Id of the channel being validated in the original request. * @param requiredEndorsements The required JWT endorsements. * @returns A `Promise` representation for either a [ClaimsIdentity](botframework-connector:module.ClaimsIdentity) or `null`. */ async getIdentityFromAuthHeader( authorizationHeader: string, channelId: string, requiredEndorsements?: string[], ): Promise<ClaimsIdentity | null> { if (!authorizationHeader) { return null; } const parts: string[] = authorizationHeader.split(' '); if (parts.length === 2) { return await this.getIdentity(parts[0], parts[1], channelId, requiredEndorsements || []); } return null; } /** * Gets the claims identity associated with a request. * * @param scheme The associated scheme. * @param parameter The token. * @param channelId The Id of the channel being validated in the original request. * @param requiredEndorsements The required JWT endorsements. * @returns A `Promise` representation for either a [ClaimsIdentity](botframework-connector:module.ClaimsIdentity) or `null`. */ async getIdentity( scheme: string, parameter: string, channelId: string, requiredEndorsements: string[] = [], ): Promise<ClaimsIdentity | null> { // No header in correct scheme or no token if (scheme !== 'Bearer' || !parameter) { return null; } // Issuer isn't allowed? No need to check signature if (!this.hasAllowedIssuer(parameter)) { return null; } return await this.validateToken(parameter, channelId, requiredEndorsements); } /** * @private */ private hasAllowedIssuer(jwtToken: string): boolean { const payload = decode(jwtToken); let issuer: string; if (payload && typeof payload === 'object') { issuer = payload.iss; } else { return false; } if (Array.isArray(this.tokenValidationParameters.issuer)) { return this.tokenValidationParameters.issuer.indexOf(issuer) !== -1; } if (typeof this.tokenValidationParameters.issuer === 'string') { return this.tokenValidationParameters.issuer === issuer; } return false; } /** * @private */ private async validateToken( jwtToken: string, channelId: string, requiredEndorsements: string[], ): Promise<ClaimsIdentity> { let header: Partial<JwtHeader> = {}; const decodedToken = decode(jwtToken, { complete: true }); if (decodedToken && typeof decodedToken === 'object') { header = decodedToken.header; } // Update the signing tokens from the last refresh const keyId = header.kid; const metadata = await this.openIdMetadata.getKey(keyId); if (!metadata) { throw new AuthenticationError('Signing Key could not be retrieved.', StatusCodes.UNAUTHORIZED); } try { let decodedPayload: Record<string, string> = {}; const verifyResults = verify(jwtToken, metadata.key, this.tokenValidationParameters); if (verifyResults && typeof verifyResults === 'object') { // Note: casting is necessary here, but we know `object` is loosely equivalent to a Record decodedPayload = verifyResults as Record<string, string>; } // enforce endorsements in openIdMetadadata if there is any endorsements associated with the key const endorsements = metadata.endorsements; if (Array.isArray(endorsements) && endorsements.length !== 0) { const isEndorsed = EndorsementsValidator.validate(channelId, endorsements); if (!isEndorsed) { throw new AuthenticationError( `Could not validate endorsement for key: ${keyId} with endorsements: ${endorsements.join(',')}`, StatusCodes.UNAUTHORIZED, ); } // Verify that additional endorsements are satisfied. If no additional endorsements are expected, the requirement is satisfied as well const additionalEndorsementsSatisfied = requiredEndorsements.every((endorsement) => EndorsementsValidator.validate(endorsement, endorsements), ); if (!additionalEndorsementsSatisfied) { throw new AuthenticationError( `Could not validate additional endorsement for key: ${keyId} with endorsements: ${requiredEndorsements.join( ',', )}. Expected endorsements: ${requiredEndorsements.join(',')}`, StatusCodes.UNAUTHORIZED, ); } } if (this.tokenValidationParameters.algorithms) { if (this.tokenValidationParameters.algorithms.indexOf(header.alg as Algorithm) === -1) { throw new AuthenticationError( `"Token signing algorithm '${header.alg}' not in allowed list`, StatusCodes.UNAUTHORIZED, ); } } const claims = Object.entries(decodedPayload).map<Claim>(([type, value]) => ({ type, value })); // Note: true is used here to indicate that these claims are to be considered authenticated. They are sourced // from a validated JWT (see `verify` above), so no harm in doing so. return new ClaimsIdentity(claims, true); } catch (err) { if (err.name === 'TokenExpiredError') { console.error(err); throw new AuthenticationError('The token has expired', StatusCodes.UNAUTHORIZED); } console.error(`Error finding key for token. Available keys: ${metadata.key}`); throw err; } } }