Feel free to ask us anything about us or our offerings. Think of this agent as your personal consultant.

BLOG

DEFINITY DISCOURSE

DEFINITY DISCOURSE

Troubleshooting Vercel hosted AI Agent to Bot to Teams to Dynamics ERP Single Sign On, by Omar del Rio

Troubleshooting Vercel hosted AI Agent to Bot to Teams to Dynamics ERP Single Sign On, by Omar del Rio

There is a particular kind of debugging session that is more frustrating than the outright broken kind. It is the one where the system appears to be mostly working, each component seems individually reasonable, and every error message is technically true, yet none of it adds up to a coherent explanation of what is actually wrong.

I was building a Microsoft Teams bot to call a business API on behalf of the signed-in user; the bot is the face of my agent, which runs on Vercel. The intended flow is clean enough to almost invite overconfidence.

The bot posts an OAuthCard. Teams silently sends a sign-in/tokenExchange invoke containing the user’s identity. The server exchanges that assertion for an API-scoped token, and the user gets their data without being asked to click through consent screens or re-authenticate. When that path works, it feels elegant, much like many Microsoft identity flows are supposed to feel once the plumbing is in place.

When it does not work, Teams falls back to a card that says, “To use this app, you need to agree to additional permissions,” and if the user clicks it, the flow may end with authrequestfailedfailed to handle SSO auth request.

The natural reading is that there must be some missing consent grant, so the first instinct is to revisit consent. Re-run the admin consent URL. Double-check the app registration. Look again at the delegated permissions. When nothing changes, the next instinct is to suspect the scope, because scope mistakes are common enough; and frankly, OAuth can become very complicated, if not impossible to grasp sometimes. I went down that path too. At one point I tried user_impersonation, and Azure AD responded with AADSTS650053: scope ‘user_impersonation’ doesn’t exist on the resource ‘00000015-0000-0000-c000-000000000000’ (this is the service principal for Microsoft Finance).

That identifier turned out to matter more than the scope name itself. Azure AD was resolving the request against the parent resource (the MS-registered SP) rather than the tenant-specific service principal I actually cared about. It was one of those moments in which the system gives you a completely accurate answer to a question you should not have been asking in the first place. I changed the scope, got a different error, followed that one for a while, and only later understood that I was still looking at symptoms locally while the real issue was architectural and cumulative.

What this flow taught me is that Teams bot SSO is not best understood as one integration. Sometimes the token exchange succeeds, and you only discover the problem when a downstream API rejects the token with a 401 (Unauthorized) response. Either way, the visible failure tends to reflect the point at which the system finally gave up, not the configuration choice that made failure inevitable.

The first gate to resolve this sits in the Teams manifest. The app needs a webApplicationInfo block that points to the Entra app’s client ID and the Application ID URI exposed by that app. For a standalone bot, the URI convention is api://botid-{clientId}. If that block is missing, Teams never really enters the silent SSO path. It does not emit a particularly useful warning, because from the client’s perspective, nothing exceptional happened. It simply does not attempt the token exchange and falls back to the visible OAuth path. Unless you already know that the manifest participates in the SSO chain in this specific way, it is easy to miss how foundational that single block is.

The second gate is the delegated scope exposed by the app registration itself. Under Expose an API, the app needs a custom delegated scope, usually access_as_user, which yields the full scope URI api://botid-{clientId}/access_as_user. Again, the failure mode is not especially instructive. If that scope does not exist, Teams has nothing meaningful to request on the user’s behalf, but the resulting behavior still appears to be a generic authentication issue rather than a missing audience definition.

The third gate is where the design started to reveal its actual character. Under the same Expose an API section, there is an area called Authorized client applications. It is visually unremarkable and easy to skip past, especially because many app registrations leave it empty. In this flow, it is not optional. Teams clients will not silently request the delegated scope unless those Microsoft-owned clients are explicitly pre-authorized for it. The relevant client IDs are 1fec8e78-bce4-4aaf-ab1b-5451cc387264 for Teams desktop and mobile, and 5e3ce6c0-2b1f-4285-8d4b-75ee78787346 for Teams web. Both need to be present and authorized for the access_as_user scope.

This was the load-bearing omission in my case, and it is a good example of how misleading the surface behavior can be. When Teams shows a consent fallback card, it is tempting to think about the user and their permissions. In reality, the user may not be the issue at all (and even more, the authentication request fails even if you attempt to consent right there). The missing link may be that Microsoft’s own Teams clients have not been allowed to request the scope silently, which is a very different kind of problem. The system presents the failure as if the user needs to do something, when in fact the application owner failed to authorize the client-to-scope relationship upstream.

The fourth gate is the token version. In the Entra app manifest, requestedAccessTokenVersion must be set to 2. If it is null or 1, Teams SSO degrades again, and it does so in the same quiet way as the other missing pieces. There is no dramatic exception pointing you to token versioning. The silent path simply never becomes reliably silent. This is one of those cases where the portal can also make life harder than it should be, because trusting the Save button is less reliable than verifying the state directly via Graph or CLI. I ended up trusting az ad app show and Graph queries more than the portal, not because the portal is always wrong, but because when debugging identity infrastructure, the CLI gives you a view without noise and without the jump-through-many-panes the UI forces you to do.

By the time I reached the fifth gate, the problem had shifted from “why is Teams showing a consent card?” to “why does the downstream API reject the token even though the exchange succeeded?” That distinction turned out to be the most interesting part of the entire exercise because it exposed where my own mental model of the flow was wrong.

Bot Framework’s OAuth Connection requires several configuration values: provider, tenant, client ID, client secret, token exchange URL, and Scopes. It is very easy to treat the Scopes field as just the usual declaration of permissions, but in practice, it does something more consequential. It determines the audience of the token the bot ultimately receives - this is something that I already knew, but my mental model had become so contaminated with different concepts that it went past me (and everything was supposed to be “correct“; all tests, except the final gate, were working).

My first version used the documented parent Dynamics resource, something like *https://erp.dynamics.com/AX.FullAccess*. That scope is valid. Azure AD accepts it. The exchange succeeds. The bot receives a token. By ordinary debugging standards, that looks like progress, and it is enough progress that you may assume the hard part is over.

When I used that token against the actual business API, the API responded with a 401 and no helpful body. Once I decoded the JWT, the reason was obvious. The token’s aud was https://erp.dynamics.com, but the API I was actually calling lived at https://{tenant}.operations.dynamics.com. The API was validating against its own tenant-specific URL as the acceptable audience and rejecting the generic parent resource outright. In other words, I had a valid token for the wrong resource, which is the sort of error that conventional permission language does not describe very well. Nothing was missing in the usual sense. The token was simply minted for an audience that the downstream API had no interest in honoring.

The fix was to configure the OAuth Connection scope with the tenant-specific resource prefix instead: https://{tenant}.operations.dynamics.com/AX.FullAccess. The permission name did not change. The resource did. Once the resource prefix matched the actual API base, the audience matched too, and the downstream call succeeded.

That was also the point where the role of Bot Framework’s OAuth Connection became clearer to me. My original plan had been to use MSAL’s acquireTokenOnBehalfOf after calling UserTokenClient.exchange(), because that is how many Microsoft samples condition people to think about OBO. You receive a bootstrap assertion, you perform OBO, and then you call the downstream API. It sounds reasonable, and in other contexts it is exactly right. Here it was not.

When I tried to chain MSAL on top of the Bot Framework exchange, I got AADSTS500131: assertion audience does not match the client app presenting the assertion. At first, that looked like one more detour. In retrospect, it was the clue that forced the design into focus. Bot Framework’s OAuth Connection had already performed the trust hop. The token returned by UserTokenClient.exchange() was not an intermediate bootstrap artifact awaiting re-exchange. It was already the final token, already minted for the downstream API specified by the connection’s scope. Passing that token into another OBO step was not just unnecessary. It was conceptually wrong. I was trying to perform OBO on the OBO output.

The bot did not need to receive a token and then decide what to do with it. The bot needed to call .exchange(), cache the returned token by (channelId, userId) with a TTL aligned to exp, and use it directly as the bearer token against the business API. No MSAL layer. No second exchange. No extra error surface created by my own misunderstanding of what the first exchange had already accomplished.

What stayed with me after all of this was not any single configuration detail, but the broader pattern of how these systems fail. Distributed identity flows often do not break in ways that reflect their true structure. They break at the edge of visibility. A card appears. A consent prompt shows up. A downstream API rejects a token. A scope error points to the wrong service principal. Every one of those events is real, but none is a satisfying explanation.

The visible symptom invites a small, local fix, but the real work is to reconstruct the entire chain and verify each gate independently. At some point, the only reliable method is to instrument everything, decode every JWT, inspect audscptid, and iss, and cross-check portal state against Graph and CLI output. The portal may tell you what you intended to configure, but the tokens tell you what the system actually believes.

What looked at first like a consent issue turned out to be a lesson in how much silent behavior modern authentication stacks hide behind convenience abstractions. Silent SSO works only because a surprising number of assumptions align across Teams, Entra, the Bot Framework, and the downstream API. When even one of those assumptions is broken, the system remains remarkably quiet about where the break actually is. What compounds this is the complexity that is exposed through the Azure Portal panes; complexities that are frustrating and merit another post.

Check out Omar's Substack

Check out Omar's Substack

Share Article

Latest News

Engineering digital solutions that transform bold ideas into measurable business results.

© Sieena, Inc. All rights reserved

Stay in the know.

Subscribe to our newsletter for insights and updates.

Engineering digital solutions that transform bold ideas into measurable business results.

© Sieena, Inc. All rights reserved

Stay in the know.

Subscribe to our newsletter for insights and updates.

Engineering digital solutions that transform bold ideas into measurable business results.

© Sieena, Inc. All rights reserved

Stay in the know.

Subscribe to our newsletter for insights and updates.

Engineering digital solutions that transform bold ideas into measurable business results.

© Sieena, Inc. All rights reserved

Stay in the know.

Subscribe to our newsletter for insights and updates.