diff --git a/packages/apps/src/middleware/auth/jwt-validator.ts b/packages/apps/src/middleware/auth/jwt-validator.ts index 9db9a8dfb..eb1791321 100644 --- a/packages/apps/src/middleware/auth/jwt-validator.ts +++ b/packages/apps/src/middleware/auth/jwt-validator.ts @@ -9,6 +9,15 @@ const DEFAULTS = { clockTolerance: 300 // 5 minutes }; +export function decodeJwtPayload(rawToken: string): JwtPayload | null { + const payload = jwt.decode(rawToken); + if (!payload || typeof payload !== 'object') { + return null; + } + + return payload; +} + export interface IJwtValidationOptions { /** Required: Application/Client ID for audience validation */ clientId: string; @@ -296,4 +305,3 @@ export const createEntraTokenValidator = ( }, }, options?.logger); }; - diff --git a/packages/apps/src/middleware/auth/service-token-validator.spec.ts b/packages/apps/src/middleware/auth/service-token-validator.spec.ts index 775282b16..5ea58aa5e 100644 --- a/packages/apps/src/middleware/auth/service-token-validator.spec.ts +++ b/packages/apps/src/middleware/auth/service-token-validator.spec.ts @@ -3,8 +3,14 @@ import { US_GOV, CHINA } from '@microsoft/teams.api'; import { JwtValidator } from './jwt-validator'; import { ServiceTokenValidator } from './service-token-validator'; -// Mock JwtValidator -jest.mock('./jwt-validator'); +// Mock JwtValidator — real one fetches JWKS keys from remote endpoints. +jest.mock('./jwt-validator', () => { + const actual = jest.requireActual('./jwt-validator'); + return { + ...actual, + JwtValidator: jest.fn(), + }; +}); describe('ServiceTokenValidator', () => { const mockClientId = 'test-client-id'; @@ -13,6 +19,14 @@ describe('ServiceTokenValidator', () => { let mockValidateAccessToken: jest.Mock; + const createUnverifiedToken = (payload: Record) => { + return [ + Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url'), + Buffer.from(JSON.stringify(payload)).toString('base64url'), + 'signature' + ].join('.'); + }; + beforeEach(() => { mockValidateAccessToken = jest.fn(); (JwtValidator as jest.Mock).mockImplementation(() => ({ @@ -169,6 +183,103 @@ describe('ServiceTokenValidator', () => { expect(result.serviceUrl).toBe(bodyServiceUrl); }); + + it('should use service validator for Bot Framework tokens', async () => { + const validator = new ServiceTokenValidator(mockClientId, mockTenantId); + mockValidateAccessToken.mockResolvedValue({ + appid: mockClientId, + sub: 'bot-id', + serviceurl: mockServiceUrl, + }); + + const token = createUnverifiedToken({ iss: 'https://api.botframework.com' }); + await validator.check(`Bearer ${token}`, { serviceUrl: mockServiceUrl }); + + expect(mockValidateAccessToken).toHaveBeenCalledWith(token, { + validateServiceUrl: { expectedServiceUrl: mockServiceUrl } + }); + }); + + it('should use Entra validator for v2 issuer tokens without serviceUrl validation', async () => { + const validator = new ServiceTokenValidator(mockClientId, mockTenantId); + mockValidateAccessToken.mockResolvedValue({ + appid: mockClientId, + sub: 'agent-id', + }); + + const token = createUnverifiedToken({ + iss: `https://login.microsoftonline.com/${mockTenantId}/v2.0`, + tid: mockTenantId, + }); + await validator.check(`Bearer ${token}`, { serviceUrl: mockServiceUrl }); + + expect(JwtValidator).toHaveBeenLastCalledWith(expect.objectContaining({ + clientId: mockClientId, + tenantId: mockTenantId, + loginEndpoint: 'https://login.microsoftonline.com', + validateIssuer: { allowedTenantIds: [mockTenantId] }, + jwksUriOptions: { type: 'tenantId' }, + }), undefined); + expect(mockValidateAccessToken).toHaveBeenLastCalledWith(token); + }); + + it('should use Entra validator for v1 sts issuer tokens', async () => { + const validator = new ServiceTokenValidator(mockClientId, mockTenantId); + mockValidateAccessToken.mockResolvedValue({ appid: mockClientId, sub: 'agent-id' }); + + const token = createUnverifiedToken({ + iss: `https://sts.windows.net/${mockTenantId}/`, + tid: mockTenantId, + }); + await validator.check(`Bearer ${token}`, { serviceUrl: mockServiceUrl }); + + expect(mockValidateAccessToken).toHaveBeenLastCalledWith(token); + }); + + it('should reject Entra tokens missing tid', async () => { + const validator = new ServiceTokenValidator(mockClientId, mockTenantId); + const token = createUnverifiedToken({ + iss: `https://login.microsoftonline.com/${mockTenantId}/v2.0`, + }); + + await expect(validator.check(`Bearer ${token}`, { serviceUrl: mockServiceUrl })) + .rejects.toThrow('Entra inbound token is missing tid'); + }); + + it('should cache Entra validators by tenant', async () => { + const validator = new ServiceTokenValidator(mockClientId, mockTenantId); + mockValidateAccessToken.mockResolvedValue({ appid: mockClientId, sub: 'agent-id' }); + const token = createUnverifiedToken({ + iss: `https://login.microsoftonline.com/${mockTenantId}/v2.0`, + tid: mockTenantId, + }); + + await validator.check(`Bearer ${token}`, { serviceUrl: mockServiceUrl }); + await validator.check(`Bearer ${token}`, { serviceUrl: mockServiceUrl }); + + // Only one Entra validator created despite two calls (plus the BotFramework one in constructor) + const cache = (validator as any).entraValidatorsByTenantId as Map; + expect(cache.size).toBe(1); + }); + + it('should bound Entra validator cache size', async () => { + const validator = new ServiceTokenValidator(mockClientId, mockTenantId); + mockValidateAccessToken.mockResolvedValue({ appid: mockClientId, sub: 'agent-id' }); + + for (let i = 0; i < 101; i++) { + const tenantId = `tenant-${i}`; + const token = createUnverifiedToken({ + iss: `https://login.microsoftonline.com/${tenantId}/v2.0`, + tid: tenantId, + }); + await validator.check(`Bearer ${token}`, { serviceUrl: mockServiceUrl }); + } + + const cache = (validator as any).entraValidatorsByTenantId as Map; + expect(cache.size).toBe(100); + expect(cache.has('tenant-0')).toBe(false); + expect(cache.has('tenant-100')).toBe(true); + }); }); describe('sovereign cloud support', () => { diff --git a/packages/apps/src/middleware/auth/service-token-validator.ts b/packages/apps/src/middleware/auth/service-token-validator.ts index 6eb7ef46f..e5d7505e9 100644 --- a/packages/apps/src/middleware/auth/service-token-validator.ts +++ b/packages/apps/src/middleware/auth/service-token-validator.ts @@ -1,7 +1,12 @@ +import { JwtPayload } from 'jsonwebtoken'; + import { CloudEnvironment, Credentials, IToken, PUBLIC } from '@microsoft/teams.api'; import { ILogger } from '@microsoft/teams.common'; -import { JwtValidator } from './jwt-validator'; +import { JwtValidator, decodeJwtPayload } from './jwt-validator'; + +const MAX_ENTRA_VALIDATOR_CACHE_SIZE = 100; +const ENTRA_V1_ISSUER_PREFIX = 'https://sts.windows.net/'; /** * Derives the JWKS keys URI from an OpenID metadata URL. @@ -18,7 +23,11 @@ function openIdMetadataToKeysUri(openIdMetadataUrl: string): string { */ export class ServiceTokenValidator { private jwtValidator: JwtValidator; + private entraValidatorsByTenantId = new Map(); private credentials?: Credentials; + private appId: string; + private cloud: CloudEnvironment; + private logger?: ILogger; constructor( appId: string, @@ -28,6 +37,9 @@ export class ServiceTokenValidator { cloud?: CloudEnvironment ) { const env = cloud ?? PUBLIC; + this.appId = appId; + this.cloud = env; + this.logger = logger; this.jwtValidator = new JwtValidator({ clientId: appId, tenantId, @@ -49,10 +61,13 @@ export class ServiceTokenValidator { ? authHeader.substring(7) : authHeader; - // Validate token using JWT validator - const payload = await this.jwtValidator.validateAccessToken(token, { - validateServiceUrl: body.serviceUrl ? { expectedServiceUrl: body.serviceUrl } : undefined - }); + const unverifiedPayload = this.decodePayload(token); + const isEntraToken = this.isEntraIssuer(unverifiedPayload?.iss); + const payload = isEntraToken + ? await this.validateEntraToken(token, unverifiedPayload) + : await this.jwtValidator.validateAccessToken(token, { + validateServiceUrl: body.serviceUrl ? { expectedServiceUrl: body.serviceUrl } : undefined + }); if (!payload) { throw new Error('Invalid token'); @@ -69,4 +84,49 @@ export class ServiceTokenValidator { isExpired: () => false, // Already validated by JWT validator }; } + + private decodePayload(rawToken: string): JwtPayload | null { + return decodeJwtPayload(rawToken); + } + + private isEntraIssuer(issuer: unknown): issuer is string { + return typeof issuer === 'string' && ( + issuer.startsWith(this.cloud.loginEndpoint) || issuer.startsWith(ENTRA_V1_ISSUER_PREFIX) + ); + } + + private async validateEntraToken(rawToken: string, unverifiedPayload: JwtPayload | null) { + const tenantId = unverifiedPayload?.tid; + if (!tenantId || typeof tenantId !== 'string') { + throw new Error('Entra inbound token is missing tid'); + } + + const validator = this.getEntraValidator(tenantId); + // Agent 365 inbound Entra tokens currently do not include serviceurl. + // Revisit service URL validation when the platform defines a signed claim. + return await validator.validateAccessToken(rawToken); + } + + private getEntraValidator(tenantId: string) { + const cachedValidator = this.entraValidatorsByTenantId.get(tenantId); + if (cachedValidator) { + return cachedValidator; + } + + const validator = new JwtValidator({ + clientId: this.appId, + tenantId, + loginEndpoint: this.cloud.loginEndpoint, + validateIssuer: { allowedTenantIds: [tenantId] }, + jwksUriOptions: { type: 'tenantId' }, + }, this.logger); + this.entraValidatorsByTenantId.set(tenantId, validator); + if (this.entraValidatorsByTenantId.size > MAX_ENTRA_VALIDATOR_CACHE_SIZE) { + const oldestTenantId = this.entraValidatorsByTenantId.keys().next().value; + if (oldestTenantId) { + this.entraValidatorsByTenantId.delete(oldestTenantId); + } + } + return validator; + } }