Skip to content

Commit 4aae1bd

Browse files
heyitsaamirclaude
andauthored
fix: accept api://botid-{app_id} audience format in token validation (#314)
## Summary - Add `api://botid-{app_id}` to the valid audiences list in `TokenValidator.for_service()` and `TokenValidator.for_entra()`, matching the TypeScript SDK behavior (teams.ts#469) - Bot Framework tokens issued for bots registered with Entra ID use this audience format and were being rejected with a 401 - Add `application_id_uri` option to `AppOptions` — matches `webApplicationInfo.resource` in the Teams app manifest — for custom audience values in Entra token validation - Add parametrized test covering all three default audience formats and tests for `application_id_uri` ## Test plan - [x] All 24 token validator tests pass - [x] Verify with a bot registered via Entra ID that tokens with `aud=api://botid-{app_id}` are accepted - [x] Verify custom `application_id_uri` audiences are accepted 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9dbb9fc commit 4aae1bd

4 files changed

Lines changed: 76 additions & 6 deletions

File tree

packages/apps/src/microsoft_teams/apps/app.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,9 @@ def __init__(self, **options: Unpack[AppOptions]):
151151
self.entra_token_validator: Optional[TokenValidator] = None
152152
if self.credentials and hasattr(self.credentials, "client_id"):
153153
self.entra_token_validator = TokenValidator.for_entra(
154-
self.credentials.client_id, self.credentials.tenant_id
154+
self.credentials.client_id,
155+
self.credentials.tenant_id,
156+
application_id_uri=self.options.application_id_uri,
155157
)
156158

157159
@property

packages/apps/src/microsoft_teams/apps/auth/token_validator.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ def __init__(self, jwt_validation_options: JwtValidationOptions):
4747
self.options = jwt_validation_options
4848
self._jwks_client = jwt.PyJWKClient(jwt_validation_options.jwks_uri)
4949

50+
@staticmethod
51+
def _default_audiences(app_id: str) -> List[str]:
52+
return [app_id, f"api://{app_id}", f"api://botid-{app_id}"]
53+
5054
# ----- Factory constructors -----
5155
@classmethod
5256
def for_service(cls, app_id: str, service_url: Optional[str] = None) -> "TokenValidator":
@@ -60,30 +64,41 @@ def for_service(cls, app_id: str, service_url: Optional[str] = None) -> "TokenVa
6064

6165
options = JwtValidationOptions(
6266
valid_issuers=["https://api.botframework.com"],
63-
valid_audiences=[app_id, f"api://{app_id}"],
67+
valid_audiences=cls._default_audiences(app_id),
6468
jwks_uri="https://login.botframework.com/v1/.well-known/keys",
6569
service_url=service_url,
6670
)
6771
return cls(options)
6872

6973
@classmethod
70-
def for_entra(cls, app_id: str, tenant_id: Optional[str], scope: Optional[str] = None) -> "TokenValidator":
74+
def for_entra(
75+
cls,
76+
app_id: str,
77+
tenant_id: Optional[str],
78+
scope: Optional[str] = None,
79+
application_id_uri: Optional[str] = None,
80+
) -> "TokenValidator":
7181
"""Create a validator for Entra ID tokens.
7282
7383
Args:
7484
app_id: The app's Microsoft App ID (used for audience validation)
7585
tenant_id: The Azure AD tenant ID
7686
scope: Optional scope that must be present in the token
87+
application_id_uri: Optional Application ID URI from Azure portal.
88+
Matches webApplicationInfo.resource in the app manifest.
7789
7890
"""
7991

8092
valid_issuers: List[str] = []
8193
if tenant_id:
8294
valid_issuers.append(f"https://login.microsoftonline.com/{tenant_id}/v2.0")
8395
tenant_id = tenant_id or "common"
96+
valid_audiences = cls._default_audiences(app_id)
97+
if application_id_uri:
98+
valid_audiences.append(application_id_uri)
8499
options = JwtValidationOptions(
85100
valid_issuers=valid_issuers,
86-
valid_audiences=[app_id, f"api://{app_id}"],
101+
valid_audiences=valid_audiences,
87102
jwks_uri=f"https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys",
88103
scope=scope,
89104
)

packages/apps/src/microsoft_teams/apps/options.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ class AppOptions(TypedDict, total=False):
2525
"""The client secret. If provided with client_id, uses ClientCredentials auth."""
2626
tenant_id: Optional[str]
2727
"""The tenant ID. Required for single-tenant apps."""
28+
application_id_uri: Optional[str]
29+
"""Application ID URI from the Azure portal. Used for user authentication.
30+
Matches webApplicationInfo.resource in the app manifest."""
2831
# Custom token provider function
2932
token: Optional[Callable[[Union[str, list[str]], Optional[str]], Union[str, Awaitable[str]]]]
3033
"""Custom token provider function. If provided with client_id (no client_secret), uses TokenCredentials."""
@@ -86,6 +89,9 @@ class InternalAppOptions:
8689
"""The client secret. If provided with client_id, uses ClientCredentials auth."""
8790
tenant_id: Optional[str] = None
8891
"""The tenant ID. Required for single-tenant apps."""
92+
application_id_uri: Optional[str] = None
93+
"""Application ID URI from the Azure portal. Used for user authentication.
94+
Matches webApplicationInfo.resource in the app manifest."""
8995
token: Optional[Callable[[Union[str, list[str]], Optional[str]], Union[str, Awaitable[str]]]] = None
9096
"""Custom token provider function. If provided with client_id (no client_secret), uses TokenCredentials."""
9197
managed_identity_client_id: Optional[str] = None

packages/apps/tests/test_token_validator.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,11 @@ def test_init(self):
6666
validator = TokenValidator.for_service("test-app-id")
6767

6868
assert validator.options.valid_issuers == ["https://api.botframework.com"]
69-
assert validator.options.valid_audiences == ["test-app-id", "api://test-app-id"]
69+
assert validator.options.valid_audiences == [
70+
"test-app-id",
71+
"api://test-app-id",
72+
"api://botid-test-app-id",
73+
]
7074
assert validator.options.jwks_uri == "https://login.botframework.com/v1/.well-known/keys"
7175

7276
@pytest.mark.asyncio
@@ -129,6 +133,33 @@ async def test_validate_token_decode_error(self, validator, mock_jwks_client):
129133
with pytest.raises(jwt.InvalidTokenError):
130134
await validator.validate_token(token)
131135

136+
@pytest.mark.asyncio
137+
@pytest.mark.parametrize(
138+
"audience",
139+
[
140+
"test-app-id",
141+
"api://test-app-id",
142+
"api://botid-test-app-id",
143+
],
144+
ids=["app_id", "api://app_id", "api://botid-app_id"],
145+
)
146+
async def test_validate_token_accepts_all_audience_formats(self, mock_jwks_client, audience):
147+
"""Test that all three audience formats are accepted."""
148+
validator = TokenValidator.for_service("test-app-id")
149+
validator._jwks_client = mock_jwks_client
150+
token = "valid.jwt.token"
151+
payload = {
152+
"iss": "https://api.botframework.com",
153+
"aud": audience,
154+
"serviceurl": "https://smba.trafficmanager.net/teams",
155+
"exp": 9999999999,
156+
"iat": 1000000000,
157+
}
158+
159+
with patch("jwt.decode", return_value=payload):
160+
result = await validator.validate_token(token)
161+
assert result["aud"] == audience
162+
132163
@pytest.mark.asyncio
133164
async def test_validate_token_invalid_audience(self, validator, mock_jwks_client):
134165
"""Test validation with invalid audience."""
@@ -220,7 +251,7 @@ def test_for_entra_initialization(self, validator_entra):
220251
"""Check Entra-specific initialization."""
221252
options = validator_entra.options
222253
assert options.valid_issuers == ["https://login.microsoftonline.com/test-tenant-id/v2.0"]
223-
assert options.valid_audiences == ["test-app-id", "api://test-app-id"]
254+
assert options.valid_audiences == ["test-app-id", "api://test-app-id", "api://botid-test-app-id"]
224255
assert options.jwks_uri == "https://login.microsoftonline.com/test-tenant-id/discovery/v2.0/keys"
225256
assert options.scope == "user.read"
226257

@@ -271,6 +302,22 @@ async def test_validate_entra_token_invalid_issuer(self, validator_entra, mock_j
271302
with pytest.raises(jwt.InvalidTokenError):
272303
await validator_entra.validate_token(token)
273304

305+
def test_for_entra_with_application_id_uri(self):
306+
"""Check that applicationIdUri is included in valid audiences."""
307+
validator = TokenValidator.for_entra(
308+
app_id="test-app-id",
309+
tenant_id="test-tenant-id",
310+
application_id_uri="api://my-app.contoso.com/test-app-id",
311+
)
312+
options = validator.options
313+
assert "api://my-app.contoso.com/test-app-id" in options.valid_audiences
314+
315+
def test_for_entra_without_application_id_uri(self):
316+
"""Check that audiences are default when applicationIdUri is not provided."""
317+
validator = TokenValidator.for_entra(app_id="test-app-id", tenant_id="test-tenant-id")
318+
options = validator.options
319+
assert options.valid_audiences == ["test-app-id", "api://test-app-id", "api://botid-test-app-id"]
320+
274321
@pytest.mark.asyncio
275322
async def test_validate_entra_token_invalid_audience(self, validator_entra, mock_jwks_client):
276323
"""Fail validation for invalid audience."""

0 commit comments

Comments
 (0)