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
10 changes: 9 additions & 1 deletion packages/apps/src/middleware/auth/jwt-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -296,4 +305,3 @@ export const createEntraTokenValidator = (
},
}, options?.logger);
};

115 changes: 113 additions & 2 deletions packages/apps/src/middleware/auth/service-token-validator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -13,6 +19,14 @@ describe('ServiceTokenValidator', () => {

let mockValidateAccessToken: jest.Mock;

const createUnverifiedToken = (payload: Record<string, any>) => {
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(() => ({
Comment thread
heyitsaamir marked this conversation as resolved.
Expand Down Expand Up @@ -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' },
}));
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<string, any>;
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<string, JwtValidator>;
expect(cache.size).toBe(100);
expect(cache.has('tenant-0')).toBe(false);
expect(cache.has('tenant-100')).toBe(true);
});
});

describe('sovereign cloud support', () => {
Expand Down
66 changes: 61 additions & 5 deletions packages/apps/src/middleware/auth/service-token-validator.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
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.
Expand All @@ -18,7 +21,10 @@ function openIdMetadataToKeysUri(openIdMetadataUrl: string): string {
*/
export class ServiceTokenValidator {
private jwtValidator: JwtValidator;
private entraValidatorsByTenantId = new Map<string, JwtValidator>();
private credentials?: Credentials;
private appId: string;
private cloud: CloudEnvironment;

constructor(
appId: string,
Expand All @@ -28,6 +34,8 @@ export class ServiceTokenValidator {
cloud?: CloudEnvironment
) {
const env = cloud ?? PUBLIC;
this.appId = appId;
this.cloud = env;
this.jwtValidator = new JwtValidator({
clientId: appId,
tenantId,
Expand All @@ -49,10 +57,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');
Expand All @@ -69,4 +80,49 @@ export class ServiceTokenValidator {
isExpired: () => false, // Already validated by JWT validator
};
}

private decodePayload(rawToken: string): Record<string, any> | null {
return decodeJwtPayload(rawToken);
}
Comment on lines +84 to +86

private isEntraIssuer(issuer: unknown): issuer is string {
return typeof issuer === 'string' && (
issuer.startsWith(this.cloud.loginEndpoint) || issuer.startsWith(ENTRA_V1_ISSUER_PREFIX)
);
}
Comment thread
heyitsaamir marked this conversation as resolved.

private async validateEntraToken(rawToken: string, unverifiedPayload: Record<string, any> | 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;
}
Comment thread
heyitsaamir marked this conversation as resolved.

const validator = new JwtValidator({
clientId: this.appId,
tenantId,
loginEndpoint: this.cloud.loginEndpoint,
validateIssuer: { allowedTenantIds: [tenantId] },
jwksUriOptions: { type: 'tenantId' },
});
Comment on lines +114 to +118
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;
}
}
Loading