From 3aef8310610a5820b6b4134bca607c4eee7916bd Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Sun, 17 May 2026 23:06:43 -0700 Subject: [PATCH] Introduce internal Core runtime plumbing --- packages/apps/src/activity-sender.spec.ts | 6 +- packages/apps/src/activity-sender.ts | 12 +- packages/apps/src/app.oauth.ts | 8 +- packages/apps/src/app.process.ts | 17 +- packages/apps/src/app.spec.ts | 42 ++-- packages/apps/src/app.ts | 222 +++++++---------- packages/apps/src/core/core.ts | 281 ++++++++++++++++++++++ packages/apps/src/core/index.ts | 1 + packages/apps/src/index.ts | 7 + packages/apps/src/token-manager.ts | 30 +++ packages/botbuilder/src/plugin.ts | 5 +- 11 files changed, 447 insertions(+), 184 deletions(-) create mode 100644 packages/apps/src/core/core.ts create mode 100644 packages/apps/src/core/index.ts diff --git a/packages/apps/src/activity-sender.spec.ts b/packages/apps/src/activity-sender.spec.ts index a298ae541..779f7241f 100644 --- a/packages/apps/src/activity-sender.spec.ts +++ b/packages/apps/src/activity-sender.spec.ts @@ -2,6 +2,7 @@ import { ActivityParams, ConversationReference } from '@microsoft/teams.api'; import {Client as HttpClient } from '@microsoft/teams.common'; import { ActivitySender } from './activity-sender'; +import { ApiClient } from './api'; describe('ActivitySender', () => { let sender: ActivitySender; @@ -22,7 +23,10 @@ describe('ActivitySender', () => { conversation: { id: 'conv-123', conversationType: 'personal' }, }; - sender = new ActivitySender(mockHttpClient, undefined as any); + sender = new ActivitySender( + (serviceUrl) => new ApiClient(serviceUrl, mockHttpClient), + undefined as any + ); }); describe('send', () => { diff --git a/packages/apps/src/activity-sender.ts b/packages/apps/src/activity-sender.ts index 4e5f76a69..e3abbb47e 100644 --- a/packages/apps/src/activity-sender.ts +++ b/packages/apps/src/activity-sender.ts @@ -1,6 +1,7 @@ -import { ActivityParams, Client, ConversationReference, SentActivity } from '@microsoft/teams.api'; -import { Client as HttpClient, ILogger } from '@microsoft/teams.common'; +import { ActivityParams, ConversationReference, SentActivity } from '@microsoft/teams.api'; +import { ILogger } from '@microsoft/teams.common'; +import { ApiClient } from './api'; import { HttpStream } from './http/http-stream'; import { IStreamer, IActivitySender } from './types'; @@ -10,13 +11,14 @@ import { IStreamer, IActivitySender } from './types'; */ export class ActivitySender implements IActivitySender { constructor( - private client: HttpClient, + // ApiClient is serviceUrl-bound, so send/stream need a per-conversation client. + private getApiClient: (serviceUrl: string) => ApiClient, private logger: ILogger ) { } async send(activity: ActivityParams, ref: ConversationReference): Promise { // Create API client for this conversation's service URL - const api = new Client(ref.serviceUrl, this.client); + const api = this.getApiClient(ref.serviceUrl); // Merge activity with conversation reference activity = { @@ -48,7 +50,7 @@ export class ActivitySender implements IActivitySender { createStream(ref: ConversationReference): IStreamer { // Create API client for this conversation's service URL - const api = new Client(ref.serviceUrl, this.client); + const api = this.getApiClient(ref.serviceUrl); return new HttpStream(api, ref, this.logger); } } diff --git a/packages/apps/src/app.oauth.ts b/packages/apps/src/app.oauth.ts index 10256c50d..2f934025f 100644 --- a/packages/apps/src/app.oauth.ts +++ b/packages/apps/src/app.oauth.ts @@ -36,9 +36,7 @@ export async function onTokenExchange( }); ctx.userGraph = new GraphClient( - this.client.clone({ - token: token.token, - }), + this.client.clone({ token: token.token }), { baseUrlRoot: this.graphBaseUrl } ); @@ -86,9 +84,7 @@ export async function onVerifyState( }); ctx.userGraph = new GraphClient( - this.client.clone({ - token: token.token, - }), + this.client.clone({ token: token.token }), { baseUrlRoot: this.graphBaseUrl } ); diff --git a/packages/apps/src/app.process.ts b/packages/apps/src/app.process.ts index fe2da2bb6..7b4413cfd 100644 --- a/packages/apps/src/app.process.ts +++ b/packages/apps/src/app.process.ts @@ -1,6 +1,7 @@ import { Activity, ActivityLike, ConversationReference, InvokeResponse, isInvokeResponse } from '@microsoft/teams.api'; +import { Client as GraphClient } from '@microsoft/teams.graph'; -import { ApiClient, GraphClient } from './api'; +import { ApiClient } from './api'; import { App } from './app'; import { ActivityContext, IActivityContext } from './contexts'; import { IActivityEvent } from './events'; @@ -38,18 +39,22 @@ export async function $process( try { userToken = await this.getUserToken(activity.channelId, activity.from.id); - } catch (err) { + } catch { // noop } - const client = this.client.clone(); - const apiClient = new ApiClient(serviceUrl, this.client.clone({ token: () => this.getBotToken() }), this.options.apiClientSettings); + const apiClient = new ApiClient( + serviceUrl, + this.client.clone({ token: () => this.getBotToken() }), + this.options.apiClientSettings, + this.cloud + ); const userGraph = new GraphClient( - client.clone({ token: () => userToken }), + this.client.clone({ token: () => userToken }), { baseUrlRoot: this.graphBaseUrl } ); const appGraph = new GraphClient( - client.clone({ token: () => this.getAppGraphToken(activity.conversation.tenantId ?? 'common') }), + this.client.clone({ token: () => this.getAppGraphToken(activity.conversation?.tenantId ?? 'common') }), { baseUrlRoot: this.graphBaseUrl } ); diff --git a/packages/apps/src/app.spec.ts b/packages/apps/src/app.spec.ts index 1c8f277d5..76a5ae253 100644 --- a/packages/apps/src/app.spec.ts +++ b/packages/apps/src/app.spec.ts @@ -68,15 +68,14 @@ describe('App', () => { }); it('should acquire bot token via TokenManager', async () => { - const mockAcquireToken = jest.fn().mockResolvedValue({ - accessToken: mockBotToken, + await app.stop(); + app = new TestApp({ + httpServerAdapter: new TestAdapter(), + clientId: 'test-client-id', + tenantId: 'test-tenant-id', + token: jest.fn().mockResolvedValue(mockBotToken), }); - // @ts-expect-error - accessing private method for testing - jest.spyOn(app.tokenManager, 'getConfidentialClient').mockReturnValue({ - acquireTokenByClientCredential: mockAcquireToken, - } as any); - const token = await app.testGetBotToken(); expect(token).toBeInstanceOf(JsonWebToken); @@ -84,15 +83,14 @@ describe('App', () => { }); it('should acquire graph token via TokenManager', async () => { - const mockAcquireToken = jest.fn().mockResolvedValue({ - accessToken: mockGraphToken, + await app.stop(); + app = new TestApp({ + httpServerAdapter: new TestAdapter(), + clientId: 'test-client-id', + tenantId: 'test-tenant-id', + token: jest.fn().mockResolvedValue(mockGraphToken), }); - // @ts-expect-error - accessing private method for testing - jest.spyOn(app.tokenManager, 'getConfidentialClient').mockReturnValue({ - acquireTokenByClientCredential: mockAcquireToken, - } as any); - const token = await app.testGetAppGraphToken(); expect(token).toBeInstanceOf(JsonWebToken); @@ -112,16 +110,18 @@ describe('App', () => { }); it('should not prefetch tokens on start', async () => { - const mockAcquireToken = jest.fn(); - - // @ts-expect-error - accessing private method for testing - jest.spyOn(app.tokenManager, 'getConfidentialClient').mockReturnValue({ - acquireTokenByClientCredential: mockAcquireToken, - } as any); + await app.stop(); + const token = jest.fn().mockResolvedValue(mockBotToken); + app = new TestApp({ + httpServerAdapter: new TestAdapter(), + clientId: 'test-client-id', + tenantId: 'test-tenant-id', + token, + }); await app.start(); - expect(mockAcquireToken).not.toHaveBeenCalled(); + expect(token).not.toHaveBeenCalled(); }); }); diff --git a/packages/apps/src/app.ts b/packages/apps/src/app.ts index 5ae39bd35..ab2c7e4ec 100644 --- a/packages/apps/src/app.ts +++ b/packages/apps/src/app.ts @@ -6,9 +6,7 @@ import { ChannelID, CloudEnvironment, ConversationReference, - cloudFromName, InvokeResponse, - PUBLIC, StripMentionsTextOptions, toActivityParams, TokenCredentials, @@ -23,8 +21,6 @@ import { LocalStorage } from '@microsoft/teams.common'; -import pkg from '../package.json'; - import { ActivitySender } from './activity-sender'; import { ApiClient, GraphClient } from './api'; @@ -44,15 +40,16 @@ import { getMetadata, getPlugin, inject, plugin } from './app.plugins'; import { $process } from './app.process'; import { message, on, use } from './app.routing'; import { Container } from './container'; +import { Core } from './core'; import { IActivityEvent } from './events'; -import { ExpressAdapter, IHttpServerAdapter } from './http'; +import { IHttpServerAdapter } from './http'; import { HttpServer } from './http/http-server'; import * as manifest from './manifest'; import * as middleware from './middleware'; import { DEFAULT_OAUTH_SETTINGS, OAuthSettings } from './oauth'; import { HttpPlugin } from './plugins'; import { Router } from './router'; -import { TokenManager } from './token-manager'; +import { Authorize, TokenManager } from './token-manager'; import { IPlugin, AppEvents } from './types'; import { PluginAdditionalContext } from './types/app-routing'; import { toThreadedConversationId } from './utils/thread'; @@ -95,6 +92,14 @@ export type AppOptions = { */ readonly token?: TokenCredentials['token']; + /** + * authorize - A partial override for outbound bot/app token resolution. + * The request is a discriminated union by auth kind. Return a token string + * to handle the request, null to handle with no token, or undefined to use + * the default TokenManager behavior. + */ + readonly authorize?: Authorize; + /** * managed identity client id - A managed identity client id. * Uses environment variable MANAGED_IDENTITY_CLIENT_ID if not explicitly provided @@ -191,16 +196,35 @@ export type AppActivityOptions = { * The orchestrator for receiving/sending activities */ export class App { - readonly api: ApiClient; - readonly cloud: CloudEnvironment; - readonly graph: GraphClient; + private readonly core: Core; readonly log: ILogger; - readonly server: HttpServer; readonly http?: HttpPlugin; - readonly client: HttpClient; readonly storage: IStorage; readonly entraTokenValidator?: middleware.JwtValidator; - readonly tokenManager: TokenManager; + + get api(): ApiClient { + return this.core.api; + } + + get graph(): GraphClient { + return this.core.graph; + } + + get cloud(): CloudEnvironment { + return this.core.cloud; + } + + get server(): HttpServer { + return this.core.server; + } + + get client(): HttpClient { + return this.core.client; + } + + get tokenManager(): TokenManager { + return this.core.tokenManager; + } /** * Graph API base URL derived from the configured cloud's `graphScope`. @@ -208,13 +232,15 @@ export class App { * Shared across every `GraphClient` the app constructs (`app.graph`, `ctx.appGraph`, `ctx.userGraph`) * so sovereign customers get consistent routing. */ - readonly graphBaseUrl?: string; + get graphBaseUrl(): string | undefined { + return this.core.graphBaseUrl; + } /** * the apps credentials */ get credentials() { - return this.tokenManager.credentials; + return this.core.credentials; } /** @@ -275,100 +301,17 @@ export class App { protected port?: number | string; protected activitySender: ActivitySender; - private readonly _userAgent = `teams.ts[apps]/${pkg.version}`; - constructor(readonly options: AppOptions = {}) { this.log = this.options.logger || new ConsoleLogger('@teams/app'); this.storage = this.options.storage || new LocalStorage(); this._manifest = this.options.manifest || {}; - // Resolve cloud environment from options or CLOUD env var - const cloudEnvName = typeof process !== 'undefined' ? process.env.CLOUD : undefined; - this.cloud = this.options.cloud ?? (cloudEnvName ? cloudFromName(cloudEnvName) : PUBLIC); - - if (!options.client) { - this.client = new HttpClient({ - headers: { - 'User-Agent': this._userAgent, - }, - }); - } else if (typeof options.client === 'function') { - this.client = options.client().clone({ - headers: { - 'User-Agent': this._userAgent, - }, - }); - } else if ('request' in options.client) { - this.client = options.client.clone({ - headers: { - 'User-Agent': this._userAgent, - }, - }); - } else { - this.client = new HttpClient(options.client).clone({ - headers: { - 'User-Agent': this._userAgent, - }, - }); - } - - const serviceUrl = (this.options.serviceUrl ?? process.env.SERVICE_URL ?? - 'https://smba.trafficmanager.net/teams').replace(/\/+$/, ''); - this.api = new ApiClient( - serviceUrl, - this.client.clone({ token: () => this.getBotToken() }), - this.options.apiClientSettings, - this.cloud - ); - - // Derive Graph API base URL from the cloud's graphScope (e.g. "https://graph.microsoft.us/.default" - // -> "https://graph.microsoft.us"). Falls back to the public Graph endpoint inside GraphClient if - // the scope isn't a URL (custom delegated scope, empty, etc.). - const graphUrlMatch = /^(https?:\/\/[^/]+)/i.exec((this.cloud.graphScope ?? '').trim()); - this.graphBaseUrl = graphUrlMatch?.[1]; - if (!this.graphBaseUrl && this.cloud.graphScope) { - this.log.warn( - `graphScope "${this.cloud.graphScope}" is not a URL; Graph calls will route to the public cloud. ` + - 'Set graphScope to an "https:///.default" value to route to the correct Graph endpoint.' - ); - } - this.graph = new GraphClient( - this.client.clone({ token: () => this.getAppGraphToken() }), - { baseUrlRoot: this.graphBaseUrl } - ); - - // initialize TokenManager with credentials - this.tokenManager = new TokenManager({ - clientId: this.options.clientId, - clientSecret: this.options.clientSecret, - tenantId: this.options.tenantId, - token: this.options.token, - managedIdentityClientId: this.options.managedIdentityClientId, - cloud: this.cloud, - }, this.log); - - // initialize ActivitySender for sending activities - this.activitySender = new ActivitySender( - this.client.clone({ token: () => this.getBotToken() }), - this.log - ); - - if (this.credentials?.clientId) { - this.entraTokenValidator = middleware.createEntraTokenValidator( - this.credentials.tenantId || 'common', - this.credentials.clientId, - { applicationIdUri: this.options.applicationIdUri, loginEndpoint: this.cloud.loginEndpoint, logger: this.log } - ); - } - - // Determine HTTP server const plugins: Array = this.options.plugins || []; const httpPlugin = plugins.find((p) => { const meta = getMetadata(p); return meta.name === 'http'; }) as HttpPlugin | undefined; - // Error if both httpServerAdapter and http plugin are provided if (this.options.httpServerAdapter && httpPlugin) { throw new Error( 'Cannot provide both httpServerAdapter option and HttpPlugin in plugins array. ' + @@ -378,34 +321,46 @@ export class App { ); } - let server: HttpServer; + let httpServer: HttpServer | undefined; - // HttpPlugin in plugins array (backwards compatibility) if (httpPlugin) { this.log.warn('[DEPRECATED] HttpPlugin in plugins array will be deprecated. Use httpServerAdapter option instead:\n' + ' new App({ httpServerAdapter: new ExpressAdapter() })'); this.http = httpPlugin; - // Extract internal server and always set this.server - server = (httpPlugin as any).asServer?.(); - if (!server) { + httpServer = (httpPlugin as any).asServer?.(); + if (!httpServer) { throw new Error('HttpPlugin.asServer() returned undefined'); } - } else { - server = new HttpServer(this.options.httpServerAdapter ?? new ExpressAdapter(undefined, { - logger: this.log, - onError: (err) => this.onError({ error: err }) - }), { - skipAuth: this.options.skipAuth, - logger: this.log, - messagingEndpoint: this.options.messagingEndpoint ?? '/api/messages', - }); } - // Always set this.server - this.server = server; + this.core = new Core({ + clientId: this.options.clientId, + clientSecret: this.options.clientSecret, + applicationIdUri: this.options.applicationIdUri, + tenantId: this.options.tenantId, + token: this.options.token, + authorize: this.options.authorize, + managedIdentityClientId: this.options.managedIdentityClientId, + client: this.options.client, + logger: this.log, + httpServerAdapter: this.options.httpServerAdapter, + httpServer, + skipAuth: this.options.skipAuth, + messagingEndpoint: this.options.messagingEndpoint, + serviceUrl: this.options.serviceUrl, + apiClientSettings: this.options.apiClientSettings, + cloud: this.options.cloud, + onError: (err) => this.onError({ error: err }), + }); + + this.entraTokenValidator = this.core.createEntraTokenValidator(); + this.core.setActivityHandler((event) => this.onActivity(event)); - // Set callback for handling activities - server.onRequest = (event) => this.onActivity(event); + // initialize ActivitySender for sending activities + this.activitySender = new ActivitySender( + (serviceUrl) => this.core.getApiClient(serviceUrl), + this.log + ); // add injectable items to container this.container.register('id', { useValue: this.id }); @@ -415,12 +370,8 @@ export class App { this.container.register('botToken', { useValue: () => this.getBotToken() }); this.container.register('ILogger', { useValue: this.log }); this.container.register('IStorage', { useValue: this.storage }); - this.container.register(this.client.constructor.name, { - useFactory: () => this.client, - }); - - // Register HTTP server for plugins that need HTTP capabilities - this.container.register('IHttpServer', { useValue: server }); + this.container.register('IHttpServerAdapter', { useValue: this.core.server.adapter }); + this.container.register('IHttpServer', { useValue: this.core.server }); // Register all plugins (including HttpPlugin if using old way) for (const plugin of plugins) { @@ -485,11 +436,8 @@ export class App { } } - // initialize server - await this.server.initialize({ - credentials: this.credentials, - cloud: this.cloud, - }); + // initialize Core + await this.core.initialize(); this.isInitialized = true; } @@ -512,8 +460,8 @@ export class App { } this.events.emit('start', this.log); - // Start HTTP server - await this.server.start(this.port); + // Start Core + await this.core.start(this.port); } catch (error: any) { await this.stop(); this.onError({ error }); @@ -532,8 +480,8 @@ export class App { } } - // Stop HTTP server - await this.server.stop(); + // Stop Core + await this.core.stop(); } catch (error: any) { this.onError({ error }); } @@ -702,25 +650,17 @@ export class App { /// protected async getBotToken() { - if (!this.tokenManager) return; - return await this.tokenManager.getBotToken(); + return (await this.core.getBotToken()) ?? null; } protected async getUserToken( channelId: ChannelID, userId: string ) { - const res = await this.api.users.token.get({ - channelId, - userId, - connectionName: this.oauth.defaultConnectionName, - }); - - return res.token; + return await this.core.getUserToken(channelId, userId, this.oauth.defaultConnectionName); } protected async getAppGraphToken(tenantId?: string) { - if (!this.tokenManager) return; - return await this.tokenManager.getGraphToken(tenantId); + return (await this.core.getAppGraphToken(tenantId)) ?? null; } } diff --git a/packages/apps/src/core/core.ts b/packages/apps/src/core/core.ts new file mode 100644 index 000000000..022ce9ec3 --- /dev/null +++ b/packages/apps/src/core/core.ts @@ -0,0 +1,281 @@ +import { + ApiClientSettings, + ChannelID, + CloudEnvironment, + cloudFromName, + Credentials, + InvokeResponse, + JsonWebToken, + PUBLIC, + TokenCredentials, +} from '@microsoft/teams.api'; +import { Client as GraphClient } from '@microsoft/teams.graph'; +import { + Client as HttpClient, + type ClientOptions as HttpClientOptions, + ConsoleLogger, + ILogger, +} from '@microsoft/teams.common'; + +import pkg from '../../package.json'; + +import { ApiClient } from '../api'; +import { IActivityEvent } from '../events'; +import { ExpressAdapter, IHttpServerAdapter } from '../http'; +import { HttpServer } from '../http/http-server'; +import * as middleware from '../middleware'; +import { Authorize, AuthorizationRequest, TokenManager } from '../token-manager'; + +export type CoreActivityHandler = ( + event: IActivityEvent +) => Promise; + +export type CoreOptions = { + readonly clientId?: string; + readonly clientSecret?: string; + readonly applicationIdUri?: string; + readonly tenantId?: string; + readonly token?: TokenCredentials['token']; + readonly authorize?: Authorize; + managedIdentityClientId?: 'system' | (string & {}); + readonly client?: HttpClient | HttpClientOptions | (() => HttpClient); + readonly logger?: ILogger; + readonly httpServerAdapter?: IHttpServerAdapter; + readonly httpServer?: HttpServer; + readonly skipAuth?: boolean; + readonly messagingEndpoint?: `/${string}`; + readonly serviceUrl?: string; + readonly apiClientSettings?: ApiClientSettings; + readonly cloud?: CloudEnvironment; + readonly onError?: (err: Error) => void; +}; + +export class Core { + readonly api: ApiClient; + readonly client: HttpClient; + readonly cloud: CloudEnvironment; + readonly graph: GraphClient; + readonly graphBaseUrl?: string; + readonly server: HttpServer; + readonly tokenManager: TokenManager; + + private readonly _userAgent = `teams.ts[apps]/${pkg.version}`; + private readonly serviceUrl: string; + + get credentials(): Credentials | undefined { + // Keep credentials available internally for existing App identity surfaces: + // inbound auth initialization, manifest defaults, and plugin dependency injection. + return this.tokenManager.credentials; + } + + constructor(readonly options: CoreOptions = {}) { + const log = this.options.logger ?? new ConsoleLogger('@teams/core'); + + const cloudEnvName = typeof process !== 'undefined' ? process.env.CLOUD : undefined; + this.cloud = this.options.cloud ?? (cloudEnvName ? cloudFromName(cloudEnvName) : PUBLIC); + + this.client = this.createHttpClient(options); + + this.tokenManager = new TokenManager({ + clientId: this.options.clientId, + clientSecret: this.options.clientSecret, + tenantId: this.options.tenantId, + // Preserve the legacy token(scope, tenantId) factory as the default + // authorizer fallback when options.authorize returns undefined. + token: this.options.token, + managedIdentityClientId: this.options.managedIdentityClientId, + cloud: this.cloud, + }, log); + + this.serviceUrl = (this.options.serviceUrl ?? process.env.SERVICE_URL ?? + 'https://smba.trafficmanager.net/teams').replace(/\/+$/, ''); + + this.api = this.getApiClient(this.serviceUrl); + + const graphUrlMatch = /^(https?:\/\/[^/]+)/i.exec((this.cloud.graphScope ?? '').trim()); + this.graphBaseUrl = graphUrlMatch?.[1]; + if (!this.graphBaseUrl && this.cloud.graphScope) { + log.warn( + `graphScope "${this.cloud.graphScope}" is not a URL; Graph calls will route to the public cloud. ` + + 'Set graphScope to an "https:///.default" value to route to the correct Graph endpoint.' + ); + } + + this.graph = this.getAppGraphClient(); + + if (this.options.httpServer) { + this.server = this.options.httpServer; + } else { + const httpAdapter = this.options.httpServerAdapter ?? new ExpressAdapter(undefined, { + logger: log, + onError: (err) => this.options.onError?.(err), + }); + this.server = new HttpServer(httpAdapter, { + skipAuth: this.options.skipAuth, + logger: log, + messagingEndpoint: this.options.messagingEndpoint ?? '/api/messages', + }); + } + } + + registerRoute: IHttpServerAdapter['registerRoute'] = (...args) => { + return this.server.adapter.registerRoute(...args); + }; + + serveStatic: NonNullable = (...args) => { + return this.server.adapter.serveStatic?.(...args); + }; + + setActivityHandler(handler: CoreActivityHandler): void { + this.server.onRequest = handler; + } + + async initialize() { + await this.server.initialize({ + credentials: this.credentials, + cloud: this.cloud, + }); + } + + async start(port: number | string) { + await this.server.start(port); + } + + async stop() { + await this.server.stop(); + } + + getApiClient(serviceUrl = this.serviceUrl): ApiClient { + return new ApiClient( + serviceUrl, + this.client.clone({ + token: () => this.authorize({ + kind: 'bot', + scope: this.cloud.botScope, + tenantId: this.resolveBotTenantId(), + }), + }), + this.options.apiClientSettings, + this.cloud + ); + } + + getAppGraphClient(tenantId?: string): GraphClient { + return new GraphClient( + this.client.clone({ + token: () => this.authorize({ + kind: 'appGraph', + scope: this.cloud.graphScope, + tenantId: this.resolveGraphTenantId(tenantId), + }), + }), + { baseUrlRoot: this.graphBaseUrl } + ); + } + + createUserGraphClient(userToken?: string): GraphClient { + return new GraphClient( + this.client.clone({ token: () => userToken }), + { baseUrlRoot: this.graphBaseUrl } + ); + } + + async getBotToken() { + const token = await this.authorize({ + kind: 'bot', + scope: this.cloud.botScope, + tenantId: this.resolveBotTenantId(), + }); + + return token ? new JsonWebToken(token) : null; + } + + async getAppGraphToken(tenantId?: string) { + const token = await this.authorize({ + kind: 'appGraph', + scope: this.cloud.graphScope, + tenantId: this.resolveGraphTenantId(tenantId), + }); + + return token ? new JsonWebToken(token) : null; + } + + private async authorize(request: AuthorizationRequest): Promise { + const custom = await this.options.authorize?.(request); + if (custom !== undefined) { + return custom; + } + + return await this.tokenManager.authorize(request); + } + + private resolveBotTenantId() { + return this.credentials?.tenantId || this.cloud.loginTenant; + } + + private resolveGraphTenantId(tenantId?: string) { + return tenantId || this.credentials?.tenantId || 'common'; + } + + // User delegated auth is intentionally not part of Core's authorize seam yet. + // App owns the OAuth/user-token flow for now because that model is still subject to change. + async getUserToken( + channelId: ChannelID, + userId: string, + connectionName: string + ) { + const res = await this.api.users.token.get({ + channelId, + userId, + connectionName, + }); + + return res.token; + } + + createEntraTokenValidator() { + if (!this.credentials?.clientId) return undefined; + + return middleware.createEntraTokenValidator( + this.credentials.tenantId || 'common', + this.credentials.clientId, + { + applicationIdUri: this.options.applicationIdUri, + loginEndpoint: this.cloud.loginEndpoint, + logger: this.options.logger, + } + ); + } + + private createHttpClient(options: CoreOptions): HttpClient { + if (!options.client) { + return new HttpClient({ + headers: { + 'User-Agent': this._userAgent, + }, + }); + } + + if (typeof options.client === 'function') { + return options.client().clone({ + headers: { + 'User-Agent': this._userAgent, + }, + }); + } + + if ('request' in options.client) { + return options.client.clone({ + headers: { + 'User-Agent': this._userAgent, + }, + }); + } + + return new HttpClient(options.client).clone({ + headers: { + 'User-Agent': this._userAgent, + }, + }); + } +} diff --git a/packages/apps/src/core/index.ts b/packages/apps/src/core/index.ts new file mode 100644 index 000000000..4b0e04137 --- /dev/null +++ b/packages/apps/src/core/index.ts @@ -0,0 +1 @@ +export * from './core'; diff --git a/packages/apps/src/index.ts b/packages/apps/src/index.ts index db94068e7..4e0cae9fd 100644 --- a/packages/apps/src/index.ts +++ b/packages/apps/src/index.ts @@ -4,6 +4,13 @@ export * from './types'; export * from './contexts'; export * from './oauth'; export * from './events'; +export type { + AppGraphAuthorizationRequest, + Authorize, + AuthorizationKind, + AuthorizationRequest, + BotAuthorizationRequest, +} from './token-manager'; export * as manifest from './manifest'; // HTTP infrastructure - public API diff --git a/packages/apps/src/token-manager.ts b/packages/apps/src/token-manager.ts index b64182706..e7959155c 100644 --- a/packages/apps/src/token-manager.ts +++ b/packages/apps/src/token-manager.ts @@ -37,6 +37,31 @@ function isFederatedIdentityCredentials(credentials: Credentials): credentials i } +export type BotAuthorizationRequest = { + readonly kind: 'bot'; + readonly scope: string; + /** + * Resolved tenant ID to use for token acquisition. + */ + readonly tenantId: string; +}; + +export type AppGraphAuthorizationRequest = { + readonly kind: 'appGraph'; + readonly scope: string; + /** + * Resolved tenant ID to use for token acquisition. + */ + readonly tenantId: string; +}; + +export type AuthorizationKind = AuthorizationRequest['kind']; +export type AuthorizationRequest = BotAuthorizationRequest | AppGraphAuthorizationRequest; + +export type Authorize = ( + request: AuthorizationRequest +) => string | null | undefined | Promise; + export type TokenManagerOptions = { readonly clientId?: string; readonly clientSecret?: string; @@ -73,6 +98,11 @@ export class TokenManager { return await this.getToken(this.cloud.graphScope, this.resolveTenantId(tenantId, DEFAULT_TENANT_FOR_GRAPH_TOKEN)); } + async authorize(request: AuthorizationRequest): Promise { + const token = await this.getToken(request.scope, request.tenantId); + return token?.toString() ?? null; + } + private initializeCredentials(options: TokenManagerOptions): Credentials | undefined { const clientId = options.clientId ?? process.env.CLIENT_ID; const tenantId = options.tenantId ?? process.env.TENANT_ID; diff --git a/packages/botbuilder/src/plugin.ts b/packages/botbuilder/src/plugin.ts index 410b22dec..57ebb040a 100644 --- a/packages/botbuilder/src/plugin.ts +++ b/packages/botbuilder/src/plugin.ts @@ -18,7 +18,7 @@ import { Plugin, manifest, } from '@microsoft/teams.apps'; -import { Client as HttpClient, ILogger } from '@microsoft/teams.common'; +import { ILogger } from '@microsoft/teams.common'; import pkg from '../package.json'; @@ -35,9 +35,6 @@ export class BotBuilderPlugin implements IPlugin { @Logger() declare readonly logger: ILogger; - @Dependency() - declare readonly client: HttpClient; - @HttpServer() declare readonly httpServer: IHttpServer;