From 6402bc0302d3cc2153d071e9e303ecf34b10f6fa Mon Sep 17 00:00:00 2001 From: Corina <14900841+corinagum@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:08:20 -0700 Subject: [PATCH 1/3] fix: validate caller app on Entra inbound Agent ID tokens --- .../inbound-activity-token-validator.spec.ts | 79 +++++++++++++++++-- .../auth/inbound-activity-token-validator.ts | 24 +++++- 2 files changed, 97 insertions(+), 6 deletions(-) diff --git a/packages/apps/src/middleware/auth/inbound-activity-token-validator.spec.ts b/packages/apps/src/middleware/auth/inbound-activity-token-validator.spec.ts index 1dca92e67..16027eb56 100644 --- a/packages/apps/src/middleware/auth/inbound-activity-token-validator.spec.ts +++ b/packages/apps/src/middleware/auth/inbound-activity-token-validator.spec.ts @@ -1,6 +1,6 @@ import { US_GOV, CHINA } from '@microsoft/teams.api'; -import { InboundActivityTokenValidator } from './inbound-activity-token-validator'; +import { AGENT_365_PLATFORM_APP_ID, InboundActivityTokenValidator } from './inbound-activity-token-validator'; import { JwtValidator } from './jwt-validator'; // Mock JwtValidator — real one fetches JWKS keys from remote endpoints. @@ -209,7 +209,7 @@ describe('InboundActivityTokenValidator', () => { it('should use Entra validator for v2 issuer tokens without serviceUrl validation', async () => { const validator = new InboundActivityTokenValidator(mockClientId, mockTenantId); mockValidateAccessToken.mockResolvedValue({ - appid: mockClientId, + azp: AGENT_365_PLATFORM_APP_ID, sub: 'agent-id', }); @@ -231,7 +231,7 @@ describe('InboundActivityTokenValidator', () => { it('should use Entra validator for v1 sts issuer tokens', async () => { const validator = new InboundActivityTokenValidator(mockClientId, mockTenantId); - mockValidateAccessToken.mockResolvedValue({ appid: mockClientId, sub: 'agent-id' }); + mockValidateAccessToken.mockResolvedValue({ appid: AGENT_365_PLATFORM_APP_ID, sub: 'agent-id' }); const token = createUnverifiedToken({ iss: `https://sts.windows.net/${mockTenantId}/`, @@ -254,7 +254,7 @@ describe('InboundActivityTokenValidator', () => { it('should cache Entra validators by tenant', async () => { const validator = new InboundActivityTokenValidator(mockClientId, mockTenantId); - mockValidateAccessToken.mockResolvedValue({ appid: mockClientId, sub: 'agent-id' }); + mockValidateAccessToken.mockResolvedValue({ appid: AGENT_365_PLATFORM_APP_ID, sub: 'agent-id' }); const token = createUnverifiedToken({ iss: `https://login.microsoftonline.com/${mockTenantId}/v2.0`, tid: mockTenantId, @@ -270,7 +270,7 @@ describe('InboundActivityTokenValidator', () => { it('should bound Entra validator cache size', async () => { const validator = new InboundActivityTokenValidator(mockClientId, mockTenantId); - mockValidateAccessToken.mockResolvedValue({ appid: mockClientId, sub: 'agent-id' }); + mockValidateAccessToken.mockResolvedValue({ appid: AGENT_365_PLATFORM_APP_ID, sub: 'agent-id' }); for (let i = 0; i < 101; i++) { const tenantId = `tenant-${i}`; @@ -288,6 +288,75 @@ describe('InboundActivityTokenValidator', () => { }); }); + describe('Entra caller app validation', () => { + const otherAppId = '00000000-0000-0000-0000-000000000099'; + const entraToken = createUnverifiedToken({ + iss: `https://login.microsoftonline.com/${mockTenantId}/v2.0`, + tid: mockTenantId, + }); + const authHeader = `Bearer ${entraToken}`; + + it('should accept the Agent 365 platform app in azp', async () => { + const validator = new InboundActivityTokenValidator(mockClientId, mockTenantId); + mockValidateAccessToken.mockResolvedValue({ azp: AGENT_365_PLATFORM_APP_ID, sub: 'agent-id' }); + + const result = await validator.check(authHeader, { serviceUrl: mockServiceUrl }); + + expect(result.fromId).toBe('agent-id'); + }); + + it('should accept the Agent 365 platform app in appid when azp is absent', async () => { + const validator = new InboundActivityTokenValidator(mockClientId, mockTenantId); + mockValidateAccessToken.mockResolvedValue({ appid: AGENT_365_PLATFORM_APP_ID.toUpperCase(), sub: 'agent-id' }); + + const result = await validator.check(authHeader, { serviceUrl: mockServiceUrl }); + + expect(result.fromId).toBe('agent-id'); + }); + + it('should reject another app in azp', async () => { + const validator = new InboundActivityTokenValidator(mockClientId, mockTenantId); + mockValidateAccessToken.mockResolvedValue({ azp: otherAppId, sub: 'agent-id' }); + + await expect(validator.check(authHeader, { serviceUrl: mockServiceUrl })) + .rejects.toThrow('Entra inbound token caller app is not allowed'); + }); + + it('should reject another app in appid', async () => { + const validator = new InboundActivityTokenValidator(mockClientId, mockTenantId); + mockValidateAccessToken.mockResolvedValue({ appid: otherAppId, sub: 'agent-id' }); + + await expect(validator.check(authHeader, { serviceUrl: mockServiceUrl })) + .rejects.toThrow('Entra inbound token caller app is not allowed'); + }); + + it('should reject tokens with neither azp nor appid', async () => { + const validator = new InboundActivityTokenValidator(mockClientId, mockTenantId); + mockValidateAccessToken.mockResolvedValue({ sub: 'agent-id' }); + + await expect(validator.check(authHeader, { serviceUrl: mockServiceUrl })) + .rejects.toThrow('Entra inbound token caller app is not allowed'); + }); + + it('should prefer azp over appid', async () => { + const validator = new InboundActivityTokenValidator(mockClientId, mockTenantId); + mockValidateAccessToken.mockResolvedValue({ azp: otherAppId, appid: AGENT_365_PLATFORM_APP_ID, sub: 'agent-id' }); + + await expect(validator.check(authHeader, { serviceUrl: mockServiceUrl })) + .rejects.toThrow('Entra inbound token caller app is not allowed'); + }); + + it('should not apply the caller app check to Bot Framework tokens', async () => { + const validator = new InboundActivityTokenValidator(mockClientId, mockTenantId); + mockValidateAccessToken.mockResolvedValue({ appid: otherAppId, sub: 'bot-id', serviceurl: mockServiceUrl }); + const botToken = createUnverifiedToken({ iss: 'https://api.botframework.com' }); + + const result = await validator.check(`Bearer ${botToken}`, { serviceUrl: mockServiceUrl }); + + expect(result.appId).toBe(otherAppId); + }); + }); + describe('sovereign cloud support', () => { it('should use public cloud defaults when no cloud provided', () => { new InboundActivityTokenValidator(mockClientId, mockTenantId); diff --git a/packages/apps/src/middleware/auth/inbound-activity-token-validator.ts b/packages/apps/src/middleware/auth/inbound-activity-token-validator.ts index d35dbd464..e369206cb 100644 --- a/packages/apps/src/middleware/auth/inbound-activity-token-validator.ts +++ b/packages/apps/src/middleware/auth/inbound-activity-token-validator.ts @@ -8,6 +8,11 @@ import { JwtValidator, decodeJwtPayload } from './jwt-validator'; const MAX_ENTRA_VALIDATOR_CACHE_SIZE = 100; const ENTRA_V1_ISSUER_PREFIX = 'https://sts.windows.net/'; +/** + * App ID of the Agent 365 platform. + */ +export const AGENT_365_PLATFORM_APP_ID = '5a807f24-c9de-44ee-a3a7-329e88a00ffc'; + /** * Derives the JWKS keys URI from an OpenID metadata URL. * e.g. "https://login.botframework.com/v1/.well-known/openidconfiguration" @@ -110,7 +115,24 @@ export class InboundActivityTokenValidator { const validator = this.getEntraValidator(tenantId); // AgenticIdentity inbound Entra tokens currently do not include serviceurl. // Revisit service URL validation when the platform defines a signed claim. - return await validator.validateAccessToken(rawToken); + const payload = await validator.validateAccessToken(rawToken); + if (payload) { + this.validateEntraCallerApp(payload); + } + return payload; + } + + /** + * Requires the client app that requested the token to be the Agent 365 platform. + * Entra v2 tokens carry the caller in `azp` and v1 tokens carry it in `appid`. + */ + private validateEntraCallerApp(payload: JwtPayload) { + const callerAppId = typeof payload.azp === 'string' && payload.azp + ? payload.azp + : payload.appid; + if (typeof callerAppId !== 'string' || callerAppId.toLowerCase() !== AGENT_365_PLATFORM_APP_ID) { + throw new Error('Entra inbound token caller app is not allowed'); + } } private getEntraValidator(tenantId: string) { From b6f542de29f06b24dd98f635ad6db849368d39e0 Mon Sep 17 00:00:00 2001 From: Corina <14900841+corinagum@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:44:45 -0700 Subject: [PATCH 2/3] reject invalid azp instead of falling back to appid --- .../auth/inbound-activity-token-validator.spec.ts | 11 +++++++++++ .../auth/inbound-activity-token-validator.ts | 5 ++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/apps/src/middleware/auth/inbound-activity-token-validator.spec.ts b/packages/apps/src/middleware/auth/inbound-activity-token-validator.spec.ts index 16027eb56..8ff951de9 100644 --- a/packages/apps/src/middleware/auth/inbound-activity-token-validator.spec.ts +++ b/packages/apps/src/middleware/auth/inbound-activity-token-validator.spec.ts @@ -346,6 +346,17 @@ describe('InboundActivityTokenValidator', () => { .rejects.toThrow('Entra inbound token caller app is not allowed'); }); + it.each([ + ['empty', ''], + ['non-string', 123], + ])('should reject a present but %s azp without falling back to appid', async (_, azp) => { + const validator = new InboundActivityTokenValidator(mockClientId, mockTenantId); + mockValidateAccessToken.mockResolvedValue({ azp, appid: AGENT_365_PLATFORM_APP_ID, sub: 'agent-id' }); + + await expect(validator.check(authHeader, { serviceUrl: mockServiceUrl })) + .rejects.toThrow('Entra inbound token caller app is not allowed'); + }); + it('should not apply the caller app check to Bot Framework tokens', async () => { const validator = new InboundActivityTokenValidator(mockClientId, mockTenantId); mockValidateAccessToken.mockResolvedValue({ appid: otherAppId, sub: 'bot-id', serviceurl: mockServiceUrl }); diff --git a/packages/apps/src/middleware/auth/inbound-activity-token-validator.ts b/packages/apps/src/middleware/auth/inbound-activity-token-validator.ts index e369206cb..dacf18c7c 100644 --- a/packages/apps/src/middleware/auth/inbound-activity-token-validator.ts +++ b/packages/apps/src/middleware/auth/inbound-activity-token-validator.ts @@ -125,11 +125,10 @@ export class InboundActivityTokenValidator { /** * Requires the client app that requested the token to be the Agent 365 platform. * Entra v2 tokens carry the caller in `azp` and v1 tokens carry it in `appid`. + * `appid` is only consulted when `azp` is absent, so a present but invalid `azp` is rejected. */ private validateEntraCallerApp(payload: JwtPayload) { - const callerAppId = typeof payload.azp === 'string' && payload.azp - ? payload.azp - : payload.appid; + const callerAppId = 'azp' in payload ? payload.azp : payload.appid; if (typeof callerAppId !== 'string' || callerAppId.toLowerCase() !== AGENT_365_PLATFORM_APP_ID) { throw new Error('Entra inbound token caller app is not allowed'); } From 5a649dae22f245d829233fb1f4dae90cdec91172 Mon Sep 17 00:00:00 2001 From: Corina <14900841+corinagum@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:52:16 -0700 Subject: [PATCH 3/3] test: pin the Agent 365 platform app id constant Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../middleware/auth/inbound-activity-token-validator.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/apps/src/middleware/auth/inbound-activity-token-validator.spec.ts b/packages/apps/src/middleware/auth/inbound-activity-token-validator.spec.ts index 8ff951de9..360f9b843 100644 --- a/packages/apps/src/middleware/auth/inbound-activity-token-validator.spec.ts +++ b/packages/apps/src/middleware/auth/inbound-activity-token-validator.spec.ts @@ -366,6 +366,10 @@ describe('InboundActivityTokenValidator', () => { expect(result.appId).toBe(otherAppId); }); + + it('should pin AGENT_365_PLATFORM_APP_ID to the Agent 365 platform app id', () => { + expect(AGENT_365_PLATFORM_APP_ID).toBe('5a807f24-c9de-44ee-a3a7-329e88a00ffc'); + }); }); describe('sovereign cloud support', () => {