From 45c757b203f959fdc387c00d4b8ffc3d6cbfa444 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Sun, 15 Mar 2026 09:55:19 -0700 Subject: [PATCH 1/2] fix: accept api://botid-{app_id} audience format in token validation Bot Framework tokens issued for bots registered with Entra ID can carry aud=api://botid-{app_id}, which was being rejected with a 401. Add this audience format to both for_service() and for_entra() factory methods, matching the TypeScript SDK behavior. Co-Authored-By: Claude Opus 4.6 --- .../apps/auth/token_validator.py | 4 +- packages/apps/tests/test_token_validator.py | 43 +++++++++++++++++-- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/packages/apps/src/microsoft_teams/apps/auth/token_validator.py b/packages/apps/src/microsoft_teams/apps/auth/token_validator.py index da70a895..e9846eec 100644 --- a/packages/apps/src/microsoft_teams/apps/auth/token_validator.py +++ b/packages/apps/src/microsoft_teams/apps/auth/token_validator.py @@ -62,7 +62,7 @@ def for_service( options = JwtValidationOptions( valid_issuers=["https://api.botframework.com"], - valid_audiences=[app_id, f"api://{app_id}"], + valid_audiences=[app_id, f"api://{app_id}", f"api://botid-{app_id}"], jwks_uri="https://login.botframework.com/v1/.well-known/keys", service_url=service_url, ) @@ -88,7 +88,7 @@ def for_entra( tenant_id = tenant_id or "common" options = JwtValidationOptions( valid_issuers=valid_issuers, - valid_audiences=[app_id, f"api://{app_id}"], + valid_audiences=[app_id, f"api://{app_id}", f"api://botid-{app_id}"], jwks_uri=f"https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys", scope=scope, ) diff --git a/packages/apps/tests/test_token_validator.py b/packages/apps/tests/test_token_validator.py index 24101f15..ef148e13 100644 --- a/packages/apps/tests/test_token_validator.py +++ b/packages/apps/tests/test_token_validator.py @@ -60,7 +60,11 @@ def test_init(self): assert validator.logger is not None assert validator.options.valid_issuers == ["https://api.botframework.com"] - assert validator.options.valid_audiences == ["test-app-id", "api://test-app-id"] + assert validator.options.valid_audiences == [ + "test-app-id", + "api://test-app-id", + "api://botid-test-app-id", + ] assert validator.options.jwks_uri == "https://login.botframework.com/v1/.well-known/keys" def test_init_with_custom_logger(self): @@ -69,7 +73,11 @@ def test_init_with_custom_logger(self): validator = TokenValidator.for_service("test-app-id", mock_logger) assert validator.options.valid_issuers == ["https://api.botframework.com"] - assert validator.options.valid_audiences == ["test-app-id", "api://test-app-id"] + assert validator.options.valid_audiences == [ + "test-app-id", + "api://test-app-id", + "api://botid-test-app-id", + ] assert validator.options.jwks_uri == "https://login.botframework.com/v1/.well-known/keys" assert validator.logger == mock_logger @@ -140,6 +148,35 @@ async def test_validate_token_decode_error(self, validator, mock_signing_key): with pytest.raises(jwt.InvalidTokenError): await validator.validate_token(token) + @pytest.mark.asyncio + @pytest.mark.parametrize( + "audience", + [ + "test-app-id", + "api://test-app-id", + "api://botid-test-app-id", + ], + ids=["app_id", "api://app_id", "api://botid-app_id"], + ) + async def test_validate_token_accepts_all_audience_formats(self, mock_signing_key, audience): + """Test that all three audience formats are accepted.""" + validator = TokenValidator.for_service("test-app-id") + token = "valid.jwt.token" + payload = { + "iss": "https://api.botframework.com", + "aud": audience, + "serviceurl": "https://smba.trafficmanager.net/teams", + "exp": 9999999999, + "iat": 1000000000, + } + + with ( + patch("jwt.PyJWKClient", return_value=mock_signing_key), + patch("jwt.decode", return_value=payload), + ): + result = await validator.validate_token(token) + assert result["aud"] == audience + @pytest.mark.asyncio async def test_validate_token_invalid_audience(self, validator, mock_signing_key): """Test validation with invalid audience.""" @@ -241,7 +278,7 @@ def test_for_entra_initialization(self, validator_entra): """Check Entra-specific initialization.""" options = validator_entra.options assert options.valid_issuers == ["https://login.microsoftonline.com/test-tenant-id/v2.0"] - assert options.valid_audiences == ["test-app-id", "api://test-app-id"] + assert options.valid_audiences == ["test-app-id", "api://test-app-id", "api://botid-test-app-id"] assert options.jwks_uri == "https://login.microsoftonline.com/test-tenant-id/discovery/v2.0/keys" assert options.scope == "user.read" From c2820ea8de4f6f7db7b9c87baae2ef3abeb375a0 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Sun, 15 Mar 2026 09:59:03 -0700 Subject: [PATCH 2/2] Add application_id_uri option for custom audience validation Port applicationIdUri from teams.ts PR #469: adds application_id_uri to AppOptions, wires it through to TokenValidator.for_entra() so custom audience values (matching webApplicationInfo.resource in the app manifest) are accepted during Entra token validation. Co-Authored-By: Claude Opus 4.6 --- packages/apps/src/microsoft_teams/apps/app.py | 5 ++++- .../microsoft_teams/apps/auth/token_validator.py | 14 ++++++++++++-- .../apps/src/microsoft_teams/apps/options.py | 6 ++++++ packages/apps/tests/test_token_validator.py | 16 ++++++++++++++++ 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/apps/src/microsoft_teams/apps/app.py b/packages/apps/src/microsoft_teams/apps/app.py index 6fc89208..ed5cb484 100644 --- a/packages/apps/src/microsoft_teams/apps/app.py +++ b/packages/apps/src/microsoft_teams/apps/app.py @@ -155,7 +155,10 @@ def __init__(self, **options: Unpack[AppOptions]): self.entra_token_validator: Optional[TokenValidator] = None if self.credentials and hasattr(self.credentials, "client_id"): self.entra_token_validator = TokenValidator.for_entra( - self.credentials.client_id, self.credentials.tenant_id, logger=self.log + self.credentials.client_id, + self.credentials.tenant_id, + application_id_uri=self.options.application_id_uri, + logger=self.log, ) @property diff --git a/packages/apps/src/microsoft_teams/apps/auth/token_validator.py b/packages/apps/src/microsoft_teams/apps/auth/token_validator.py index e9846eec..b77dd8c7 100644 --- a/packages/apps/src/microsoft_teams/apps/auth/token_validator.py +++ b/packages/apps/src/microsoft_teams/apps/auth/token_validator.py @@ -70,7 +70,12 @@ def for_service( @classmethod def for_entra( - cls, app_id: str, tenant_id: Optional[str], scope: Optional[str] = None, logger: Optional[Logger] = None + cls, + app_id: str, + tenant_id: Optional[str], + scope: Optional[str] = None, + application_id_uri: Optional[str] = None, + logger: Optional[Logger] = None, ) -> "TokenValidator": """Create a validator for Entra ID tokens. @@ -78,6 +83,8 @@ def for_entra( app_id: The app's Microsoft App ID (used for audience validation) tenant_id: The Azure AD tenant ID scope: Optional scope that must be present in the token + application_id_uri: Optional Application ID URI from Azure portal. + Matches webApplicationInfo.resource in the app manifest. logger: Optional logger instance """ @@ -86,9 +93,12 @@ def for_entra( if tenant_id: valid_issuers.append(f"https://login.microsoftonline.com/{tenant_id}/v2.0") tenant_id = tenant_id or "common" + valid_audiences = [app_id, f"api://{app_id}", f"api://botid-{app_id}"] + if application_id_uri: + valid_audiences.append(application_id_uri) options = JwtValidationOptions( valid_issuers=valid_issuers, - valid_audiences=[app_id, f"api://{app_id}", f"api://botid-{app_id}"], + valid_audiences=valid_audiences, jwks_uri=f"https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys", scope=scope, ) diff --git a/packages/apps/src/microsoft_teams/apps/options.py b/packages/apps/src/microsoft_teams/apps/options.py index f56f0dd7..8c6e5490 100644 --- a/packages/apps/src/microsoft_teams/apps/options.py +++ b/packages/apps/src/microsoft_teams/apps/options.py @@ -26,6 +26,9 @@ class AppOptions(TypedDict, total=False): """The client secret. If provided with client_id, uses ClientCredentials auth.""" tenant_id: Optional[str] """The tenant ID. Required for single-tenant apps.""" + application_id_uri: Optional[str] + """Application ID URI from the Azure portal. Used for user authentication. + Matches webApplicationInfo.resource in the app manifest.""" # Custom token provider function token: Optional[Callable[[Union[str, list[str]], Optional[str]], Union[str, Awaitable[str]]]] """Custom token provider function. If provided with client_id (no client_secret), uses TokenCredentials.""" @@ -85,6 +88,9 @@ class InternalAppOptions: """The client secret. If provided with client_id, uses ClientCredentials auth.""" tenant_id: Optional[str] = None """The tenant ID. Required for single-tenant apps.""" + application_id_uri: Optional[str] = None + """Application ID URI from the Azure portal. Used for user authentication. + Matches webApplicationInfo.resource in the app manifest.""" token: Optional[Callable[[Union[str, list[str]], Optional[str]], Union[str, Awaitable[str]]]] = None """Custom token provider function. If provided with client_id (no client_secret), uses TokenCredentials.""" managed_identity_client_id: Optional[str] = None diff --git a/packages/apps/tests/test_token_validator.py b/packages/apps/tests/test_token_validator.py index ef148e13..c19179e6 100644 --- a/packages/apps/tests/test_token_validator.py +++ b/packages/apps/tests/test_token_validator.py @@ -335,6 +335,22 @@ async def test_validate_entra_token_invalid_issuer(self, validator_entra, mock_s with pytest.raises(jwt.InvalidTokenError): await validator_entra.validate_token(token) + def test_for_entra_with_application_id_uri(self): + """Check that applicationIdUri is included in valid audiences.""" + validator = TokenValidator.for_entra( + app_id="test-app-id", + tenant_id="test-tenant-id", + application_id_uri="api://my-app.contoso.com/test-app-id", + ) + options = validator.options + assert "api://my-app.contoso.com/test-app-id" in options.valid_audiences + + def test_for_entra_without_application_id_uri(self): + """Check that audiences are default when applicationIdUri is not provided.""" + validator = TokenValidator.for_entra(app_id="test-app-id", tenant_id="test-tenant-id") + options = validator.options + assert options.valid_audiences == ["test-app-id", "api://test-app-id", "api://botid-test-app-id"] + @pytest.mark.asyncio async def test_validate_entra_token_invalid_audience(self, validator_entra, mock_signing_key): """Fail validation for invalid audience."""