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
5 changes: 4 additions & 1 deletion packages/apps/src/microsoft_teams/apps/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 13 additions & 3 deletions packages/apps/src/microsoft_teams/apps/auth/token_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,22 +62,29 @@ 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,
)
return cls(options, logger)

@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.

Args:
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

"""
Expand All @@ -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}"],
valid_audiences=valid_audiences,
jwks_uri=f"https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys",
scope=scope,
)
Expand Down
6 changes: 6 additions & 0 deletions packages/apps/src/microsoft_teams/apps/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand Down
59 changes: 56 additions & 3 deletions packages/apps/tests/test_token_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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

Expand Down Expand Up @@ -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),
Comment on lines +173 to +175
):
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."""
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -298,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."""
Expand Down
Loading