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

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions