Skip to content

Add TID→Issuer cross-check in inbound JWT validation #423

Description

@matthewmeyer

Repo: microsoft/Agents-for-python
Parent: microsoft/Agents#626 (sub-issue)

Summary

Harden inbound JWT Bearer-token validation in microsoft-agents-hosting-core so that, in addition to
the existing audience and signature checks, JwtTokenValidator.validate_token():

  1. Cross-checks the tid (tenant id) claim against the tenant GUID embedded in iss for
    recognized Entra issuers — the required change, and
  2. Optionally validates the token issuer (iss) against an accepted-issuer allow-list, opt-in
    per app
    (mirroring .NET's ValidIssuers) — not enforced by default.

Problem

libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py
(validate_token) currently:

  • verifies the signature via PyJWT (PyJWKClient, algorithms=["RS256"], leeway=300,
    options={"verify_aud": False}), and
  • validates audience via AgentAuthConfiguration._jwt_patch_is_valid_aud() (matches aud to a
    connection CLIENT_ID).

But the issuer is only used unverified to choose the JWKS URI, and the tid claim is never
read
. Because Entra signing keys are shared across tenants in a cloud, a signature-valid token from
a different tenant with a matching aud can be accepted → cross-tenant authentication bypass.

# jwt_token_validator.py (current — abridged)
jwks_uri = ("https://login.botframework.com/v1/.well-known/keys"
            if unverified_payload.get("iss") == "https://api.botframework.com"
            else f"https://login.microsoftonline.com/{self.configuration.TENANT_ID}/discovery/v2.0/keys")
decoded = decode(token, key=key, algorithms=["RS256"], leeway=300.0, options={"verify_aud": False})
if not self.configuration._jwt_patch_is_valid_aud(decoded["aud"]):
    raise ValueError("Invalid audience.")
# ...no issuer validation, tid never inspected...

Proposed change

In validate_token(), after decoding and the audience check (before returning the claims identity):

  1. tid ↔ issuer binding (required). If iss is a recognized Entra issuer carrying a GUID tenant
    (https://sts.windows.net/{guid}/ or https://login.microsoftonline.com|us/{guid}/v2.0) and
    the token has a tid claim, require decoded["tid"] to equal that GUID (case-insensitive);
    otherwise reject. If the token has no tid claim, skip the cross-check (do not reject for a
    missing tid). Skip binding for non-Entra issuers (e.g. api.botframework.com, no tid).
  2. Optional issuer allow-list (opt-in). When an app opts in, build the accepted-issuer set from
    AgentAuthConfiguration.ISSUERS (agent_auth_configuration.py — already defines
    api.botframework.com, https://sts.windows.net/{TENANT_ID}/,
    https://login.microsoftonline.com/{TENANT_ID}/v2.0) unioned with the always-trusted Microsoft
    first-party issuers for the configured cloud (public vs US Gov, using the gov constants already in
    authentication_constants.py). Compare case-insensitively and reject if not accepted. When not
    opted in, skip this check (signature + audience + tid binding still apply).
  3. Multi-tenant connections. If the configured tenant is common/organizations, accept any
    canonical Entra issuer for the configured cloud (binding to tid still applies).
  4. Errors. Raise the existing validation error type so both middlewares surface a 401.

Affected files

  • .../core/authorization/jwt_token_validator.py — issuer + tid validation in validate_token.
  • .../core/authorization/agent_auth_configuration.py — reuse ISSUERS; helper for issuer/tenant parse.
  • .../core/authorization/authentication_constants.py — gov issuer constants (reuse).
  • Middlewares (no behavior change, inherit result):
    • microsoft-agents-hosting-fastapi/.../jwt_authorization_middleware.py
    • microsoft-agents-hosting-aiohttp/.../jwt_authorization_middleware.py
  • Tests next to the existing validator tests.

Testing

  • ✅ same-tenant token accepted.
  • ❌ signature-valid token from a different tenant with matching aud rejected.
  • ✅ public vs US Gov issuer matched to configured cloud.
  • ✅ multi-tenant (common) config accepts canonical Entra issuer and binds tid.
  • api.botframework.com token accepted (binding skipped).
  • ❌ token whose tid is present but does not match the issuer tenant GUID rejected.
  • ✅ token with no tid claim accepted (binding skipped — not rejected for missing tid).
  • Verify both FastAPI and aiohttp middlewares return 401 on failure.

Acceptance criteria

  • tidiss cross-check implemented in validate_token.
  • Optional issuer allow-list available (opt-in, off by default).
  • Public + US Gov clouds and multi-tenant configs handled.
  • Both FastAPI and aiohttp paths reject with 401.
  • Tests above pass.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions