mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
15499e7fc2
commit
2d1242556e
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+1
-27
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user