diff --git a/packages/api/src/auth/credentials.ts b/packages/api/src/auth/credentials.ts index 64f61a5e7..0f4ce6760 100644 --- a/packages/api/src/auth/credentials.ts +++ b/packages/api/src/auth/credentials.ts @@ -1,13 +1,14 @@ /** * credentials for app authentication */ -export type Credentials = ClientCredentials | TokenCredentials; +export type Credentials = ClientCredentials | TokenCredentials | UserManagedIdentityCredentials; /** * credentials for authentication * of an app via `clientId` and `clientSecret` */ export type ClientCredentials = { + type: 'clientSecret'; readonly clientId: string; readonly clientSecret: string; readonly tenantId?: string; @@ -18,7 +19,17 @@ export type ClientCredentials = { * of an app via any external auth method */ export type TokenCredentials = { + type: 'token'; readonly clientId: string; readonly tenantId?: string; readonly token: (scope: string | string[], tenantId?: string) => string | Promise; }; + +/** + * credentials for user managed identity +*/ +export type UserManagedIdentityCredentials = { + type: 'userManagedIdentity'; + readonly clientId: string; + readonly tenantId?: string; +}; diff --git a/packages/api/src/clients/bot/token.spec.ts b/packages/api/src/clients/bot/token.spec.ts index af8c270b4..d55d3da7a 100644 --- a/packages/api/src/clients/bot/token.spec.ts +++ b/packages/api/src/clients/bot/token.spec.ts @@ -9,6 +9,7 @@ describe('BotTokenClient', () => { const spy = jest.spyOn(http, 'post').mockResolvedValueOnce({}); await client.get({ + type: 'clientSecret', clientId: 'test', clientSecret: '123', }); @@ -25,6 +26,7 @@ describe('BotTokenClient', () => { const spy = jest.spyOn(client.http, 'post').mockResolvedValueOnce({}); await client.get({ + type: 'clientSecret', clientId: 'test', clientSecret: '123', }); @@ -42,6 +44,7 @@ describe('BotTokenClient', () => { const spy = jest.spyOn(client.http, 'post').mockResolvedValueOnce({}); await client.get({ + type: 'clientSecret', clientId: 'test', clientSecret: '123', }); @@ -58,6 +61,7 @@ describe('BotTokenClient', () => { const spy = jest.spyOn(client.http, 'post').mockResolvedValueOnce({}); await client.get({ + type: 'clientSecret', clientId: 'test', clientSecret: '123', tenantId: 'test-tenant', @@ -77,6 +81,7 @@ describe('BotTokenClient', () => { const spy = jest.spyOn(client.http, 'post').mockResolvedValueOnce({}); await client.getGraph({ + type: 'clientSecret', clientId: 'test', clientSecret: '123', }); @@ -93,6 +98,7 @@ describe('BotTokenClient', () => { const spy = jest.spyOn(client.http, 'post').mockResolvedValueOnce({}); await client.getGraph({ + type: 'clientSecret', clientId: 'test', clientSecret: '123', tenantId: 'test-tenant', diff --git a/packages/api/src/clients/bot/token.ts b/packages/api/src/clients/bot/token.ts index 898870c7e..940017b8d 100644 --- a/packages/api/src/clients/bot/token.ts +++ b/packages/api/src/clients/bot/token.ts @@ -46,6 +46,10 @@ export class BotTokenClient { }; } + if (!('clientSecret' in credentials)) { + throw new Error('Bot Token Client only supports auth via secrets'); + } + const tenantId = credentials.tenantId || 'botframework.com'; const res = await this.http.post( `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, @@ -75,6 +79,10 @@ export class BotTokenClient { }; } + if (!('clientSecret' in credentials)) { + throw new Error('Bot Token Client only supports auth via secrets'); + } + const tenantId = credentials.tenantId || 'botframework.com'; const res = await this.http.post( `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, diff --git a/packages/apps/src/app.spec.ts b/packages/apps/src/app.spec.ts index 24d60cd63..28e8e7fd9 100644 --- a/packages/apps/src/app.spec.ts +++ b/packages/apps/src/app.spec.ts @@ -25,6 +25,10 @@ class TestApp extends App { public async testGetAppGraphToken(tenantId?: string) { return this.getAppGraphToken(tenantId); } + + public getCredentials() { + return this.credentials; + } } describe('App', () => { @@ -180,4 +184,151 @@ describe('App', () => { expect(mockAcquireToken).not.toHaveBeenCalled(); }); }); + + describe('Credentials', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should create ClientCredentials with clientSecret from options or env', () => { + // From options + const appWithSecret = new TestApp({ + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + tenantId: 'test-tenant-id', + plugins: [new TestHttpPlugin()], + }); + + expect(appWithSecret.getCredentials()).toEqual({ + type: 'clientSecret', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + tenantId: 'test-tenant-id', + }); + + // From environment variables + process.env.CLIENT_ID = 'env-client-id'; + process.env.CLIENT_SECRET = 'env-client-secret'; + process.env.TENANT_ID = 'env-tenant-id'; + + const appFromEnv = new TestApp({ + plugins: [new TestHttpPlugin()], + }); + + expect(appFromEnv.getCredentials()).toEqual({ + type: 'clientSecret', + clientId: 'env-client-id', + clientSecret: 'env-client-secret', + tenantId: 'env-tenant-id', + }); + }); + + it('should create TokenCredentials with token provider', () => { + const tokenProvider = jest.fn().mockResolvedValue('mock-token'); + const appWithToken = new TestApp({ + clientId: 'test-client-id', + token: tokenProvider, + tenantId: 'test-tenant-id', + plugins: [new TestHttpPlugin()], + }); + + expect(appWithToken.getCredentials()).toEqual({ + type: 'token', + clientId: 'test-client-id', + token: tokenProvider, + tenantId: 'test-tenant-id', + }); + }); + + it('should create UserManagedIdentity credentials with only clientId from options or env', () => { + // From options + const appWithUMI = new TestApp({ + clientId: 'test-client-id', + tenantId: 'test-tenant-id', + plugins: [new TestHttpPlugin()], + }); + + expect(appWithUMI.getCredentials()).toEqual({ + type: 'userManagedIdentity', + clientId: 'test-client-id', + tenantId: 'test-tenant-id', + }); + + // From environment variables + process.env.CLIENT_ID = 'env-client-id'; + process.env.TENANT_ID = 'env-tenant-id'; + + const appFromEnv = new TestApp({ + plugins: [new TestHttpPlugin()], + }); + + expect(appFromEnv.getCredentials()).toEqual({ + type: 'userManagedIdentity', + clientId: 'env-client-id', + tenantId: 'env-tenant-id', + }); + }); + + it('should prioritize clientSecret over token when both provided', () => { + const tokenProvider = jest.fn().mockResolvedValue('mock-token'); + const appWithBoth = new TestApp({ + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + token: tokenProvider, + tenantId: 'test-tenant-id', + plugins: [new TestHttpPlugin()], + }); + + expect(appWithBoth.getCredentials()).toEqual({ + type: 'clientSecret', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + tenantId: 'test-tenant-id', + }); + }); + + it('should merge options and environment variables', () => { + // Options take precedence over env + process.env.CLIENT_ID = 'env-client-id'; + process.env.CLIENT_SECRET = 'env-client-secret'; + process.env.TENANT_ID = 'env-tenant-id'; + + const appWithOptions = new TestApp({ + clientId: 'options-client-id', + clientSecret: 'options-client-secret', + tenantId: 'options-tenant-id', + plugins: [new TestHttpPlugin()], + }); + + expect(appWithOptions.getCredentials()).toEqual({ + type: 'clientSecret', + clientId: 'options-client-id', + clientSecret: 'options-client-secret', + tenantId: 'options-tenant-id', + }); + + // Mix options and env + process.env.CLIENT_SECRET = 'env-client-secret'; + process.env.TENANT_ID = 'env-tenant-id'; + + const appMerged = new TestApp({ + clientId: 'options-client-id', + plugins: [new TestHttpPlugin()], + }); + + expect(appMerged.getCredentials()).toEqual({ + type: 'clientSecret', + clientId: 'options-client-id', + clientSecret: 'env-client-secret', + tenantId: 'env-tenant-id', + }); + }); + }); }); diff --git a/packages/apps/src/app.ts b/packages/apps/src/app.ts index f4bbadb03..1bd4b64e3 100644 --- a/packages/apps/src/app.ts +++ b/packages/apps/src/app.ts @@ -230,19 +230,28 @@ export class App { const token = 'token' in this.options ? this.options.token : undefined; if (clientId && clientSecret) { + this.log.debug('Using Client Credentials auth'); this.credentials = { + type: 'clientSecret', clientId, clientSecret, tenantId, }; - } - - if (clientId && token) { + } else if (clientId && token) { + this.log.debug(('Using custom token factory auth')); this.credentials = { + type: 'token', clientId, tenantId, token, }; + } else if (clientId && !clientSecret) { + this.log.debug('Using user managed identity auth'); + this.credentials = { + type: 'userManagedIdentity', + clientId, + tenantId + }; } this.tokenManager = new TokenManager(this.credentials, this.log); diff --git a/packages/apps/src/token-manager.spec.ts b/packages/apps/src/token-manager.spec.ts index 7b84c4e67..c4922b14e 100644 --- a/packages/apps/src/token-manager.spec.ts +++ b/packages/apps/src/token-manager.spec.ts @@ -1,7 +1,7 @@ -import { AuthenticationResult, ConfidentialClientApplication } from '@azure/msal-node'; +import { AuthenticationResult, ConfidentialClientApplication, ManagedIdentityApplication } from '@azure/msal-node'; import { type MockedObject } from 'jest-mock'; -import { ClientCredentials, TokenCredentials } from '@microsoft/teams.api'; +import { ClientCredentials, TokenCredentials, UserManagedIdentityCredentials } from '@microsoft/teams.api'; import { ConsoleLogger } from '@microsoft/teams.common'; import { TokenManager } from './token-manager'; @@ -50,6 +50,7 @@ describe('TokenManager', () => { let logger: ConsoleLogger; const mockClientCredentials: ClientCredentials = { + type: 'clientSecret', clientId: 'test-client-id', clientSecret: 'test-client-secret', tenantId: 'test-tenant-id' @@ -97,6 +98,7 @@ describe('TokenManager', () => { it('should use default bot framework tenant when credentials have no tenantId', async () => { const credentialsWithoutTenant: ClientCredentials = { + type: 'clientSecret', clientId: 'test-client-id', clientSecret: 'test-client-secret' }; @@ -172,6 +174,7 @@ describe('TokenManager', () => { it('should use default common tenant when no tenant is specified', async () => { const credentialsWithoutTenant: ClientCredentials = { + type: 'clientSecret', clientId: 'test-client-id', clientSecret: 'test-client-secret' }; @@ -247,6 +250,7 @@ describe('TokenManager', () => { it('should use token provider for bot token when TokenCredentials provided', async () => { const mockTokenProvider = jest.fn().mockResolvedValue('mock-provider-token'); const tokenCredentials: TokenCredentials = { + type: 'token', clientId: 'test-client-id', token: mockTokenProvider, tenantId: 'test-tenant-id' @@ -269,6 +273,7 @@ describe('TokenManager', () => { it('should use token provider for graph token when TokenCredentials provided', async () => { const mockTokenProvider = jest.fn().mockResolvedValue('mock-graph-provider-token'); const tokenCredentials: TokenCredentials = { + type: 'token', clientId: 'test-client-id', token: mockTokenProvider, tenantId: 'test-tenant-id' @@ -291,6 +296,7 @@ describe('TokenManager', () => { it('should use default tenant for token provider when no tenant specified', async () => { const mockTokenProvider = jest.fn().mockResolvedValue('mock-token'); const tokenCredentials: TokenCredentials = { + type: 'token', clientId: 'test-client-id', token: mockTokenProvider }; @@ -309,6 +315,7 @@ describe('TokenManager', () => { it('should prioritize explicit tenant ID over credentials tenant ID', async () => { const mockTokenProvider = jest.fn().mockResolvedValue('mock-token'); const tokenCredentials: TokenCredentials = { + type: 'token', clientId: 'test-client-id', token: mockTokenProvider, tenantId: 'credentials-tenant' @@ -326,6 +333,7 @@ describe('TokenManager', () => { it('should use credentials tenant ID when explicit tenant not provided', async () => { const mockTokenProvider = jest.fn().mockResolvedValue('mock-token'); const tokenCredentials: TokenCredentials = { + type: 'token', clientId: 'test-client-id', token: mockTokenProvider, tenantId: 'credentials-tenant' @@ -343,6 +351,7 @@ describe('TokenManager', () => { it('should use default tenant when neither explicit nor credentials tenant provided', async () => { const mockTokenProvider = jest.fn().mockResolvedValue('mock-token'); const tokenCredentials: TokenCredentials = { + type: 'token', clientId: 'test-client-id', token: mockTokenProvider }; @@ -379,6 +388,7 @@ describe('TokenManager', () => { const providerError = new Error('Token provider failed'); const mockTokenProvider = jest.fn().mockRejectedValue(providerError); const tokenCredentials: TokenCredentials = { + type: 'token', clientId: 'test-client-id', token: mockTokenProvider, tenantId: 'test-tenant-id' @@ -389,4 +399,111 @@ describe('TokenManager', () => { await expect(tokenManager.getBotToken()).rejects.toThrow('Token provider failed'); }); }); + + describe('UserManagedIdentityCredentials', () => { + let mockManagedIdentityClient: MockedObject; + let mockAcquireToken: jest.Mock; + + const mockUMICredentials: UserManagedIdentityCredentials = { + type: 'userManagedIdentity', + clientId: 'test-client-id', + tenantId: 'test-tenant-id' + }; + + beforeEach(() => { + // Mock the acquireToken method + mockAcquireToken = jest.fn(); + + // Mock the ManagedIdentityApplication instance + mockManagedIdentityClient = { + acquireToken: mockAcquireToken + } as unknown as MockedObject; + + // Mock the ManagedIdentityApplication constructor + (ManagedIdentityApplication as jest.MockedClass).mockImplementation(() => mockManagedIdentityClient); + }); + + it('should acquire bot token via ManagedIdentityApplication', async () => { + mockAcquireToken.mockResolvedValue(createMockAuthResult('mock-umi-bot-token')); + + const tokenManager = new TokenManager(mockUMICredentials, logger); + const token = await tokenManager.getBotToken(); + + expect(ManagedIdentityApplication).toHaveBeenCalledWith({ + managedIdentityIdParams: { + userAssignedClientId: 'test-client-id' + } + }); + + expect(mockAcquireToken).toHaveBeenCalledWith({ + resource: 'https://api.botframework.com' + }); + + expect(token).not.toBeNull(); + expect(token?.toString()).toBe('mock-umi-bot-token'); + }); + + it('should acquire graph token via ManagedIdentityApplication', async () => { + mockAcquireToken.mockResolvedValue(createMockAuthResult('mock-umi-graph-token')); + + const tokenManager = new TokenManager(mockUMICredentials, logger); + const token = await tokenManager.getGraphToken(); + + expect(ManagedIdentityApplication).toHaveBeenCalledWith({ + managedIdentityIdParams: { + userAssignedClientId: 'test-client-id' + } + }); + + expect(mockAcquireToken).toHaveBeenCalledWith({ + resource: 'https://graph.microsoft.com' + }); + + expect(token).not.toBeNull(); + expect(token?.toString()).toBe('mock-umi-graph-token'); + }); + + it('should strip /.default suffix from scope when acquiring token', async () => { + mockAcquireToken.mockResolvedValue(createMockAuthResult('mock-token')); + + const tokenManager = new TokenManager(mockUMICredentials, logger); + await tokenManager.getBotToken(); + + // Verify that /.default was stripped from the scope + expect(mockAcquireToken).toHaveBeenCalledWith({ + resource: 'https://api.botframework.com' + }); + }); + + it('should cache and reuse ManagedIdentityApplication instance', async () => { + mockAcquireToken.mockResolvedValue(createMockAuthResult('mock-token')); + + const tokenManager = new TokenManager(mockUMICredentials, logger); + + // First call - should create new client + await tokenManager.getBotToken(); + expect(ManagedIdentityApplication).toHaveBeenCalledTimes(1); + + // Second call - should reuse cached client + await tokenManager.getGraphToken(); + expect(ManagedIdentityApplication).toHaveBeenCalledTimes(1); + }); + + it('should throw error when MSAL returns null', async () => { + mockAcquireToken.mockResolvedValue(null); + + const tokenManager = new TokenManager(mockUMICredentials, logger); + + await expect(tokenManager.getBotToken()).rejects.toThrow('Failed to get token'); + }); + + it('should propagate MSAL errors', async () => { + const msalError = new Error('Managed identity authentication failed'); + mockAcquireToken.mockRejectedValue(msalError); + + const tokenManager = new TokenManager(mockUMICredentials, logger); + + await expect(tokenManager.getGraphToken()).rejects.toThrow('Managed identity authentication failed'); + }); + }); }); diff --git a/packages/apps/src/token-manager.ts b/packages/apps/src/token-manager.ts index 0dd811c55..1f7688669 100644 --- a/packages/apps/src/token-manager.ts +++ b/packages/apps/src/token-manager.ts @@ -1,6 +1,7 @@ -import { AuthenticationResult, ConfidentialClientApplication } from '@azure/msal-node'; +import { AuthenticationResult, ConfidentialClientApplication, ManagedIdentityApplication } from '@azure/msal-node'; import { ClientCredentials, Credentials, IToken, JsonWebToken, TokenCredentials } from '@microsoft/teams.api'; +import { UserManagedIdentityCredentials } from '@microsoft/teams.api/dist/auth/credentials'; import { ConsoleLogger, ILogger } from '@microsoft/teams.common'; const DEFAULT_BOT_TOKEN_SCOPE = 'https://api.botframework.com/.default'; @@ -12,6 +13,7 @@ const GET_DEFAULT_TOKEN_AUTHORITY = (tenantId: string) => `https://login.microso export class TokenManager { private logger: ILogger; private confidentialClientsByTenantId: Record = {}; + private managedIdentityClient: ManagedIdentityApplication | null = null; constructor(private credentials: Credentials | undefined, logger: ILogger) { this.logger = logger.child('TokenManager') ?? new ConsoleLogger('TokenManager'); @@ -30,14 +32,17 @@ export class TokenManager { 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); + switch (this.credentials.type) { + case 'clientSecret': + return this.getTokenWithClientCredentials(this.credentials, scope, tenantId); + case 'token': + return this.getTokenWithTokenProvider(this.credentials, scope, tenantId); + case 'userManagedIdentity': + return this.getTokenWithManagedIdentity(this.credentials, scope); + default: + this.logger.warn('getToken was called, but credentials did not match any of the available credential types'); + return null; } - - 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 { @@ -51,6 +56,15 @@ export class TokenManager { return new JsonWebToken(token); } + private async getTokenWithManagedIdentity(credentials: UserManagedIdentityCredentials, scope: string) { + const managedIdentityClient = this.getManagedIdentityClient(credentials); + // Resource doesn't need the ./default suffix + const resource = scope.replace('/.default', ''); + const result = await managedIdentityClient.acquireToken({ + resource + }); + return this.handleTokenResponse(result); + } private resolveTenantId(tenantId: string | undefined, defaultTenantId: string) { return tenantId || this.credentials?.tenantId || defaultTenantId; @@ -73,6 +87,20 @@ export class TokenManager { return client; } + private getManagedIdentityClient(credentials: UserManagedIdentityCredentials): ManagedIdentityApplication { + if (this.managedIdentityClient) { + return this.managedIdentityClient; + } + + this.managedIdentityClient = new ManagedIdentityApplication({ + managedIdentityIdParams: { + userAssignedClientId: credentials.clientId + } + }); + + return this.managedIdentityClient; + } + private handleTokenResponse(result: AuthenticationResult | null) { if (!result) { throw new Error('Failed to get token');