Skip to content

[Parent] Consistent inbound JWT Bearer-token validation: add TID→Issuer cross-check across SDKs #626

Description

@matthewmeyer

Repo: microsoft/Agents (language-agnostic parent)
Children: Agents-for-js, Agents-for-python, Agents-for-net (linked as sub-issues)

Summary

Make inbound JWT Bearer-token validation consistent across all three Microsoft 365 Agents SDKs
and close a cross-tenant authentication gap by:

  1. Adding a tenant-binding cross-check (the required, consistent change): when the token's iss
    is a recognized Entra issuer that embeds a tenant GUID and the token carries a tid (tenant id)
    claim, that tid claim must equal the issuer tenant. (A token without a tid claim is not
    rejected on that basis — the cross-check is simply skipped.)
  2. Making issuer (iss) allow-list validation available as an opt-in, per-sample/per-app option
    — mirroring how the .NET SDK already exposes ValidIssuers. This is not mandatory,
    SDK-enforced behavior.

.NET already exposes the opt-in issuer allow-list (ValidIssuers) that JS/Python mirror, and is the
reference for issuer handling. All three SDKs, including .NET, add the explicit tid ↔ issuer
tenant cross-check; for .NET that is the only change (its issuer handling is unchanged). This is a
focused change to the inbound validation path only — not a broader auth refactor.

Motivation

Security — cross-tenant bypass

Entra ID signing keys are shared across all tenants within a cloud. Therefore validating only the
signature + audience (which two of the three SDKs do today) means a signature-valid token minted
for a different tenant, but carrying a matching aud (e.g. for a multi-tenant app registration),
can be accepted. Binding the issuer's tenant to the tid claim prevents a token issued for tenant A
from being accepted as tenant B. (Optional issuer allow-list validation can further narrow accepted
issuers, but is offered as an opt-in per-sample option rather than mandatory enforcement.)

Consistency

Each SDK currently enforces a different subset of checks, so the security posture depends on which
language an agent is written in. This parent tracks bringing all three to the same baseline.

Background — current state (as of this writing)

Check Agents-for-js Agents-for-python Agents-for-net
Signature (RS256, JWKS)
Audience (aud → clientId)
Issuer (iss) allow-list ❌ not available ❌ used unverified to pick JWKS ✅ opt-in ValidIssuers
Signing-key ↔ issuer binding EnableAadSigningKeyIssuerValidation()
Explicit tid claim ↔ iss tenant ⚠️ via signing-key binding (no explicit claim check)
Issuer config location SDK middleware SDK validator per-sample app config (by design)

Notes:

  • JS: packages/agents-hosting/src/auth/jwt-middleware.ts (verifyToken) validates audience and
    signature; jwt.verify is not given an issuer option and never reads tid.
  • Python: microsoft-agents-hosting-core/.../authorization/jwt_token_validator.py
    (validate_token) validates audience and signature; iss is only used unverified to choose the
    JWKS URI; tid is never read.
  • .NET (issuer handling unchanged; adds tid cross-check): enforces issuer via opt-in
    ValidIssuers and binds the signing key to the issuer tenant via
    EnableAadSigningKeyIssuerValidation() (Microsoft.IdentityModel.Validators). Configured per-sample
    in AspNetExtensions.cs by design — this issuer-handling model is the pattern JS and Python
    converge toward and is not changed. .NET adds only the explicit tidiss-tenant claim
    check (its current signing-key binding does not literally compare the tid claim).

Scope

In scope

  • Add a tidiss-tenant GUID cross-check for recognized Entra issuers — the required,
    consistent
    change in JS and Python.
  • Offer optional, per-sample/per-app issuer (iss) allow-list validation (opt-in), mirroring
    .NET's existing ValidIssuers model — not mandatory, SDK-enforced behavior.
  • Support public (login.microsoftonline.com) and US Gov (login.microsoftonline.us) clouds,
    plus the cloud-agnostic v1 issuer host (sts.windows.net).
  • Handle multi-tenant / blueprint connections (common / organizations) where the caller's
    tenant is unknown at config time: accept any canonical Entra issuer for the configured cloud, then
    bind it to the token's tid.
  • Consistent failure semantics: validation failure → 401 with a non-leaky error message.
  • Unit tests, including cross-tenant negative tests.

Out of scope

  • Changing the .NET SDK's issuer handling — the opt-in ValidIssuers allow-list and
    EnableAadSigningKeyIssuerValidation() signing-key binding remain as-is (no consolidation of
    AspNetExtensions.cs, no change to issuer validation). .NET only adds the explicit tid
    cross-check.
  • Making issuer allow-list validation mandatory / on-by-default in any SDK.
  • Broader authentication/authorization refactors (connection manager, OBO, sidecar provider, etc.).
  • Changing audience-matching or signing-key fetching behavior beyond what tid binding needs.
  • New configuration surface beyond what already exists (tenant id, authority, issuers).

Required behavior (the tid binding applies to all three SDKs; issuer handling differs per SDK)

  1. tid ↔ issuer binding (required, all SDKs). If iss is a recognized Entra issuer with a GUID
    tenant and the token carries a tid claim, require tid to equal that tenant GUID
    (case-insensitive); otherwise reject. If the token has no tid claim, skip the cross-check (do
    not reject on that basis — signature/audience still apply). Non-Entra issuers (e.g. Azure Bot
    Service api.botframework.*, which carry no tid) are likewise skipped. In .NET this is the
    only change; JS and Python add it alongside the recognition logic below.
  2. Canonical Entra issuer recognition. Recognize:
    • v1: https://sts.windows.net/{tenantGuid}/ (cloud-agnostic)
    • v2 public: https://login.microsoftonline.com/{tenantGuid}/v2.0
    • v2 gov: https://login.microsoftonline.us/{tenantGuid}/v2.0
      Only GUID tenants are bindable (domain-alias issuers cannot be compared to the GUID tid and
      are left unbound for compatibility).
  3. Issuer allow-list (optional, opt-in). When enabled by a sample/app, build the accepted-issuer
    set from the matched connection's configured issuers (or a tenant-scoped default) unioned with
    the always-trusted Microsoft first-party issuers for the configured cloud, compared
    case-insensitively. When not enabled, issuer allow-list checks are skipped (signature +
    audience + tid binding still apply).
  4. Multi-tenant connections. If the connection tenant is common/organizations, accept any
    canonical Entra issuer for the configured cloud (signature + audience + tid binding still apply).
  5. Failure → 401, message must not leak internal error codes/help links.

Expected consistency matrix (target end state)

Capability JS Python .NET
tidiss tenant binding ✅ explicit claim check (added) ✅ explicit claim check (added) ✅ explicit claim check (added)
Issuer allow-list available (optional, opt-in) ✅ existing ValidIssuers (unchanged)
Public + US Gov clouds
Multi-tenant (common/organizations)
Bot Framework issuers skipped from binding
Cross-tenant negative tests

Security considerations

  • Closes a cross-tenant token-substitution/bypass for multi-tenant app registrations via the required
    tid ↔ issuer binding.
  • The tid binding only strengthens (never loosens) validation; it applies only when the token has
    a tid claim
    and iss is a GUID-tenant Entra issuer. Tokens without a tid claim, non-GUID
    (domain-alias) issuers, and non-Entra issuers degrade to the existing signature + audience checks.
  • Optional issuer allow-list comparison is case-insensitive to tolerate operator-provided GUID casing
    while still rejecting unrelated tenants when enabled.

Testing strategy

  • ✅ Same-tenant token: accepted.
  • ❌ Signature-valid token from a different tenant with matching aud: rejected (tid binding).
  • ✅/❌ Public vs US Gov issuer matched to the configured cloud.
  • ✅ Multi-tenant connection accepts a canonical Entra issuer and binds to tid.
  • ✅ Azure Bot Service issuer (no tid) still accepted (binding skipped).
  • ✅ Token with no tid claim accepted even when the issuer embeds a GUID tenant (binding skipped).
  • ❌ Token whose tid is present but does not match the issuer tenant GUID: rejected.
  • ✅ Optional issuer allow-list: when enabled, unlisted issuer rejected; when disabled, skipped.

Acceptance criteria

  • All three child issues completed and merged (JS, Python, .NET each add the tid cross-check).
  • Behavior matches the consistency matrix above (tid binding required everywhere; issuer
    allow-list opt-in; .NET issuer handling unchanged).
  • Cross-tenant negative test present in each SDK's test suite.
  • Failure path returns 401 without leaking internal error codes.

References

  • Microsoft.IdentityModel EnableAadSigningKeyIssuerValidation / AadIssuerValidator (tenant binding).

Child issues

Metadata

Metadata

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