hermes - 💡(How to fix) Fix [Bug]: Teams adapter forces single-tenant JWT validation; .env.example doc misleading on multi-tenant [1 pull requests]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

Root Cause

In plugins/platforms/teams/adapter.py:

# line 169
self._tenant_id = extra.get("tenant_id") or os.getenv("TEAMS_TENANT_ID", "")

# lines 195-201
if not self._client_id or not self._client_secret or not self._tenant_id:
    self._set_fatal_error(
        "MISSING_CREDENTIALS",
        "TEAMS_CLIENT_ID, TEAMS_CLIENT_SECRET, and TEAMS_TENANT_ID are all required",
        retryable=False,
    )
    return False

# lines 208-215
self._app = App(
    client_id=self._client_id,
    client_secret=self._client_secret,
    tenant_id=self._tenant_id,
    http_server_adapter=_AiohttpBridgeAdapter(aiohttp_app),
    client=ClientOptions(headers={"User-Agent": "Hermes"}),
)

The validation check forces tenant_id to be non-empty before the App() call. The SDK then interprets that non-empty value as a single-tenant constraint. There is no supported code path that lets a deployer pass tenant_id=None (or omit it) to the SDK.

Fix Action

Fixed

Code Example

# TEAMS_TENANT_ID=                # Azure AD tenant ID (or "common" for multi-tenant)

---

# line 169
self._tenant_id = extra.get("tenant_id") or os.getenv("TEAMS_TENANT_ID", "")

# lines 195-201
if not self._client_id or not self._client_secret or not self._tenant_id:
    self._set_fatal_error(
        "MISSING_CREDENTIALS",
        "TEAMS_CLIENT_ID, TEAMS_CLIENT_SECRET, and TEAMS_TENANT_ID are all required",
        retryable=False,
    )
    return False

# lines 208-215
self._app = App(
    client_id=self._client_id,
    client_secret=self._client_secret,
    tenant_id=self._tenant_id,
    http_server_adapter=_AiohttpBridgeAdapter(aiohttp_app),
    client=ClientOptions(headers={"User-Agent": "Hermes"}),
)

---

# TEAMS_TENANT_ID=                # Azure AD tenant ID (or "common" for multi-tenant)

---

# TEAMS_TENANT_ID=                # Azure AD tenant ID for single-tenant bots.
   #                                 # LEAVE EMPTY for multi-tenant bots —
   #                                 # the SDK treats any value (including "common")
   #                                 # as a single-tenant constraint and will 401 on
   #                                 # cross-tenant tokens.
RAW_BUFFERClick to expand / collapse

Bug Description

The Teams platform adapter (plugins/platforms/teams/adapter.py) requires TEAMS_TENANT_ID to be a non-empty value at startup, and passes that value through to the microsoft-teams-apps SDK's App(tenant_id=...) constructor. The microsoft-teams-apps SDK uses the truthiness of tenant_id to decide between single-tenant and multi-tenant JWT validation — a non-empty string forces single-tenant mode, regardless of what the string contains.

Two consequences:

  1. .env.example is misleading. Line 430 reads:

    # TEAMS_TENANT_ID=                # Azure AD tenant ID (or "common" for multi-tenant)

    Setting TEAMS_TENANT_ID=common does not put the bot into multi-tenant mode; the SDK uses the literal string "common" as the issuer/audience tenant constraint, JWT validation against incoming tokens fails with that as the expected tenant claim, and the bot silently 401s on inbound messaging.

  2. There is no supported way to run the Teams adapter in true multi-tenant mode. Even if a deployer leaves TEAMS_TENANT_ID unset, the adapter's pre-flight check at lines 195-201 marks the credentials as missing and refuses to start. So the only way to use the adapter today is single-tenant.

This blocks any deployment shape where:

  • The bot's Entra ID App Registration lives in tenant A (e.g. an integrator/dev tenant),
  • Real users live in tenant B (e.g. a customer or corporate tenant),
  • and the bot is sideloaded into tenant B via Teams Admin Center.

In that shape, Microsoft Teams' Resource-Specific Consent (RSC) pre-check (IsUserAuthorizedToGrantGroupResourceSpecificPermissions) returns 404 Workload Unknown to anyone trying to install the app, because Teams cannot enumerate RSC permissions for a cross-tenant app whose Application object isn't resolvable in the consenting tenant. The documented Microsoft fix is to make the App Registration multi-tenant (signInAudience = AzureADMultipleOrgs, microsoftAppType = MultiTenant), but doing that and then booting Hermes leaves you in case (1) above: silent 401 on every inbound message because the SDK is still in single-tenant validation mode.

Steps to Reproduce

  1. Provision an Entra ID App Registration with signInAudience = AzureADMultipleOrgs and an Azure Bot Service with microsoftAppType = MultiTenant.
  2. Configure Hermes per the README/.env.example, setting TEAMS_TENANT_ID=common (the .env.example-suggested value for multi-tenant).
  3. Run hermes with the Teams platform enabled.
  4. From a user in any tenant, send the bot a 1:1 message in Teams.
  5. The Bot Framework delivers the activity; Hermes silently rejects it with 401 (token validation failure). Nothing reaches the agent.

If you instead leave TEAMS_TENANT_ID empty, Hermes refuses to start at all (lines 195-201), even though that is exactly the SDK input shape required for multi-tenant JWT validation.

Expected Behavior

TEAMS_TENANT_ID should be optional in the adapter. When unset, the adapter should:

  • Not refuse to start (remove tenant_id from the required-credentials check).
  • Pass tenant_id=None (or omit the kwarg) to microsoft-teams-apps App(), which puts the SDK into multi-tenant JWT validation mode (TokenValidator.for_entra() with no tenant constraint).
  • Log clearly that the adapter is starting in multi-tenant mode.

.env.example should either:

  • Update the comment to clarify that "common" does not enable multi-tenant validation — leave the variable empty for that, or
  • Make the multi-tenant story explicit: "Leave TEAMS_TENANT_ID empty for multi-tenant deployments. Set to a tenant GUID for single-tenant."

Actual Behavior

  • TEAMS_TENANT_ID is required at startup (adapter.py lines 195-201).
  • Whatever value is supplied, including "common", is passed verbatim to App(tenant_id=...) (adapter.py lines 208-215) and to is_available() (line 146).
  • The interactive setup wizard (line 631-635) and the gateway requirements list (line 670) both require a non-empty TEAMS_TENANT_ID.
  • The .env.example comment (line 430) suggests "common" works for multi-tenant; it does not.

The microsoft-teams-apps SDK's App.__init__ builds its TokenValidator via TokenValidator.for_entra(tenant_id) when tenant_id is truthy, and TokenValidator.for_entra() (no argument) when it is falsy. The first form constrains the JWT issuer/audience to the supplied tenant; the second permits any tenant. The string "common" is not treated specially — it is used as a tenant identifier, which Microsoft's identity platform does not issue tokens against, so all incoming tokens fail validation.

Affected Component

  • Gateway (Telegram/Discord/Slack/WhatsApp) — specifically the Teams platform adapter
  • Configuration (config.yaml, .env, hermes setup)

Messaging Platform

  • Microsoft Teams (the upstream .github/ISSUE_TEMPLATE/bug_report.yml doesn't list Teams in the platform dropdown yet — worth adding alongside this fix)

Debug Report

Not applicable — this is a documented design / docs-drift bug discovered by source-reading the upstream main branch (commit 0159f25fd024c76a5a1f66fdbca39a828e4e2a61) and the published microsoft-teams-apps SDK. No runtime debug share is needed to reproduce the analysis.

Operating System

N/A — platform-independent (the bug lives in the adapter and the SDK contract).

Hermes Version

Reproduced against upstream main at 0159f25fd024c76a5a1f66fdbca39a828e4e2a61 (current HEAD at time of filing).

Originally observed deploying Hermes 0.12.0 from a downstream repo where the bot's App Registration sits in a separate Terraform-runner tenant from the corporate users tenant. The same code path exists upstream.

Root Cause Analysis

In plugins/platforms/teams/adapter.py:

# line 169
self._tenant_id = extra.get("tenant_id") or os.getenv("TEAMS_TENANT_ID", "")

# lines 195-201
if not self._client_id or not self._client_secret or not self._tenant_id:
    self._set_fatal_error(
        "MISSING_CREDENTIALS",
        "TEAMS_CLIENT_ID, TEAMS_CLIENT_SECRET, and TEAMS_TENANT_ID are all required",
        retryable=False,
    )
    return False

# lines 208-215
self._app = App(
    client_id=self._client_id,
    client_secret=self._client_secret,
    tenant_id=self._tenant_id,
    http_server_adapter=_AiohttpBridgeAdapter(aiohttp_app),
    client=ClientOptions(headers={"User-Agent": "Hermes"}),
)

The validation check forces tenant_id to be non-empty before the App() call. The SDK then interprets that non-empty value as a single-tenant constraint. There is no supported code path that lets a deployer pass tenant_id=None (or omit it) to the SDK.

Proposed Fix

Two-part change, both small:

  1. Make tenant_id optional in the adapter.

    • Drop self._tenant_id from the required-credentials check at lines 195-201 (keep client_id and client_secret required).
    • When self._tenant_id is empty, omit the kwarg from the App() call (or pass tenant_id=None) so the SDK defaults to multi-tenant JWT validation.
    • In is_available() (line 146), drop tenant_id from the truthiness gate.
    • In the interactive setup wizard (lines 630-635), make the tenant-ID prompt optional with explicit "Leave blank for multi-tenant" guidance.
    • In plugin.yaml requires_env, move TEAMS_TENANT_ID from required to optional (or document conditional requirement).
    • Add a startup log line distinguishing single-tenant vs multi-tenant mode so operators can tell which mode they're actually in.
  2. Fix .env.example line 430. Replace:

    # TEAMS_TENANT_ID=                # Azure AD tenant ID (or "common" for multi-tenant)

    with something like:

    # TEAMS_TENANT_ID=                # Azure AD tenant ID for single-tenant bots.
    #                                 # LEAVE EMPTY for multi-tenant bots —
    #                                 # the SDK treats any value (including "common")
    #                                 # as a single-tenant constraint and will 401 on
    #                                 # cross-tenant tokens.
  3. Optional but adjacent: add Microsoft Teams to the Messaging Platform dropdown in .github/ISSUE_TEMPLATE/bug_report.yml so future Teams bugs land with the right metadata.

Happy to put up a PR if it would help — let me know if you'd prefer the single-tenant default preserved (with multi-tenant as opt-in via something explicit like TEAMS_TENANT_ID=multi) or the strict "empty == multi" mapping I've described above. The latter matches what the SDK actually does and avoids inventing a new sentinel value.

Cross-references

  • Microsoft Teams cross-tenant install symptom that surfaces this: 404 Workload Unknown from teamsgraph.teams.microsoft.com/v1.0/groups/{group-id}/isUserAuthorizedToGrantResourceSpecificPermissions. Not Hermes' bug per se, but the only way to fix it for this deployment shape requires the multi-tenant adapter mode that this issue is asking to enable.
  • microsoft-teams-apps (PyPI: https://pypi.org/project/microsoft-teams-apps/) — the upstream SDK whose App(tenant_id=...) contract this issue is about.

Are you willing to submit a PR for this?

Yes — happy to.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING