diff --git a/package-lock.json b/package-lock.json index a9bcbbfae..f08da59eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "tests/*" ], "devDependencies": { - "@changesets/cli": "*", + "@changesets/cli": "latest", "@turbo/gen": "^2.5.7", "bluehawk": "^1.6.0", "turbo": "^2.4.0", @@ -21705,6 +21705,7 @@ "version": "2.0.2", "license": "MIT", "dependencies": { + "@azure/msal-node": "^3.8.1", "axios": "^1.12.0", "cors": "^2.8.5", "express": "^4.21.0", @@ -21738,6 +21739,29 @@ "@microsoft/teams.graph": "2.0.2" } }, + "packages/apps/node_modules/@azure/msal-common": { + "version": "15.13.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.13.1.tgz", + "integrity": "sha512-vQYQcG4J43UWgo1lj7LcmdsGUKWYo28RfEvDQAEMmQIMjSFufvb+pS0FJ3KXmrPmnWlt1vHDl3oip6mIDUQ4uA==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "packages/apps/node_modules/@azure/msal-node": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.8.1.tgz", + "integrity": "sha512-HszfqoC+i2C9+BRDQfuNUGp15Re7menIhCEbFCQ49D3KaqEDrgZIgQ8zSct4T59jWeUIL9N/Dwiv4o2VueTdqQ==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.13.1", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, "packages/apps/node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -22047,6 +22071,15 @@ "node": ">= 0.6" } }, + "packages/apps/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "packages/auth": { "name": "@microsoft/teams.auth", "version": "2.0.0-preview.6", @@ -22835,7 +22868,7 @@ "devDependencies": { "@types/node": "^22.5.4", "dotenv": "^16.4.5", - "env-cmd": "*", + "env-cmd": "latest", "rimraf": "^6.0.1", "tsx": "^4.20.6", "typescript": "^5.4.5" @@ -22902,7 +22935,7 @@ "devDependencies": { "@types/node": "^22.5.4", "dotenv": "^16.4.5", - "env-cmd": "*", + "env-cmd": "latest", "rimraf": "^6.0.1", "tsx": "^4.20.6", "typescript": "^5.4.5" @@ -22921,7 +22954,7 @@ "devDependencies": { "@types/node": "^22.5.4", "dotenv": "^16.5.0", - "env-cmd": "*", + "env-cmd": "latest", "rimraf": "^6.0.1", "tsx": "^4.20.6", "typescript": "^5.4.5" @@ -22943,7 +22976,7 @@ "@microsoft/teams.config": "2.0.2", "@types/node": "^22.5.4", "dotenv": "^16.4.5", - "env-cmd": "*", + "env-cmd": "latest", "rimraf": "^6.0.1", "tsx": "^4.20.6", "typescript": "^5.4.5" @@ -22966,7 +22999,7 @@ "@microsoft/teams.config": "2.0.2", "@types/node": "^22.5.4", "dotenv": "^16.4.5", - "env-cmd": "*", + "env-cmd": "latest", "rimraf": "^6.0.1", "tsx": "^4.20.6", "typescript": "^5.4.5" @@ -23017,7 +23050,7 @@ "@types/node": "^22.5.4", "cross-env": "^7.0.3", "dotenv": "^16.4.5", - "env-cmd": "*", + "env-cmd": "latest", "rimraf": "^6.0.1", "tsx": "^4.20.6", "typescript": "^5.4.5" @@ -23059,7 +23092,7 @@ "@microsoft/teams.config": "2.0.2", "@types/node": "^22.5.4", "dotenv": "^16.4.5", - "env-cmd": "*", + "env-cmd": "latest", "rimraf": "^6.0.1", "tsx": "^4.20.6", "typescript": "^5.4.5" diff --git a/packages/apps/package.json b/packages/apps/package.json index 30363f1a1..35a42cce0 100644 --- a/packages/apps/package.json +++ b/packages/apps/package.json @@ -38,6 +38,7 @@ "generate": "npx quicktype -s schema -l typescript ./manifest.schema.json -o ./src/manifest.ts --prefer-unions --prefer-types --just-types" }, "dependencies": { + "@azure/msal-node": "^3.8.1", "axios": "^1.12.0", "cors": "^2.8.5", "express": "^4.21.0", diff --git a/packages/apps/src/app.process.ts b/packages/apps/src/app.process.ts index ccc2d9684..2700edf98 100644 --- a/packages/apps/src/app.process.ts +++ b/packages/apps/src/app.process.ts @@ -28,8 +28,6 @@ export async function $process( serviceUrl = serviceUrl.slice(0, serviceUrl.length - 1); } - await this.refreshTokens(); - let userToken: string | undefined; try { @@ -38,21 +36,13 @@ export async function $process( // noop } - - let appToken: string | undefined; - try { - appToken = await this.getOrRefreshTenantToken(activity.conversation.tenantId ?? 'common'); - } catch (err) { - // noop - } - const client = this.client.clone(); - const apiClient = new ApiClient(serviceUrl, this.client.clone({ token: () => this.tokens.bot })); + const apiClient = new ApiClient(serviceUrl, this.client.clone({ token: () => this.getBotToken() })); const userGraph = new GraphClient( client.clone({ token: () => userToken }) ); const appGraph = new GraphClient( - client.clone({ token: () => appToken }) + client.clone({ token: () => this.getAppGraphToken(activity.conversation.tenantId ?? 'common') }) ); const ref: ConversationReference = { @@ -121,7 +111,6 @@ export async function $process( appGraph, appId: this.id || '', log: this.log, - tokens: this.tokens, userToken: userToken, ref, storage: this.storage, diff --git a/packages/apps/src/app.spec.ts b/packages/apps/src/app.spec.ts index 97500342c..24d60cd63 100644 --- a/packages/apps/src/app.spec.ts +++ b/packages/apps/src/app.spec.ts @@ -6,8 +6,6 @@ import { App } from './app'; import { HttpPlugin } from './plugins'; import { IPluginStartEvent } from './types'; -const REFRESH_TOKEN_BUFFER_MS = 1000 * 60 * 5; - class TestHttpPlugin extends HttpPlugin { async onStart(_event: IPluginStartEvent) { // No-op for tests @@ -20,137 +18,166 @@ class TestHttpPlugin extends HttpPlugin { class TestApp extends App { // Expose protected members for testing - public get testTokens() { - return this._tokens; + public async testGetBotToken() { + return this.getBotToken(); } - public async testRefreshTokens() { - return this.refreshTokens(); + public async testGetAppGraphToken(tenantId?: string) { + return this.getAppGraphToken(tenantId); } } describe('App', () => { - describe('token refresh', () => { + describe('token acquisition with MSAL', () => { let app: TestApp; - const mockGraphToken = jwt.sign({ access_token: 'mock-graph-token' }, 'test-secret'); - const mockBotToken = jwt.sign({ access_token: 'mock-bot-token' }, 'test-secret'); + const mockBotToken = jwt.sign( + { + exp: Math.floor((Date.now() + 3600000) / 1000), + aud: 'https://api.botframework.com', + iss: 'https://login.microsoftonline.com/test-tenant/v2.0', + }, + 'test-secret' + ); + const mockGraphToken = jwt.sign( + { + exp: Math.floor((Date.now() + 3600000) / 1000), + aud: 'https://graph.microsoft.com', + iss: 'https://login.microsoftonline.com/test-tenant/v2.0', + }, + 'test-secret' + ); beforeEach(() => { app = new TestApp({ clientId: 'test-client-id', clientSecret: 'test-client-secret', + tenantId: 'test-tenant-id', plugins: [new TestHttpPlugin()], }); - - app.api.bots.token.getGraph = jest.fn().mockResolvedValue({ access_token: mockGraphToken }); - app.api.bots.token.get = jest.fn().mockResolvedValue({ access_token: mockBotToken }); }); - it('should refresh bot token when expired', async () => { - // Set expired token with expiration in payload - const expiredToken = jwt.sign( - { - exp: Math.floor((Date.now() - 1000) / 1000), - aud: 'test-audience', - iss: 'test-issuer', - }, - 'test-secret', - { algorithm: 'HS256' } - ); - app.testTokens.bot = new JsonWebToken(expiredToken); + it('should acquire bot token via TokenManager', async () => { + // Mock the MSAL acquireTokenByClientCredential method + const mockAcquireToken = jest.fn().mockResolvedValue({ + accessToken: mockBotToken, + }); + + if (app.tokenManager) { + // @ts-expect-error - accessing private method for testing + jest.spyOn(app.tokenManager, 'getConfidentialClient').mockReturnValue({ + acquireTokenByClientCredential: mockAcquireToken, + } as any); + } - await app.testRefreshTokens(); + const token = await app.testGetBotToken(); - expect(app.api.bots.token.get).toHaveBeenCalledWith(app.credentials); - expect(app.testTokens.bot?.toString()).toBe(mockBotToken); + expect(token).toBeInstanceOf(JsonWebToken); + expect(token?.toString()).toBe(mockBotToken); + expect(mockAcquireToken).toHaveBeenCalledWith({ + scopes: ['https://api.botframework.com/.default'], + }); }); - it('should refresh bot token when not expired but within buffer', async () => { - // Set expired token with expiration in payload - const expiredToken = jwt.sign( - { - exp: Math.floor((Date.now() + REFRESH_TOKEN_BUFFER_MS - 1) / 1000), - aud: 'test-audience', - iss: 'test-issuer', - }, - 'test-secret', - { algorithm: 'HS256' } - ); - app.testTokens.bot = new JsonWebToken(expiredToken); + it('should acquire graph token via TokenManager', async () => { + // Mock the MSAL acquireTokenByClientCredential method + const mockAcquireToken = jest.fn().mockResolvedValue({ + accessToken: mockGraphToken, + }); + + if (app.tokenManager) { + // @ts-expect-error - accessing private method for testing + jest.spyOn(app.tokenManager, 'getConfidentialClient').mockReturnValue({ + acquireTokenByClientCredential: mockAcquireToken, + } as any); + } - await app.testRefreshTokens(); + const token = await app.testGetAppGraphToken(); - expect(app.api.bots.token.get).toHaveBeenCalledWith(app.credentials); - expect(app.testTokens.bot?.toString()).toBe(mockBotToken); + expect(token).toBeInstanceOf(JsonWebToken); + expect(token?.toString()).toBe(mockGraphToken); + expect(mockAcquireToken).toHaveBeenCalledWith({ + scopes: ['https://graph.microsoft.com/.default'], + }); }); - it('should refresh graph token when expired', async () => { - // Set expired token with expiration in payload - const expiredToken = jwt.sign( - { - exp: Math.floor((Date.now() - 1000) / 1000), - aud: 'test-audience', - iss: 'test-issuer', - }, - 'test-secret', - { algorithm: 'HS256' } - ); - app.testTokens.graph = new JsonWebToken(expiredToken); + it('should acquire graph token for specific tenant', async () => { + const tenantId = 'specific-tenant-id'; + const mockAcquireToken = jest.fn().mockResolvedValue({ + accessToken: mockGraphToken, + }); + + if (app.tokenManager) { + // @ts-expect-error - accessing private method for testing + jest.spyOn(app.tokenManager, 'getConfidentialClient').mockReturnValue({ + acquireTokenByClientCredential: mockAcquireToken, + } as any); + } - await app.testRefreshTokens(); + const token = await app.testGetAppGraphToken(tenantId); - expect(app.api.bots.token.getGraph).toHaveBeenCalledWith(app.credentials); - expect(app.testTokens.graph?.toString()).toBe(mockGraphToken); + expect(token).toBeInstanceOf(JsonWebToken); + expect(token?.toString()).toBe(mockGraphToken); }); - it('should refresh graph token when not expired but within buffer', async () => { - // Set expired token with expiration in payload - const expiredToken = jwt.sign( - { - exp: Math.floor((Date.now() + REFRESH_TOKEN_BUFFER_MS - 1) / 1000), - aud: 'test-audience', - iss: 'test-issuer', - }, - 'test-secret', - { algorithm: 'HS256' } - ); - app.testTokens.graph = new JsonWebToken(expiredToken); + it('should return null when tokenManager is not initialized', async () => { + const appWithoutCreds = new TestApp({ + plugins: [new TestHttpPlugin()], + }); + + const botToken = await appWithoutCreds.testGetBotToken(); + const graphToken = await appWithoutCreds.testGetAppGraphToken(); - await app.testRefreshTokens(); + expect(botToken).toBeNull(); + expect(graphToken).toBeNull(); + }); - expect(app.api.bots.token.getGraph).toHaveBeenCalledWith(app.credentials); - expect(app.testTokens.graph?.toString()).toBe(mockGraphToken); + it('should initialize tokenManager when credentials are provided', () => { + expect(app.tokenManager).not.toBeNull(); + expect(app.tokenManager).toBeDefined(); }); - it('should not refresh bot token if not expired', async () => { - const existingToken = jwt.sign( + it('should support TokenCredentials with token provider', async () => { + const mockToken = jwt.sign( { - exp: Math.floor((Date.now() + 1000000) / 1000), - aud: 'test-audience', - iss: 'test-issuer', + exp: Math.floor((Date.now() + 3600000) / 1000), + aud: 'https://api.botframework.com', }, - 'test-secret', - { algorithm: 'HS256' } + 'test-secret' ); - app.testTokens.bot = new JsonWebToken(existingToken); - // the function should never be called - app.api.bots.token.get = jest.fn(); + const tokenProvider = jest.fn().mockResolvedValue(mockToken); + + const appWithTokenProvider = new TestApp({ + clientId: 'test-client-id', + token: tokenProvider, + plugins: [new TestHttpPlugin()], + }); + + expect(appWithTokenProvider.tokenManager).not.toBeNull(); - await app.testRefreshTokens(); + const token = await appWithTokenProvider.testGetBotToken(); - expect(app.api.bots.token.get).not.toHaveBeenCalled(); - expect(app.testTokens.bot?.toString()).toBe(existingToken); + expect(token).toBeInstanceOf(JsonWebToken); + expect(tokenProvider).toHaveBeenCalledWith( + 'https://api.botframework.com/.default', + 'botframework.com' + ); }); - it('should refresh both tokens on app start', async () => { - app.api.bots.token.get = jest.fn().mockResolvedValue({ access_token: mockBotToken }); - app.api.bots.token.getGraph = jest.fn().mockResolvedValue({ access_token: mockGraphToken }); + it('should start app without prefetching tokens', async () => { + const mockAcquireToken = jest.fn(); + + if (app.tokenManager) { + // @ts-expect-error - accessing private method for testing + jest.spyOn(app.tokenManager, 'getConfidentialClient').mockReturnValue({ + acquireTokenByClientCredential: mockAcquireToken, + } as any); + } await app.start(); - expect(app.api.bots.token.get).toHaveBeenCalled(); - expect(app.api.bots.token.getGraph).toHaveBeenCalled(); + // Tokens should not be acquired during start + expect(mockAcquireToken).not.toHaveBeenCalled(); }); }); -}); \ No newline at end of file +}); diff --git a/packages/apps/src/app.ts b/packages/apps/src/app.ts index 3b8f19bc5..48418e806 100644 --- a/packages/apps/src/app.ts +++ b/packages/apps/src/app.ts @@ -5,8 +5,6 @@ import { ChannelID, ConversationReference, Credentials, - IToken, - JsonWebToken, StripMentionsTextOptions, toActivityParams } from '@microsoft/teams.api'; @@ -40,6 +38,7 @@ 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 { AppEvents, IPlugin } from './types'; import { PluginAdditionalContext } from './types/app-routing'; @@ -98,18 +97,6 @@ export type AppActivityOptions = { }; }; -export type AppTokens = { - /** - * bot token used to send activities - */ - bot?: IToken; - - /** - * graph token used to query the graph api - */ - graph?: IToken; -}; - /** * The orchestrator for receiving/sending activities */ @@ -122,19 +109,22 @@ export class App { readonly storage: IStorage; readonly credentials?: Credentials; readonly entraTokenValidator?: middleware.JwtValidator; + readonly tokenManager: TokenManager | null; /** * the apps id */ get id() { - return this.tokens.bot?.appId || this.tokens.graph?.appId; + return this.credentials?.clientId; } /** * the apps name + * @deprecated Name will be removed in the near future. Please remove dependencies from it. */ get name() { - return this.tokens.bot?.appDisplayName || this.tokens.graph?.appDisplayName; + this.log.warn('app.name will be removed in the future'); + return this._manifest.name?.full; } get oauth() { @@ -151,9 +141,8 @@ export class App { return { id: this.id, name: { - short: this.name || '??', - full: this.name || '??', - ...this._manifest.name, + short: this._manifest.name?.short || '??', + full: this._manifest.name?.full || '??', }, bots: [ { @@ -172,14 +161,6 @@ export class App { } protected readonly _manifest: Partial; - /** - * the apps auth tokens - */ - get tokens(): AppTokens { - return this._tokens; - } - protected _tokens: AppTokens = {}; - protected container = new Container(); protected plugins: Array = []; protected router = new Router>(); @@ -224,11 +205,11 @@ export class App { this.api = new ApiClient( 'https://smba.trafficmanager.net/teams', - this.client.clone({ token: () => this._tokens.bot }) + this.client.clone({ token: () => this.getBotToken() }) ); this.graph = new GraphClient( - this.client.clone({ token: () => this._tokens.graph }) + this.client.clone({ token: () => this.getAppGraphToken() }) ); // initialize credentials @@ -258,6 +239,8 @@ export class App { }; } + this.tokenManager = new TokenManager(this.credentials, this.log); + if (clientId) { this.entraTokenValidator = middleware.createEntraTokenValidator( tenantId || 'common', @@ -289,10 +272,7 @@ export class App { this.container.register('name', { useValue: this.name }); this.container.register('manifest', { useValue: this.manifest }); this.container.register('credentials', { useValue: this.credentials }); - this.container.register('botToken', { useValue: () => this.tokens.bot }); - this.container.register('graphToken', { - useValue: () => this.tokens.graph, - }); + 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, { @@ -345,8 +325,6 @@ export class App { this.port = port || process.env.PORT || 3978; try { - await this.refreshTokens(true); - // initialize plugins for (const plugin of this.plugins) { // inject dependencies @@ -504,36 +482,9 @@ export class App { /// Token /// - /** - * Refresh the tokens for the app - */ - protected async refreshTokens(force = false) { - return Promise.all([ - this.refreshBotToken(force), - this.refreshGraphToken(force), - ]); - } - - protected async refreshBotToken(force = false) { - if (!this.credentials) return; - if (!this.tokens.bot?.isExpired() && !force) return; - if (this.tokens.bot) { - this.log.debug('refreshing bot token'); - } - - const botResponse = await this.api.bots.token.get(this.credentials); - this._tokens.bot = new JsonWebToken(botResponse.access_token); - } - - protected async refreshGraphToken(force = false) { - if (!this.credentials) return; - if (!this.tokens.graph?.isExpired() && !force) return; - if (this.tokens.graph) { - this.log.debug('refreshing graph token'); - } - - const graphResponse = await this.api.bots.token.getGraph(this.credentials); - this._tokens.graph = new JsonWebToken(graphResponse.access_token); + protected async getBotToken() { + if (!this.tokenManager) return; + return await this.tokenManager.getBotToken(); } protected async getUserToken( @@ -549,21 +500,8 @@ export class App { return res.token; } - protected async getOrRefreshTenantToken(tenantId: string) { - let appToken = - this.tenantTokens.get(tenantId); - if (this.credentials && !this.tenantTokens.get(tenantId)) { - const { access_token } = await this.api.bots.token.getGraph({ - ...this.credentials, - tenantId: tenantId, - }); - - this.log.debug(`refreshing tenant token for ${tenantId}`); - - appToken = access_token; - this.tenantTokens.set(tenantId, access_token); - } - - return appToken; + protected async getAppGraphToken(tenantId?: string) { + if (!this.tokenManager) return; + return await this.tokenManager.getGraphToken(tenantId); } } diff --git a/packages/apps/src/plugins/http/plugin.ts b/packages/apps/src/plugins/http/plugin.ts index c1ed88976..f1ab6a331 100644 --- a/packages/apps/src/plugins/http/plugin.ts +++ b/packages/apps/src/plugins/http/plugin.ts @@ -58,9 +58,6 @@ export class HttpPlugin implements ISender { @Dependency({ optional: true }) readonly botToken?: () => IToken; - @Dependency({ optional: true }) - readonly graphToken?: () => IToken; - @Event('error') readonly $onError!: (event: IErrorEvent) => void; diff --git a/packages/apps/src/token-manager.spec.ts b/packages/apps/src/token-manager.spec.ts new file mode 100644 index 000000000..7b84c4e67 --- /dev/null +++ b/packages/apps/src/token-manager.spec.ts @@ -0,0 +1,392 @@ +import { AuthenticationResult, ConfidentialClientApplication } from '@azure/msal-node'; +import { type MockedObject } from 'jest-mock'; + +import { ClientCredentials, TokenCredentials } from '@microsoft/teams.api'; +import { ConsoleLogger } from '@microsoft/teams.common'; + +import { TokenManager } from './token-manager'; + +jest.mock('@azure/msal-node'); +jest.mock('@microsoft/teams.api', () => { + const actual = jest.requireActual('@microsoft/teams.api'); + return { + ...actual, + JsonWebToken: jest.fn().mockImplementation((value: string) => ({ + toString: () => value, + appId: 'mock-app-id', + serviceUrl: 'https://smba.trafficmanager.net/teams', + from: 'bot' as const, + fromId: '28:mock-app-id', + isExpired: () => false + })) + }; +}); + +const createMockAuthResult = (accessToken: string): AuthenticationResult => ({ + accessToken, + account: null, + authority: '', + uniqueId: '', + tenantId: '', + scopes: [], + idToken: '', + idTokenClaims: {}, + fromCache: false, + correlationId: '', + expiresOn: null, + extExpiresOn: undefined, + familyId: '', + tokenType: '', + state: '', + cloudGraphHostName: '', + msGraphHost: '', + code: '', + fromNativeBroker: false +}); + +describe('TokenManager', () => { + let mockConfidentialClient: MockedObject; + let mockAcquireTokenByClientCredential: jest.Mock; + let logger: ConsoleLogger; + + const mockClientCredentials: ClientCredentials = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + tenantId: 'test-tenant-id' + }; + + beforeEach(() => { + jest.clearAllMocks(); + + logger = new ConsoleLogger('TokenManager'); + + // Mock the acquireTokenByClientCredential method + mockAcquireTokenByClientCredential = jest.fn(); + + // Mock the ConfidentialClientApplication instance + mockConfidentialClient = { + acquireTokenByClientCredential: mockAcquireTokenByClientCredential + } as unknown as MockedObject; + + // Mock the ConfidentialClientApplication constructor + (ConfidentialClientApplication as jest.MockedClass).mockImplementation(() => mockConfidentialClient); + }); + + describe('getBotToken', () => { + it('should acquire token with correct bot framework scope and tenant', async () => { + mockAcquireTokenByClientCredential.mockResolvedValue(createMockAuthResult('mock-bot-token')); + + const tokenManager = new TokenManager(mockClientCredentials, logger); + const token = await tokenManager.getBotToken(); + + expect(ConfidentialClientApplication).toHaveBeenCalledWith({ + auth: { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + authority: 'https://login.microsoftonline.com/test-tenant-id' + } + }); + + expect(mockAcquireTokenByClientCredential).toHaveBeenCalledWith({ + scopes: ['https://api.botframework.com/.default'] + }); + + expect(token).not.toBeNull(); + expect(token?.toString()).toBe('mock-bot-token'); + }); + + it('should use default bot framework tenant when credentials have no tenantId', async () => { + const credentialsWithoutTenant: ClientCredentials = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret' + }; + + mockAcquireTokenByClientCredential.mockResolvedValue(createMockAuthResult('mock-bot-token')); + + const tokenManager = new TokenManager(credentialsWithoutTenant, logger); + await tokenManager.getBotToken(); + + expect(ConfidentialClientApplication).toHaveBeenCalledWith({ + auth: { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + authority: 'https://login.microsoftonline.com/botframework.com' + } + }); + }); + + it('should return null when no credentials are provided', async () => { + const tokenManager = new TokenManager(undefined, logger); + const token = await tokenManager.getBotToken(); + + expect(token).toBeNull(); + expect(ConfidentialClientApplication).not.toHaveBeenCalled(); + }); + + it('should throw error when MSAL returns null', async () => { + mockAcquireTokenByClientCredential.mockResolvedValue(null); + + const tokenManager = new TokenManager(mockClientCredentials, logger); + + await expect(tokenManager.getBotToken()).rejects.toThrow('Failed to get token'); + }); + }); + + describe('getGraphToken', () => { + it('should acquire token with correct graph scope and tenant', async () => { + mockAcquireTokenByClientCredential.mockResolvedValue(createMockAuthResult('mock-graph-token')); + + const tokenManager = new TokenManager(mockClientCredentials, logger); + const token = await tokenManager.getGraphToken(); + + expect(ConfidentialClientApplication).toHaveBeenCalledWith({ + auth: { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + authority: 'https://login.microsoftonline.com/test-tenant-id' + } + }); + + expect(mockAcquireTokenByClientCredential).toHaveBeenCalledWith({ + scopes: ['https://graph.microsoft.com/.default'] + }); + + expect(token).not.toBeNull(); + expect(token?.toString()).toBe('mock-graph-token'); + }); + + it('should use provided tenant ID when specified', async () => { + mockAcquireTokenByClientCredential.mockResolvedValue(createMockAuthResult('mock-graph-token')); + + const tokenManager = new TokenManager(mockClientCredentials, logger); + await tokenManager.getGraphToken('custom-tenant-id'); + + expect(ConfidentialClientApplication).toHaveBeenCalledWith({ + auth: { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + authority: 'https://login.microsoftonline.com/custom-tenant-id' + } + }); + }); + + it('should use default common tenant when no tenant is specified', async () => { + const credentialsWithoutTenant: ClientCredentials = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret' + }; + + mockAcquireTokenByClientCredential.mockResolvedValue(createMockAuthResult('mock-graph-token')); + + const tokenManager = new TokenManager(credentialsWithoutTenant, logger); + await tokenManager.getGraphToken(); + + expect(ConfidentialClientApplication).toHaveBeenCalledWith({ + auth: { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + authority: 'https://login.microsoftonline.com/common' + } + }); + }); + }); + + describe('ConfidentialClientApplication caching', () => { + it('should cache and reuse ConfidentialClientApplication per tenant', async () => { + mockAcquireTokenByClientCredential.mockResolvedValue(createMockAuthResult('mock-token')); + + const tokenManager = new TokenManager(mockClientCredentials, logger); + + // First call - should create new client + await tokenManager.getBotToken(); + expect(ConfidentialClientApplication).toHaveBeenCalledTimes(1); + + // Second call with same tenant - should reuse cached client + await tokenManager.getBotToken(); + expect(ConfidentialClientApplication).toHaveBeenCalledTimes(1); + + // Third call - should reuse cached client + await tokenManager.getGraphToken(); + expect(ConfidentialClientApplication).toHaveBeenCalledTimes(1); + }); + + it('should create separate ConfidentialClientApplication instances for different tenants', async () => { + mockAcquireTokenByClientCredential.mockResolvedValue(createMockAuthResult('mock-token')); + + const tokenManager = new TokenManager(mockClientCredentials, logger); + + // Call with first tenant + await tokenManager.getGraphToken('tenant-1'); + expect(ConfidentialClientApplication).toHaveBeenCalledTimes(1); + expect(ConfidentialClientApplication).toHaveBeenLastCalledWith({ + auth: { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + authority: 'https://login.microsoftonline.com/tenant-1' + } + }); + + // Call with second tenant - should create new client + await tokenManager.getGraphToken('tenant-2'); + expect(ConfidentialClientApplication).toHaveBeenCalledTimes(2); + expect(ConfidentialClientApplication).toHaveBeenLastCalledWith({ + auth: { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + authority: 'https://login.microsoftonline.com/tenant-2' + } + }); + + // Call with first tenant again - should reuse cached client + await tokenManager.getGraphToken('tenant-1'); + expect(ConfidentialClientApplication).toHaveBeenCalledTimes(2); + }); + }); + + describe('TokenCredentials provider', () => { + it('should use token provider for bot token when TokenCredentials provided', async () => { + const mockTokenProvider = jest.fn().mockResolvedValue('mock-provider-token'); + const tokenCredentials: TokenCredentials = { + clientId: 'test-client-id', + token: mockTokenProvider, + tenantId: 'test-tenant-id' + }; + + const tokenManager = new TokenManager(tokenCredentials, logger); + const token = await tokenManager.getBotToken(); + + expect(mockTokenProvider).toHaveBeenCalledWith( + 'https://api.botframework.com/.default', + 'test-tenant-id' + ); + expect(token).not.toBeNull(); + expect(token?.toString()).toBe('mock-provider-token'); + + // MSAL should not be called + expect(ConfidentialClientApplication).not.toHaveBeenCalled(); + }); + + it('should use token provider for graph token when TokenCredentials provided', async () => { + const mockTokenProvider = jest.fn().mockResolvedValue('mock-graph-provider-token'); + const tokenCredentials: TokenCredentials = { + clientId: 'test-client-id', + token: mockTokenProvider, + tenantId: 'test-tenant-id' + }; + + const tokenManager = new TokenManager(tokenCredentials, logger); + const token = await tokenManager.getGraphToken('custom-tenant'); + + expect(mockTokenProvider).toHaveBeenCalledWith( + 'https://graph.microsoft.com/.default', + 'custom-tenant' + ); + expect(token).not.toBeNull(); + expect(token?.toString()).toBe('mock-graph-provider-token'); + + // MSAL should not be called + expect(ConfidentialClientApplication).not.toHaveBeenCalled(); + }); + + it('should use default tenant for token provider when no tenant specified', async () => { + const mockTokenProvider = jest.fn().mockResolvedValue('mock-token'); + const tokenCredentials: TokenCredentials = { + clientId: 'test-client-id', + token: mockTokenProvider + }; + + const tokenManager = new TokenManager(tokenCredentials, logger); + await tokenManager.getGraphToken(); + + expect(mockTokenProvider).toHaveBeenCalledWith( + 'https://graph.microsoft.com/.default', + 'common' + ); + }); + }); + + describe('tenant ID resolution', () => { + it('should prioritize explicit tenant ID over credentials tenant ID', async () => { + const mockTokenProvider = jest.fn().mockResolvedValue('mock-token'); + const tokenCredentials: TokenCredentials = { + clientId: 'test-client-id', + token: mockTokenProvider, + tenantId: 'credentials-tenant' + }; + + const tokenManager = new TokenManager(tokenCredentials, logger); + await tokenManager.getGraphToken('explicit-tenant'); + + expect(mockTokenProvider).toHaveBeenCalledWith( + 'https://graph.microsoft.com/.default', + 'explicit-tenant' + ); + }); + + it('should use credentials tenant ID when explicit tenant not provided', async () => { + const mockTokenProvider = jest.fn().mockResolvedValue('mock-token'); + const tokenCredentials: TokenCredentials = { + clientId: 'test-client-id', + token: mockTokenProvider, + tenantId: 'credentials-tenant' + }; + + const tokenManager = new TokenManager(tokenCredentials, logger); + await tokenManager.getGraphToken(); + + expect(mockTokenProvider).toHaveBeenCalledWith( + 'https://graph.microsoft.com/.default', + 'credentials-tenant' + ); + }); + + it('should use default tenant when neither explicit nor credentials tenant provided', async () => { + const mockTokenProvider = jest.fn().mockResolvedValue('mock-token'); + const tokenCredentials: TokenCredentials = { + clientId: 'test-client-id', + token: mockTokenProvider + }; + + const tokenManager = new TokenManager(tokenCredentials, logger); + await tokenManager.getBotToken(); + + expect(mockTokenProvider).toHaveBeenCalledWith( + 'https://api.botframework.com/.default', + 'botframework.com' + ); + }); + }); + + describe('error handling', () => { + it('should propagate MSAL errors', async () => { + const msalError = new Error('MSAL authentication failed'); + mockAcquireTokenByClientCredential.mockRejectedValue(msalError); + + const tokenManager = new TokenManager(mockClientCredentials, logger); + + await expect(tokenManager.getBotToken()).rejects.toThrow('MSAL authentication failed'); + }); + + it('should throw when MSAL returns null result', async () => { + mockAcquireTokenByClientCredential.mockResolvedValue(null); + + const tokenManager = new TokenManager(mockClientCredentials, logger); + + await expect(tokenManager.getGraphToken()).rejects.toThrow('Failed to get token'); + }); + + it('should propagate token provider errors', async () => { + const providerError = new Error('Token provider failed'); + const mockTokenProvider = jest.fn().mockRejectedValue(providerError); + const tokenCredentials: TokenCredentials = { + clientId: 'test-client-id', + token: mockTokenProvider, + tenantId: 'test-tenant-id' + }; + + const tokenManager = new TokenManager(tokenCredentials, logger); + + await expect(tokenManager.getBotToken()).rejects.toThrow('Token provider failed'); + }); + }); +}); diff --git a/packages/apps/src/token-manager.ts b/packages/apps/src/token-manager.ts new file mode 100644 index 000000000..0dd811c55 --- /dev/null +++ b/packages/apps/src/token-manager.ts @@ -0,0 +1,83 @@ +import { AuthenticationResult, ConfidentialClientApplication } from '@azure/msal-node'; + +import { ClientCredentials, Credentials, IToken, JsonWebToken, TokenCredentials } from '@microsoft/teams.api'; +import { ConsoleLogger, ILogger } from '@microsoft/teams.common'; + +const DEFAULT_BOT_TOKEN_SCOPE = 'https://api.botframework.com/.default'; +const DEFAULT_GRAPH_TOKEN_SCOPE = 'https://graph.microsoft.com/.default'; +const DEFAULT_TENANT_FOR_BOT_TOKEN = 'botframework.com'; +const DEFAULT_TENANT_FOR_GRAPH_TOKEN = 'common'; +const GET_DEFAULT_TOKEN_AUTHORITY = (tenantId: string) => `https://login.microsoftonline.com/${tenantId}`; + +export class TokenManager { + private logger: ILogger; + private confidentialClientsByTenantId: Record = {}; + + constructor(private credentials: Credentials | undefined, logger: ILogger) { + this.logger = logger.child('TokenManager') ?? new ConsoleLogger('TokenManager'); + } + + async getBotToken(): Promise { + return await this.getToken(DEFAULT_BOT_TOKEN_SCOPE, this.resolveTenantId(undefined, DEFAULT_TENANT_FOR_BOT_TOKEN)); + } + + async getGraphToken(tenantId?: string): Promise { + return await this.getToken(DEFAULT_GRAPH_TOKEN_SCOPE, this.resolveTenantId(tenantId, DEFAULT_TENANT_FOR_GRAPH_TOKEN)); + } + + private async getToken(scope: string, tenantId: string): Promise { + if (!this.credentials) { + return null; + } + + if ('clientSecret' in this.credentials) { + return this.getTokenWithClientCredentials(this.credentials, scope, tenantId); + } else if ('token' in this.credentials) { + return this.getTokenWithTokenProvider(this.credentials, scope, tenantId); + } + + this.logger.warn('getToken was called, but credentials did not match any of the available credential types'); + return null; + } + + private async getTokenWithClientCredentials(credentials: ClientCredentials, scope: string, tenantId: string): Promise { + const confidentialClient = this.getConfidentialClient(credentials, tenantId); + const result = await confidentialClient.acquireTokenByClientCredential({ scopes: [scope] }); + return this.handleTokenResponse(result); + } + + private async getTokenWithTokenProvider(credentials: TokenCredentials, scope: string, tenantId: string): Promise { + const token = await credentials.token(scope, tenantId); + + return new JsonWebToken(token); + } + + private resolveTenantId(tenantId: string | undefined, defaultTenantId: string) { + return tenantId || this.credentials?.tenantId || defaultTenantId; + } + + private getConfidentialClient(credentials: ClientCredentials, tenantId: string) { + const cachedClient = this.confidentialClientsByTenantId[tenantId]; + if (cachedClient) { + return cachedClient; + } + + const client = new ConfidentialClientApplication({ + auth: { + clientId: credentials.clientId, + clientSecret: credentials.clientSecret, + authority: GET_DEFAULT_TOKEN_AUTHORITY(tenantId) + } + }); + this.confidentialClientsByTenantId[tenantId] = client; + return client; + } + + private handleTokenResponse(result: AuthenticationResult | null) { + if (!result) { + throw new Error('Failed to get token'); + } + + return new JsonWebToken(result.accessToken); + } +} diff --git a/packages/botbuilder/src/plugin.ts b/packages/botbuilder/src/plugin.ts index 2979ba5e0..b3c6af1fc 100644 --- a/packages/botbuilder/src/plugin.ts +++ b/packages/botbuilder/src/plugin.ts @@ -57,9 +57,6 @@ export class BotBuilderPlugin extends HttpPlugin implements ISender { @Dependency({ optional: true }) declare readonly botToken?: () => IToken; - @Dependency({ optional: true }) - declare readonly graphToken?: () => IToken; - @Event('error') declare readonly $onError: (event: IErrorEvent) => void;