¿Tu SSO en pestaña de Teams falla con “App resource defined in manifest and iframe origin do not match”? Aquí tienes la explicación precisa de por qué ocurre, la solución exacta (con ejemplos para local, túneles y producción) y una guía paso a paso para dejarlo funcionando con @microsoft/teamsfx
(React + Fluent UI) y Microsoft Graph.
Resumen rápido del problema
Al implementar SSO en una pestaña de Microsoft Teams con @microsoft/teamsfx
, el intento de obtener el token puede fallar con el error:
App resource defined in manifest and iframe origin do not match
Un ejemplo típico de manifiesto problemático es:
{
"webApplicationInfo": {
"id": "<client-id>",
"resource": "api://<client-id>" // ❌ Causa el error
}
}
En Microsoft Entra ID (antes Azure AD), el Application ID URI también estaba definido como api://<client-id>
, y se probaba en local con https://localhost:53000
. Resultado: el token SSO no se emite y aparece el error.
La solución en una frase
El “resource” del manifiesto (y el Application ID URI en Entra ID) deben incluir el dominio (origen) de la página alojada en el iFrame de Teams.
En concreto, define ambos así y asegúrate de que coincidan exactamente:
Application ID URI (Entra ID): api://<dominio-completo>/<client-id>
webApplicationInfo.resource: api://<dominio-completo>/<client-id>
Ejemplos
- Producción:
api://app.contoso.com/<client-id>
- Desarrollo (local):
api://localhost/<client-id>
(usa el host/origen; no uses la forma cortaapi://<client-id>
).
Por qué ocurre el error (modelo mental claro)
Teams abre tu pestaña en un iFrame. En el flujo de SSO, Teams compara tres piezas de información:
- El origen del iFrame que carga tu pestaña (por ejemplo,
https://localhost:53000
ohttps://app.contoso.com
). - El Application ID URI configurado en Microsoft Entra ID (Expose an API).
- El
webApplicationInfo.resource
del manifiesto de la app de Teams.
Si cualquiera de esos valores no concuerda en el dominio, la validación falla y verás “App resource defined in manifest and iframe origin do not match”. La forma abreviada api://<client-id>
no aporta el dominio, por eso rompe la comparación.
Guía paso a paso para corregirlo
Paso 1 — Configurar Microsoft Entra ID (Expose an API)
- En tu app registrada, ve a Expose an API.
- Establece Application ID URI con el formato:
api://<tu-dominio>/<client-id>
- Si tu pestaña se sirve desde
https://localhost:53000
, usaapi://localhost/<client-id>
. - Si usas un túnel (ngrok, Dev Tunnels, Tunnels de VS/Visual Studio Code), usa su FQDN:
api://<subdominio>.ngrok.io/<client-id>
- Si tu pestaña se sirve desde
- (Opcional pero recomendado) Crea/Verifica el scope de SSO:
- Nombre habitual:
accessasuser
(en algunos templates/ejemplos se ve comoaccessasuser
). - Descripción acorde y State habilitado.
- Nombre habitual:
Paso 2 — Actualizar el manifiesto de Teams
En el app manifest, alinea webApplicationInfo
con Application ID URI y el dominio real desde el que se sirve tu pestaña:
{
// ...
"webApplicationInfo": {
"id": "<client-id>",
"resource": "api://<tu-dominio>/<client-id>"
},
"validDomains": [
"localhost",
"app.contoso.com",
"mi-tunel-1234.ngrok.io"
],
"configurableTabs": [
{
"configurationUrl": "https://app.contoso.com/config",
"canUpdateConfiguration": true,
"scopes": ["team", "groupchat", "personal"]
}
],
"staticTabs": [
{
"entityId": "home",
"name": "Inicio",
"contentUrl": "https://app.contoso.com/tab",
"scopes": ["personal"]
}
]
// ...
}
Claves:
webApplicationInfo.resource
debe ser idéntico al Application ID URI.- Incluye el dominio (o dominios) en
validDomains
. - Las URLs reales (
contentUrl
,configurationUrl
) deben usar ese mismo dominio.
Paso 3 — Reempaquetar e instalar la app en Teams
- Incrementa la versión del manifiesto (campo
version
). - Empaqueta e instala de nuevo en Teams.
- Si el error persiste, limpia caché de Teams, cierra sesión y vuelve a entrar, o reinicia el cliente de escritorio. En web, realiza un hard reload.
Ejemplos completos (listos para copiar)
Local (https://localhost:53000)
Elemento | Valor correcto | Notas |
---|---|---|
Application ID URI (Entra ID) | api://localhost/<client-id> | No incluyas puerto; usa solo localhost como host. |
webApplicationInfo.resource | api://localhost/<client-id> | Debe coincidir 1:1 con el Application ID URI. |
validDomains | ["localhost"] | Agrega dominios adicionales si los usas (p. ej., túnel). |
contentUrl/configurationUrl | https://localhost:53000/... | Usa el mismo host. |
{
"webApplicationInfo": {
"id": "<client-id>",
"resource": "api://localhost/<client-id>"
},
"validDomains": ["localhost"],
"staticTabs": [
{ "entityId": "local", "name": "Local", "contentUrl": "https://localhost:53000/tab", "scopes": ["personal"] }
]
}
Túnel ngrok (https://abc123.ngrok.io)
Elemento | Valor correcto | Notas |
---|---|---|
Application ID URI | api://abc123.ngrok.io/<client-id> | El subdominio cambia en cada sesión si no es estático. |
webApplicationInfo.resource | api://abc123.ngrok.io/<client-id> | Actualiza el manifiesto cada vez que cambie el FQDN. |
validDomains | ["abc123.ngrok.io"] | Incluye también localhost si alternas entornos. |
Producción (https://app.contoso.com)
Elemento | Valor correcto | Notas |
---|---|---|
Application ID URI | api://app.contoso.com/<client-id> | Recomendado usar un dominio estable. |
webApplicationInfo.resource | api://app.contoso.com/<client-id> | Debe coincidir exactamente. |
validDomains | ["app.contoso.com"] | Añade subdominios si hay múltiples endpoints. |
Checklist rápido (evita recaídas)
Application ID URI
=api://<dominio-de-la-pestaña>/<client-id>
.webApplicationInfo.resource
idéntico al Application ID URI.validDomains
contiene ese dominio (ylocalhost
si aplica).- Las URLs de la pestaña (
contentUrl
/configurationUrl
) usan ese mismo dominio. - Si cambia el dominio (nuevo subdominio de túnel), actualiza Entra ID y el manifiesto.
Cómo validar que ya funciona
- Abre la pestaña dentro de Teams; ejecuta el flujo de obtención de token SSO con
@microsoft/teamsfx
. - Inspecciona la consola: ya no debe aparecer el error del “resource” y el token debe recibirse.
- Verifica el audience (
aud
) y el issuer (iss
) del token decodificándolo con un visor JWT. Elaud
debe corresponder a tu Application ID URI/ID del recurso.
Integración con Microsoft Graph (flujo OBO)
El token SSO emitido para tu API no es directamente un token de Graph. Para llamar a Microsoft Graph desde el servidor, usa el patrón On‑Behalf‑Of (OBO):
- El cliente (pestaña) obtiene token SSO para tu recurso (
api://<dominio>/<client-id>
). - Envía ese token al backend (HTTPS).
- El backend ejecuta OBO contra Entra ID para intercambiarlo por un token de Graph, solicitando permisos delegados (p. ej.,
User.Read
).
Scopes recomendados: empieza por User.Read
, añade otros según necesidades (Mail.Read
, etc.).
Ejemplo OBO con Node.js y MSAL
// Backend (Node/Express) - ejemplo simplificado
import express from "express";
import { ConfidentialClientApplication } from "@azure/msal-node";
const app = express();
app.use(express.json());
const msal = new ConfidentialClientApplication({
auth: {
clientId: process.env.CLIENT\_ID, // \ de tu app
authority: `https://login.microsoftonline.com/${process.env.TENANT_ID}`,
clientSecret: process.env.CLIENT\_SECRET
}
});
app.post("/me", async (req, res) => {
try {
const oboRequest = {
oboAssertion: req.body.ssoToken, // token SSO recibido desde la pestaña
scopes: \["[https://graph.microsoft.com/User.Read](https://graph.microsoft.com/User.Read)"]
};
const result = await msal.acquireTokenOnBehalfOf(oboRequest);
// Usa result.accessToken para llamar a Graph
res.json({ accessTokenForGraph: !!result.accessToken });
} catch (e) {
console.error(e);
res.status(401).json({ error: "OBO failed" });
}
});
app.listen(3000, () => console.log("API running"));
Integración con @microsoft/teamsfx
(cliente)
En el lado cliente, puedes usar las utilidades de TeamsFx para obtener el token SSO y pasarlo a tu API:
// Frontend (React) - ejemplo simplificado
import { TeamsUserCredential } from "@microsoft/teamsfx";
async function getSsoTokenAndCallApi() {
const credential = new TeamsUserCredential();
// El scope de SSO suele ser "access\as\user" (a veces "accessasuser")
const token = await credential.getToken(""); // token SSO para tu recurso
const response = await fetch("/me", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ssoToken: token?.token })
});
const data = await response.json();
console.log(data);
} </code></pre>
<p><em>Nota:</em> asegúrate de que la app y el manifiesto referencian el mismo <code>clientId</code>, <code>initiateLoginEndpoint</code> (si aplica) y dominio de origen que has configurado en el Application ID URI.</p>
<h2>Mapa de decisiones (qué valor usar en cada entorno)</h2>
<table>
<thead>
<tr>
<th>Entorno</th>
<th>Origen de la pestaña</th>
<th>Application ID URI</th>
<th>Manifest <code>webApplicationInfo.resource</code></th>
<th><code>validDomains</code></th>
</tr>
</thead>
<tbody>
<tr>
<td>Local</td>
<td><code>https://localhost:53000</code></td>
<td><code>api://localhost/<client-id></code></td>
<td><code>api://localhost/<client-id></code></td>
<td><code>["localhost"]</code></td>
</tr>
<tr>
<td>ngrok (dinámico)</td>
<td><code>https://abc123.ngrok.io</code></td>
<td><code>api://abc123.ngrok.io/<client-id></code></td>
<td><code>api://abc123.ngrok.io/<client-id></code></td>
<td><code>["abc123.ngrok.io"]</code></td>
</tr>
<tr>
<td>ngrok (estático)</td>
<td><code>https://dev-contoso.ngrok.io</code></td>
<td><code>api://dev-contoso.ngrok.io/<client-id></code></td>
<td><code>api://dev-contoso.ngrok.io/<client-id></code></td>
<td><code>["dev-contoso.ngrok.io"]</code></td>
</tr>
<tr>
<td>Producción</td>
<td><code>https://app.contoso.com</code></td>
<td><code>api://app.contoso.com/<client-id></code></td>
<td><code>api://app.contoso.com/<client-id></code></td>
<td><code>["app.contoso.com"]</code></td>
</tr>
</tbody>
</table>
<h2>Errores relacionados y cómo resolverlos</h2>
<ul>
<li><strong>AADSTS500011: “The resource principal named … was not found”</strong><br>
Suele indicar que el Application ID URI no existe tal cual en el tenant. Revisa ortografía, dominio y que el recurso esté expuesto en la app correcta (tenant correcto).</li>
<li><strong>AADSTS65001 / consent_required</strong><br>
Falta consentimiento para algún permiso delegado (p. ej., Graph). Concede el consentimiento (admin si aplica) o solicita scopes menos intrusivos.</li>
<li><strong>invalid_scope</strong><br>
Estás pidiendo un scope que no existe o no pertenece al recurso. Alinea los scopes de OBO con Graph (<code>https://graph.microsoft.com/.default</code> o <code>.../User.Read</code>), y los de SSO con tu recurso (<code>accessasuser</code>).</li>
<li><strong>redirecturimismatch</strong><br>
Ocurre si usas <em>initiateLoginEndpoint</em> o auth tradicional y la URL configurada no coincide. Revisa el dominio y rutas.</li>
</ul>
<h2>Buenas prácticas para Dev/Prod</h2>
<ul>
<li><strong>Dominios estables</strong>: evita subdominios efímeros en producción; usa dominios fijos.</li>
<li><strong>Variables de entorno</strong>: parametriza <code>Application ID URI</code>, dominios y URLs por entorno.</li>
<li><strong>Automatiza</strong>: si usas ngrok dinámico, genera scripts que actualicen el Application ID URI y el manifiesto cuando cambie el subdominio, y reempaqueta la app.</li>
<li><strong>Versionado de manifiesto</strong>: sube la versión en cada cambio para forzar actualización en Teams.</li>
<li><strong>Seguridad</strong>: protege el endpoint backend (CORS, validación de tokens, HTTPS).</li>
</ul>
<h2>Snippets útiles para automatizar cambios</h2>
<p><strong>Actualizar el dominio del Application ID URI y el manifest (ejemplo con placeholders):</strong></p>
<pre><code># Supón que NEWDOMAIN=xyz.ngrok.io y CLIENTID=...
1) Actualiza tu manifest.json (uso genérico con sed; ajusta a tu OS/shell)
sed -i.bak "s#api://[^/]*/$CLIENTID#api://$NEWDOMAIN/$CLIENT_ID#g" ./appPackage/manifest.json
2) Recuerda actualizar el Application ID URI en Entra ID manualmente o vía script/infra (ARM/Bicep/Terraform)
</code></pre>
<h2>Preguntas frecuentes</h2>
<p><strong>¿Por qué “api://<client-id>” a veces funciona en otras apps, pero no en mi pestaña de Teams?</strong><br>
Porque en Teams SSO el iFrame introduce una validación adicional del <em>origen</em>, y se espera que el recurso incluya el dominio para que el emisor del token pueda verificar a qué sitio (host) está asociado ese recurso.</p>
<p><strong>¿Puedo usar comodines (wildcards) en el dominio del Application ID URI?</strong><br>
No. Debes especificar el FQDN exacto. En túneles dinámicos, cada cambio de subdominio requiere actualización.</p>
<p><strong>¿El nombre del scope es “accessasuser” o “accessasuser”?</strong><br>
El nombre más habitual es <code>accessasuser</code>. Algunos templates/herramientas muestran <code>accessasuser</code>; si ya lo tienes así y funciona en tu tenant, puedes mantenerlo, pero procura ser consistente.</p>
<p><strong>¿Debo incluir el puerto en el Application ID URI?</strong><br>
No. Para <code>localhost</code> utiliza <code>api://localhost/<client-id></code> (sin puerto). Las URLs de pestaña sí pueden incluir puerto en <code>contentUrl</code>/<code>configurationUrl</code>.</p>
<p><strong>¿Cómo depuro el token?</strong><br>
Decodifícalo con una herramienta de JWT para revisar <code>aud</code>, <code>iss</code> y <code>scp</code>. Comprueba que <code>aud</code> corresponde a tu recurso y que <code>scp</code> incluye el scope de SSO (por ejemplo, <code>accessasuser</code>).</p>
<h2>Plantilla de verificación final</h2>
<ol>
<li>He establecido el <strong>Application ID URI</strong> a <code>api://<dominio-correcto>/<client-id></code>.</li>
<li>El <strong>manifest</strong> usa el mismo valor en <code>webApplicationInfo.resource</code>.</li>
<li>El origen (<code>contentUrl</code>/<code>configurationUrl</code>) carga desde ese dominio.</li>
<li><code>validDomains</code> incluye ese dominio (y <code>localhost</code> si aplica).</li>
<li>El SSO devuelve token y el error desaparece.</li>
</ol>
<h2>Resumen práctico</h2>
<p>El error <em>“App resource defined in manifest and iframe origin do not match”</em> aparece cuando el recurso de tu app (Application ID URI / <code>webApplicationInfo.resource</code>) <strong>no incluye</strong> el dominio real desde el que se sirve la pestaña o <strong>no coincide</strong> con él. La corrección es directa: usa el formato <code>api://<dominio>/<client-id></code> tanto en Entra ID como en el manifiesto, alinea <code>validDomains</code> y las URLs de la pestaña, reinstala la app, y listo.</p>
<hr>
<h2>Apéndice — Descripción original (para referencia)</h2>
<p><strong>Error SSO en pestaña de Teams: “App resource defined in manifest and iframe origin do not match”</strong></p>
<p><em>Resumen de la Pregunta</em><br>
Implementando SSO con <code>@microsoft/teamsfx</code> en una pestaña de Teams (React + Fluent UI), la obtención del token falla con el error:
“App resource defined in manifest and iframe origin do not match.”<br>
El manifiesto usaba:</p>
<pre><code>"webApplicationInfo": {
"id": "<client-id>",
"resource": "api://<client-id>"
}
</code></pre>
<p>En Azure AD, el Application ID URI también era <code>api://<client-id></code>. Se probaba en local con <code>https://localhost:53000</code>.</p>
<p><em>Respuesta y Solución</em><br>
La causa es que el “resource” (Application ID URI) debe incluir el dominio (origen) de la página que se carga en el iFrame de Teams.
La solución efectiva fue cambiar ambos valores a este formato y que coincidan exactamente:</p>
<pre><code>Application ID URI (Azure AD): api://<dominio-completo>/<client-id>
webApplicationInfo.resource (manifest): api://<dominio-completo>/<client-id>
</code></pre>
<p><strong>Ejemplo</strong></p>
<ul>
<li>Producción: <code>api://app.contoso.com/<client-id></code></li>
<li>Desarrollo (local): <code>api://localhost/<client-id></code> (usa el host/origen; no uses la forma corta <code>api://<client-id></code>).</li>
</ul>
<p><em>Pasos concretos para corregirlo</em></p>
<ol>
<li>Azure AD → Expose an API
<ul>
<li>Establece Application ID URI a <code>api://<tu-dominio>/<client-id></code>.
<ul>
<li>Si tu pestaña se sirve desde <code>https://localhost:53000</code>, usa <code>api://localhost/<client-id></code>.</li>
<li>Si usas un túnel (ngrok/dev tunnels), usa el FQDN del túnel: <code>api://<subdominio>.ngrok.io/<client-id></code>.</li>
</ul>
</li>
</ul>
</li>
<li>Manifiesto de Teams
<ul>
<li>Asegura que:
<pre><code>"webApplicationInfo": {
"id": "<client-id>",
"resource": "api://<tu-dominio>/<client-id>"
}
</li>
<li>En validDomains y en las URLs de la pestaña (contentUrl, configurationUrl), usa el mismo dominio que pusiste en el Application ID URI.</li>
</ul>
Reinstala/actualiza la app en Teams
- Después de cambiar el manifiesto, vuelve a empaquetar e instalar la app (borra caché si persiste el error).
Por qué ocurre el error (en corto)
Teams valida que el origen del iFrame (la página de tu pestaña) coincida con el dominio del Application ID URI
(el “resource” del manifiesto). Si usas la forma corta api://<client-id>
o un dominio diferente, la comprobación falla y aparece el error.
Checklist
Application ID URI
=api://<dominio-de-la-pestaña>/<client-id>
.webApplicationInfo.resource
idéntico al Application ID URI.validDomains
contiene ese dominio (ylocalhost
si aplica).- Las URLs de la pestaña (content/configuration) se sirven desde el mismo dominio.
- Si el dominio cambia (p. ej., nuevo subdominio de ngrok), actualiza Azure AD y el manifiesto.
Información complementaria (útil para SSO + Graph)
En Expose an API, define el scope accessasuser
(también se ve como accessasuser
en algunos templates). Para llamar a Microsoft Graph con el token de SSO, implementa el On‑Behalf‑Of (OBO) en tu backend y concede los permisos delegados necesarios en Entra ID (p. ej., User.Read
).
Resultado: Ajustar el Application ID URI
y alinear webApplicationInfo.resource
con el dominio/origen real de la pestaña elimina el error y permite obtener el token SSO correctamente.