import { CloudEnvironment, Credentials, InvokeResponse, IToken, } from '@microsoft/teams.api'; import { ConsoleLogger, ILogger } from '@microsoft/teams.common'; import { IActivityEvent, ICoreActivity } from '../events'; import { ServiceTokenValidator } from '../middleware/auth/service-token-validator'; import { HttpMethod, IHttpServerAdapter, IHttpServerRequest, IHttpServerResponse, HttpRouteHandler } from './adapter'; type AuthResult = | { success: true; token: IToken } | { success: false; error: string }; /** * Sanitize an attacker-controlled activity field for inclusion in a log line. * Strips control characters (CR/LF/tab/etc.) and caps length to defeat log injection. */ function safeLogField(v: unknown): string { // eslint-disable-next-line no-control-regex return String(v ?? 'unknown').replace(/[\r\n\t\x00-\x1f\x7f]/g, '').slice(0, 64); } export type HttpServerOptions = { /** * Dangerously allow incoming HTTP requests without Teams service token validation. */ readonly dangerouslyAllowUnauthenticatedRequests?: boolean; readonly logger?: ILogger; /** * URL path for the Teams messaging endpoint */ readonly messagingEndpoint: string; }; /** * Public interface for HttpServer, exposed via DI for plugins */ export interface IHttpServer { handleRequest(request: IHttpServerRequest): Promise; readonly adapter: IHttpServerAdapter; readonly messagingEndpoint: string; } /** * Configurable HTTP server for receiving Teams activities */ export class HttpServer implements IHttpServer { /** * Callback invoked when a valid activity request arrives * App should set this to process activities */ onRequest?: (event: IActivityEvent) => Promise; protected logger: ILogger; protected credentials?: Credentials; protected cloud?: CloudEnvironment; protected initialized: boolean = false; protected serviceTokenValidator?: ServiceTokenValidator; private dangerouslyAllowUnauthenticatedRequests: boolean; private _adapter: IHttpServerAdapter; private _messagingEndpoint: string; /** * Get the underlying adapter * Useful for plugins that need adapter-specific features */ get adapter(): IHttpServerAdapter { return this._adapter; } /** * Get the messaging endpoint path */ get messagingEndpoint(): string { return this._messagingEndpoint; } constructor(adapter: IHttpServerAdapter, options: HttpServerOptions) { this._adapter = adapter; this.logger = options.logger ?? new ConsoleLogger('HttpServer'); this._messagingEndpoint = options.messagingEndpoint; this.dangerouslyAllowUnauthenticatedRequests = options.dangerouslyAllowUnauthenticatedRequests ?? false; } /** * Initialize the server with dependencies (registers routes, prepares adapter) * Can be called multiple times - only initializes once * Called by App.initialize() */ async initialize(deps: { credentials?: Credentials; cloud?: CloudEnvironment; }) { if (this.initialized) { this.logger.debug('HttpServer already initialized, skipping'); return; } this.credentials = deps.credentials; this.cloud = deps.cloud; if (!this.credentials && !this.dangerouslyAllowUnauthenticatedRequests) { this.logger.warn( 'No credentials configured and dangerouslyAllowUnauthenticatedRequests is not enabled. ' + 'All incoming requests will be rejected. Configure client authentication to securely receive messages, ' + 'or set dangerouslyAllowUnauthenticatedRequests: true for local development.' ); } // Initialize service token validator if credentials provided and auth validation is enabled if (this.credentials && !this.dangerouslyAllowUnauthenticatedRequests) { this.serviceTokenValidator = new ServiceTokenValidator( this.credentials.clientId, this.credentials.tenantId, undefined, // serviceUrl will be validated from activity body this.logger, deps.cloud ); } else if (!this.credentials && this.dangerouslyAllowUnauthenticatedRequests) { this.logger.warn( 'No credentials configured (CLIENT_ID / CLIENT_SECRET / TENANT_ID), ' + 'but dangerouslyAllowUnauthenticatedRequests is enabled. ' + `Bot will accept unauthenticated requests on ${this._messagingEndpoint}.` ); } else if (this.credentials && this.dangerouslyAllowUnauthenticatedRequests) { this.logger.warn( 'Credentials are configured, but dangerouslyAllowUnauthenticatedRequests is enabled. ' + `Bot will bypass Teams service token validation on ${this._messagingEndpoint}.` ); } // Register Teams bot endpoint (POST only) this._adapter.registerRoute('POST', this._messagingEndpoint, async (request) => { return this.handleRequest(request); }); this.initialized = true; } /** * Start the HTTP server * Called by App.start() */ async start(port: number | string) { if (!this._adapter.start) { throw new Error( 'Adapter does not implement start(). ' + 'Either implement start() in your adapter, or manage server lifecycle manually.' ); } await this._adapter.start(port); } /** * Stop the HTTP server * Called by App.stop() if implemented */ async stop() { if (!this._adapter.stop) { this.logger.warn('Adapter does not implement stop(). Skipping server shutdown.'); return; } await this._adapter.stop(); } /** * Register a route handler with the adapter * Used by app.function() and other app methods */ registerRoute(method: HttpMethod, path: string, handler: HttpRouteHandler) { this._adapter.registerRoute(method, path, handler); } /** * Serve static files from a directory * Used by app.tab() and other app methods */ serveStatic(path: string, directory: string) { if (this._adapter.serveStatic) { this._adapter.serveStatic(path, directory); } } /** * Handle incoming activity request * Validates JWT, dispatches to app, returns response */ async handleRequest(request: IHttpServerRequest): Promise { try { const body = request.body as ICoreActivity; this.logger.debug('Handling activity', body); const auth = await this.authorize(request.headers, body); if (!auth.success) { return { status: 401, body: { error: auth.error } }; } if (!this.onRequest) { throw new Error('HttpServer.onRequest callback not set'); } const response = await this.onRequest({ body, token: auth.token }); return { status: response.status || 200, body: response.body }; } catch (err) { this.logger.error('Error processing activity:', err); return { status: 500, body: { error: 'Internal server error' } }; } } /** * Authorize the request by validating the JWT token. */ protected async authorize( headers: Record, body: ICoreActivity ): Promise { if (this.dangerouslyAllowUnauthenticatedRequests) { const serviceUrl = body.serviceUrl || ''; return { success: true, token: { appId: '', from: 'azure', fromId: '', serviceUrl, isExpired: () => false, }, }; } if (!this.credentials) { return { success: false, error: 'Authentication not configured' }; } const raw = headers['authorization']; const authHeader = Array.isArray(raw) ? raw[0] : raw; if (!authHeader) { this.logger.warn( `inbound activity rejected (type=${safeLogField(body?.type)}, id=${safeLogField(body?.id)}): missing Authorization header (responding 401)` ); return { success: false, error: 'Missing authorization header' }; } if (!this.serviceTokenValidator) { throw new Error('Service token validator not initialized - credentials required'); } try { const token = await this.serviceTokenValidator.check(authHeader, body); return { success: true, token }; } catch (err) { this.logger.error('JWT validation failed', err); return { success: false, error: 'JWT validation failed' }; } } }