Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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',
});

Expand All @@ -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}/`,
Expand All @@ -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,
Expand All @@ -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}`;
Expand All @@ -288,6 +288,90 @@ 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.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 });
const botToken = createUnverifiedToken({ iss: 'https://api.botframework.com' });

const result = await validator.check(`Bearer ${botToken}`, { serviceUrl: mockServiceUrl });

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', () => {
it('should use public cloud defaults when no cloud provided', () => {
new InboundActivityTokenValidator(mockClientId, mockTenantId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -110,7 +115,23 @@ 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`.
* `appid` is only consulted when `azp` is absent, so a present but invalid `azp` is rejected.
*/
private validateEntraCallerApp(payload: JwtPayload) {
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');
}
}

private getEntraValidator(tenantId: string) {
Expand Down
Loading