test(msteams): serve JWKS from in-process server in auth coverage (#119354)

* test(msteams): serve JWKS from in-process server in auth coverage

The Entra/service-token validator tests spied JwksClient.prototype on the
jwks-rsa copy the test file imports, but the SDK's internal
require("jwks-rsa") resolves a different physical copy and vitest cannot
intercept a CommonJS require inside node_modules — so the spy missed and
the tests hit the real login.botframework.com / login.microsoftonline.com
JWKS endpoints (JwksError: Bad Request, SigningKeyNotFoundError).

Replace the mock with a real JWKS document served from an in-process
node:http server, pointed at via the SDK's own endpoint overrides
(openIdMetadataUrl cloud override for the service validator, loginEndpoint
for the Entra factory). The v2-issuer acceptance test drives the underlying
JwtValidator with a decoupled jwksUriOptions {type:'uri'} so issuer
validation uses the real login host while keys come from the local server.
Deterministic, no external network, and exercises the real fetch + RS256
verify path.

* test(msteams): fix cloud-environment import and drop unused jwks-rsa

The extensions test-types gate resolves @microsoft/teams.api's dual
.d.ts/.d.mts types strictly and does not see the root re-export of
withOverrides/PUBLIC (TS2305), so import them from the canonical
auth/cloud-environment subpath the SDK itself uses. The JWKS-server rework
also removed the last direct jwks-rsa import, so drop the now-unused
devDependency and refresh the lockfile (knip deadcode).

---------

Co-authored-by: Peter Steinberger <steipete@mac-studio-sf2.local>
This commit is contained in:
Peter Steinberger
2026-08-04 15:34:38 -07:00
committed by GitHub
parent 15499e7fc2
commit 2d1242556e
3 changed files with 92 additions and 55 deletions
-1
View File
@@ -19,7 +19,6 @@
"@microsoft/teams.common": "2.0.14",
"@openclaw/plugin-sdk": "workspace:*",
"jose": "6.2.4",
"jwks-rsa": "4.1.0",
"openclaw": "workspace:*"
},
"peerDependencies": {
+91 -27
View File
@@ -22,26 +22,45 @@
* tested is purely the validator's accept/reject behavior — the surrounding
* HTTP plumbing is a separate concern covered by `monitor.lifecycle.test.ts`.
*
* `JwksClient.prototype.getSigningKey` is patched to return a single
* in-memory test public key so we don't hit `login.botframework.com` /
* `login.microsoftonline.com` during the test. `jose` (devDep) mints RS256
* tokens against the matching private key.
* The validators fetch signing keys over HTTP from a JWKS endpoint, so the
* test serves a real JWKS document from an in-process `node:http` server on
* 127.0.0.1 and points the validators at it via the SDK's own endpoint
* overrides (`withOverrides(..., { openIdMetadataUrl })` for the service
* validator, `loginEndpoint` for the Entra validator). This exercises the
* SDK's real JWKS fetch + signature verification path instead of stubbing it,
* and keeps the test fully deterministic with no external network access.
* `jose` (devDep) mints RS256 tokens against the matching private key.
*/
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { PUBLIC, withOverrides } from "@microsoft/teams.api/dist/auth/cloud-environment.js";
// Internal subpath imports. See file header for the rationale.
import { createEntraTokenValidator } from "@microsoft/teams.apps/dist/middleware/auth/jwt-validator.js";
import {
createEntraTokenValidator,
JwtValidator,
} from "@microsoft/teams.apps/dist/middleware/auth/jwt-validator.js";
import { ServiceTokenValidator } from "@microsoft/teams.apps/dist/middleware/auth/service-token-validator.js";
import type { ILogger } from "@microsoft/teams.common";
import { exportSPKI, generateKeyPair, SignJWT } from "jose";
import { JwksClient, type SigningKey } from "jwks-rsa";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { exportJWK, generateKeyPair, SignJWT } from "jose";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
const APP_ID = "test-app-id";
const TENANT_ID = "test-tenant-id";
const TEST_KID = "test-key-id";
let privateKey: CryptoKey;
let publicPem: string;
let jwksServer: Server;
// The SDK builds the Entra JWKS URI as `${loginEndpoint}/${tenantId}/discovery/v2.0/keys`,
// so the override must carry no path of its own.
let loginEndpoint: string;
// The service validator derives its keys URI by replacing the
// `/openidconfiguration` suffix with `/keys`.
let openIdMetadataUrl: string;
// Direct JWKS URI for the JwtValidator v2-acceptance test, which decouples
// the signing-key source (`jwksUriOptions: { type: 'uri' }`) from the issuer
// allowlist (`loginEndpoint`).
let jwksUri: string;
async function mintToken(claims: Record<string, unknown>): Promise<string> {
return await new SignJWT(claims)
@@ -56,20 +75,31 @@ beforeAll(async () => {
modulusLength: 2048,
});
privateKey = priv;
publicPem = await exportSPKI(publicKey);
const jwk = { ...(await exportJWK(publicKey)), kid: TEST_KID, alg: "RS256", use: "sig" };
const jwksDocument = JSON.stringify({ keys: [jwk] });
// Patch `JwksClient.prototype.getSigningKeys` so every JWKS lookup the SDK
// performs returns our in-memory test key instead of fetching from
// `login.botframework.com` / `login.microsoftonline.com` while preserving
// the package's callback/promise getSigningKey wrapper behavior.
vi.spyOn(JwksClient.prototype, "getSigningKeys").mockResolvedValue([
{
kid: TEST_KID,
alg: "RS256",
getPublicKey: () => publicPem,
rsaPublicKey: publicPem,
} as SigningKey,
]);
jwksServer = createServer((req, res) => {
if (req.url?.endsWith("/keys") || req.url === "/jwks") {
res.writeHead(200, { "content-type": "application/json" });
res.end(jwksDocument);
return;
}
res.writeHead(404);
res.end();
});
await new Promise<void>((resolve) => {
jwksServer.listen(0, "127.0.0.1", resolve);
});
const { port } = jwksServer.address() as AddressInfo;
loginEndpoint = `http://127.0.0.1:${port}`;
openIdMetadataUrl = `${loginEndpoint}/v1/.well-known/openidconfiguration`;
jwksUri = `${loginEndpoint}/jwks`;
});
afterAll(async () => {
await new Promise<void>((resolve, reject) => {
jwksServer.close((err) => (err ? reject(err) : resolve()));
});
});
// Logger that surfaces SDK validation failures so we can see *why* a token
@@ -86,8 +116,19 @@ const debugLogger: ILogger = {
};
describe("ServiceTokenValidator (inbound Bot Framework)", () => {
// A cloud environment whose OpenID metadata URL points at the in-process
// JWKS server; every other endpoint stays on the public-cloud defaults so
// issuer/service-url semantics are unchanged.
const testCloud = () => withOverrides(PUBLIC, { openIdMetadataUrl });
it("accepts a token whose audience matches the bot app id", async () => {
const validator = new ServiceTokenValidator(APP_ID, undefined, undefined, debugLogger);
const validator = new ServiceTokenValidator(
APP_ID,
undefined,
undefined,
debugLogger,
testCloud(),
);
const token = await mintToken({
aud: APP_ID,
iss: "https://api.botframework.com",
@@ -99,7 +140,13 @@ describe("ServiceTokenValidator (inbound Bot Framework)", () => {
});
it("rejects a token with aud=api.botframework.com even when the appid claim matches the bot", async () => {
const validator = new ServiceTokenValidator(APP_ID);
const validator = new ServiceTokenValidator(
APP_ID,
undefined,
undefined,
undefined,
testCloud(),
);
// This is the confused-deputy shape: the token was issued *for* the
// Connector resource (`aud=https://api.botframework.com`) and happens to
// carry the bot's app id in `appid`. The SDK must reject it on the
@@ -119,6 +166,7 @@ describe("createEntraTokenValidator (Entra access tokens — SDK 2.0.10 v1 issue
it("accepts the v1 sts.windows.net issuer for an allowed tenant", async () => {
const validator = createEntraTokenValidator(TENANT_ID, APP_ID, {
allowedTenantIds: [TENANT_ID],
loginEndpoint,
});
const token = await mintToken({
aud: APP_ID,
@@ -132,9 +180,23 @@ describe("createEntraTokenValidator (Entra access tokens — SDK 2.0.10 v1 issue
});
it("accepts the v2 login.microsoftonline.com issuer for an allowed tenant", async () => {
const validator = createEntraTokenValidator(TENANT_ID, APP_ID, {
allowedTenantIds: [TENANT_ID],
});
// `createEntraTokenValidator` hardcodes `jwksUriOptions: { type: 'tenantId' }`,
// which ties both the JWKS fetch and the issuer allowlist to `loginEndpoint`.
// Pointing that at the local JWKS server drops the public login host from the
// allowlist, so it can't prove v2-issuer acceptance. The underlying
// `JwtValidator` decouples the two: `jwksUriOptions: { type: 'uri' }` sources
// keys from the local server while `loginEndpoint` independently drives issuer
// validation, so the real public v2 issuer is accepted here.
const validator = new JwtValidator(
{
clientId: APP_ID,
tenantId: TENANT_ID,
loginEndpoint: "https://login.microsoftonline.com",
validateIssuer: { allowedTenantIds: [TENANT_ID] },
jwksUriOptions: { type: "uri", uri: jwksUri },
},
debugLogger,
);
const token = await mintToken({
aud: APP_ID,
iss: `https://login.microsoftonline.com/${TENANT_ID}/v2.0`,
@@ -143,11 +205,13 @@ describe("createEntraTokenValidator (Entra access tokens — SDK 2.0.10 v1 issue
const payload = await validator.validateAccessToken(token);
expect(payload).not.toBeNull();
expect(payload?.iss).toBe(`https://login.microsoftonline.com/${TENANT_ID}/v2.0`);
});
it("rejects an issuer for a tenant that is not allowed", async () => {
const validator = createEntraTokenValidator(TENANT_ID, APP_ID, {
allowedTenantIds: [TENANT_ID],
loginEndpoint,
});
const token = await mintToken({
aud: APP_ID,
+1 -27
View File
@@ -1388,9 +1388,6 @@ importers:
jose:
specifier: 6.2.4
version: 6.2.4
jwks-rsa:
specifier: 4.1.0
version: 4.1.0(supports-color@10.2.2)
openclaw:
specifier: workspace:*
version: link:../..
@@ -5045,7 +5042,7 @@ packages:
resolution: {integrity: sha512-QShO60SB0E+HH+TbcKj3CBEQbodToRyiXnxuSB4t1kvUlqEmuGA1nOOjrRDkDJbOECAZ13PLe4ek9SrntpfoYg==}
engines: {node: '>=20', npm: '>=9.6.4'}
peerDependencies:
undici: ^7.0.0
undici: 7.29.0
'@slack/types@3.0.0':
resolution: {integrity: sha512-KNOqpnNAlsFt5Jk9XBclslQ0lobRIg/0tnhpmvZJAglHJx9E8oceN8hC3gaBzkR6UzQ9Wzq4rLsJ98wUcxWPfw==}
@@ -7266,10 +7263,6 @@ packages:
resolution: {integrity: sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==}
engines: {node: '>=14'}
jwks-rsa@4.1.0:
resolution: {integrity: sha512-sbkByqyATKYJP5F4RXj03N5TUNC0QLTjCAZvwTzC4BwJZ8e0/cWxN8YROnyUth2g1/ONWi4eSFHeu6oYalrc3Q==}
engines: {node: ^20.19.0 || ^22.12.0 || >= 23.0.0}
jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
@@ -7511,9 +7504,6 @@ packages:
lru-memoizer@2.3.0:
resolution: {integrity: sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==}
lru-memoizer@3.0.0:
resolution: {integrity: sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==}
lru_map@0.4.1:
resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==}
@@ -14558,17 +14548,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
jwks-rsa@4.1.0(supports-color@10.2.2):
dependencies:
'@types/jsonwebtoken': 9.0.10
debug: 4.4.3(supports-color@10.2.2)
jose: 6.2.4
limiter: 1.1.5
lru-cache: 11.5.2
lru-memoizer: 3.0.0
transitivePeerDependencies:
- supports-color
jws@4.0.1:
dependencies:
jwa: 2.0.1
@@ -14787,11 +14766,6 @@ snapshots:
lodash.clonedeep: 4.5.0
lru-cache: 6.0.0
lru-memoizer@3.0.0:
dependencies:
lodash.clonedeep: 4.5.0
lru-cache: 11.5.2
lru_map@0.4.1: {}
magic-string@0.30.21: