Three defects stood between this codebase and a working Entra deployment.
All three fail silently, which is why they are grouped: each one masks the
next, and fixing any two still leaves a broken or dangerous install.
1. JWKS discovery. The key set URL was hardcoded to `${issuer}/jwks/`,
which is Authentik's convention, not a standard. Entra serves keys at
`/{tenant}/discovery/v2.0/keys`, so every Entra-issued MCP token failed
verification on a 404 — authentication was impossible, not merely
misconfigured. We now read `jwks_uri` from the issuer's discovery
document and fall back to the old path, so Authentik is untouched.
Discovery failure arms a 60s retry rather than pinning the wrong URL
for the life of the container.
2. Identity. Entra's `sub` is pairwise — derived from the token
recipient — so the Web UI and MCP app registrations emit different
`sub` values for the same human. Keyed on (iss, sub), that person got
two rows: sign into the Web UI, connect Claude Code, land in an empty
account. Both paths upsert, so nothing errored. Identity now keys on
`oid`, which Microsoft documents as constant across applications in a
tenant, via one resolver both surfaces share. Rows created before the
0005 migration adopt their `oid` on next sign-in.
3. Groups overage. Past 200 groups Entra omits `groups` entirely and
substitutes a `_claim_names` pointer. `normalizeGroupsClaim` read that
as "zero groups" and the sync deleted every membership the user had,
revoking access to every shared project on both surfaces with no error
raised. Both surfaces now refuse such a token instead — the Web UI
fails the sign-in, MCP returns 401 — leaving memberships intact and
naming the operator fix. An absent claim with no overage marker still
clears memberships, which is unchanged and deliberate.
Verified against a real pgvector instance: 66 tests pass, and reverting
either new behaviour fails exactly the tests that cover it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
227 lines
9.0 KiB
TypeScript
227 lines
9.0 KiB
TypeScript
import { createServer, type Server } from "node:http";
|
|
import { AddressInfo } from "node:net";
|
|
import { SignJWT, exportJWK, generateKeyPair } from "jose";
|
|
import type { JWK, KeyLike } from "jose";
|
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
|
|
/**
|
|
* The JWKS location is discovered, not assumed.
|
|
*
|
|
* `${issuer}/jwks/` used to be hardcoded here. That is an Authentik
|
|
* convention — EntraID serves its keys at
|
|
* `https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys`, so on
|
|
* EntraID the hardcoded path 404s and NO MCP token can ever verify. These
|
|
* tests pin the three behaviours that make the discovery path safe to ship:
|
|
* discovery is honoured, failure degrades to the old path rather than to a
|
|
* broken deployment, and it happens once rather than per request.
|
|
*
|
|
* A real loopback HTTP server is used rather than a `fetch` mock because jose
|
|
* fetches the key set through `node:http` directly, not through global
|
|
* `fetch` — a mocked `fetch` would silently never be consulted for the JWKS
|
|
* request, and the assertion about *which* URL was used would prove nothing.
|
|
*/
|
|
|
|
const TENANT = "11111111-2222-3333-4444-555555555555";
|
|
const AUDIENCE = "99999999-8888-7777-6666-555555555555";
|
|
|
|
/** Paths the fake IdP was asked for, in order. */
|
|
let requested: string[] = [];
|
|
/** Response the fake IdP gives for the discovery document. */
|
|
let discoveryResponse: { status: number; body: string };
|
|
let server: Server;
|
|
let origin: string;
|
|
let privateKey: KeyLike;
|
|
let publicJwk: JWK;
|
|
|
|
/** Authentik-shaped issuer: application-scoped path, trailing slash. */
|
|
function authentikIssuer(): string {
|
|
return `${origin}/application/o/shared-memory-mcp/`;
|
|
}
|
|
|
|
/** EntraID-shaped issuer: tenant-scoped, no trailing slash. */
|
|
function entraIssuer(): string {
|
|
return `${origin}/${TENANT}/v2.0`;
|
|
}
|
|
|
|
/** Where an EntraID discovery document points for keys. */
|
|
function entraKeysPath(): string {
|
|
return `/${TENANT}/discovery/v2.0/keys`;
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
const pair = await generateKeyPair("RS256");
|
|
privateKey = pair.privateKey;
|
|
publicJwk = { ...(await exportJWK(pair.publicKey)), kid: "test-key", alg: "RS256", use: "sig" };
|
|
requested = [];
|
|
|
|
server = createServer((req, res) => {
|
|
requested.push(req.url ?? "");
|
|
if (req.url?.endsWith("/.well-known/openid-configuration")) {
|
|
res.writeHead(discoveryResponse.status, { "content-type": "application/json" });
|
|
res.end(discoveryResponse.body);
|
|
return;
|
|
}
|
|
// Every other path is treated as a key set endpoint. Which path the
|
|
// request actually arrived on is the thing under test.
|
|
res.writeHead(200, { "content-type": "application/json" });
|
|
res.end(JSON.stringify({ keys: [publicJwk] }));
|
|
});
|
|
|
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
// The key set is memoised on globalThis, which survives vi.resetModules().
|
|
// Without clearing it, the second test in this file would silently reuse
|
|
// the first test's resolution and assert nothing.
|
|
delete (globalThis as Record<string, unknown>).__sharedMemoryJwks;
|
|
delete (globalThis as Record<string, unknown>).__sharedMemoryJwksRetryAt;
|
|
delete process.env.OIDC_ISSUER_MCP;
|
|
delete process.env.OIDC_AUDIENCE;
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
});
|
|
|
|
/**
|
|
* Import `authenticateBearer` fresh so it observes the env vars this test set
|
|
* (lib/env.ts caches its parse in a module singleton).
|
|
*/
|
|
async function loadAuthenticateBearer() {
|
|
vi.resetModules();
|
|
const mod = await import("@/lib/auth/jwt");
|
|
return mod.authenticateBearer;
|
|
}
|
|
|
|
async function signAccessToken(issuer: string): Promise<string> {
|
|
return new SignJWT({ groups: ["memory-users"] })
|
|
.setProtectedHeader({ alg: "RS256", kid: "test-key" })
|
|
.setIssuer(issuer)
|
|
.setAudience(AUDIENCE)
|
|
.setSubject("user-object-id")
|
|
.setIssuedAt()
|
|
.setExpirationTime("5m")
|
|
.sign(privateKey);
|
|
}
|
|
|
|
function discoveryPathsSeen(): string[] {
|
|
return requested.filter((p) => p.endsWith("/.well-known/openid-configuration"));
|
|
}
|
|
|
|
describe("MCP JWKS resolution", () => {
|
|
test("uses the jwks_uri from discovery, so EntraID's key endpoint is reached", async () => {
|
|
process.env.OIDC_ISSUER_MCP = entraIssuer();
|
|
process.env.OIDC_AUDIENCE = AUDIENCE;
|
|
discoveryResponse = {
|
|
status: 200,
|
|
// Shape of https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration
|
|
body: JSON.stringify({
|
|
issuer: entraIssuer(),
|
|
jwks_uri: `${origin}${entraKeysPath()}`,
|
|
token_endpoint: `${origin}/${TENANT}/oauth2/v2.0/token`,
|
|
}),
|
|
};
|
|
|
|
const authenticateBearer = await loadAuthenticateBearer();
|
|
const claims = await authenticateBearer(`Bearer ${await signAccessToken(entraIssuer())}`);
|
|
|
|
expect(claims.sub).toBe("user-object-id");
|
|
expect(requested).toContain(entraKeysPath());
|
|
// The Authentik convention must NOT have been tried.
|
|
expect(requested).not.toContain(`/${TENANT}/v2.0/jwks/`);
|
|
});
|
|
|
|
test("tolerates a trailing slash on the issuer while still honouring discovery", async () => {
|
|
// acceptedIssuers() takes both spellings; discovery must not regress that.
|
|
process.env.OIDC_ISSUER_MCP = `${entraIssuer()}/`;
|
|
process.env.OIDC_AUDIENCE = AUDIENCE;
|
|
discoveryResponse = {
|
|
status: 200,
|
|
body: JSON.stringify({ jwks_uri: `${origin}${entraKeysPath()}` }),
|
|
};
|
|
|
|
const authenticateBearer = await loadAuthenticateBearer();
|
|
// Token carries the un-slashed spelling; the env var carries the slashed one.
|
|
const claims = await authenticateBearer(`Bearer ${await signAccessToken(entraIssuer())}`);
|
|
|
|
expect(claims.sub).toBe("user-object-id");
|
|
expect(requested).toContain(entraKeysPath());
|
|
});
|
|
|
|
test("falls back to ${issuer}/jwks/ when discovery is unreachable", async () => {
|
|
process.env.OIDC_ISSUER_MCP = authentikIssuer();
|
|
process.env.OIDC_AUDIENCE = AUDIENCE;
|
|
discoveryResponse = { status: 500, body: "upstream exploded" };
|
|
|
|
const authenticateBearer = await loadAuthenticateBearer();
|
|
const claims = await authenticateBearer(`Bearer ${await signAccessToken(authentikIssuer())}`);
|
|
|
|
// Existing Authentik deployments keep working with no discovery document.
|
|
expect(claims.sub).toBe("user-object-id");
|
|
expect(requested).toContain("/application/o/shared-memory-mcp/jwks/");
|
|
});
|
|
|
|
test("falls back to ${issuer}/jwks/ when discovery omits jwks_uri", async () => {
|
|
process.env.OIDC_ISSUER_MCP = authentikIssuer();
|
|
process.env.OIDC_AUDIENCE = AUDIENCE;
|
|
// 200 OK, valid JSON, no usable key set pointer — the malformed case that
|
|
// a naive `doc.jwks_uri` read would turn into `new URL(undefined)`.
|
|
discoveryResponse = { status: 200, body: JSON.stringify({ issuer: authentikIssuer() }) };
|
|
|
|
const authenticateBearer = await loadAuthenticateBearer();
|
|
const claims = await authenticateBearer(`Bearer ${await signAccessToken(authentikIssuer())}`);
|
|
|
|
expect(claims.sub).toBe("user-object-id");
|
|
expect(requested).toContain("/application/o/shared-memory-mcp/jwks/");
|
|
});
|
|
|
|
test("falls back when discovery returns non-JSON", async () => {
|
|
process.env.OIDC_ISSUER_MCP = authentikIssuer();
|
|
process.env.OIDC_AUDIENCE = AUDIENCE;
|
|
discoveryResponse = { status: 200, body: "<html>login page</html>" };
|
|
|
|
const authenticateBearer = await loadAuthenticateBearer();
|
|
const claims = await authenticateBearer(`Bearer ${await signAccessToken(authentikIssuer())}`);
|
|
|
|
expect(claims.sub).toBe("user-object-id");
|
|
expect(requested).toContain("/application/o/shared-memory-mcp/jwks/");
|
|
});
|
|
|
|
test("discovers once across many verifications, not once per request", async () => {
|
|
// The MCP endpoint verifies a token on essentially every request. A
|
|
// discovery fetch per request would add a round-trip to every tool call.
|
|
process.env.OIDC_ISSUER_MCP = entraIssuer();
|
|
process.env.OIDC_AUDIENCE = AUDIENCE;
|
|
discoveryResponse = {
|
|
status: 200,
|
|
body: JSON.stringify({ jwks_uri: `${origin}${entraKeysPath()}` }),
|
|
};
|
|
|
|
const authenticateBearer = await loadAuthenticateBearer();
|
|
for (let i = 0; i < 3; i++) {
|
|
await authenticateBearer(`Bearer ${await signAccessToken(entraIssuer())}`);
|
|
}
|
|
|
|
expect(discoveryPathsSeen()).toHaveLength(1);
|
|
});
|
|
|
|
test("concurrent cold requests share a single discovery fetch", async () => {
|
|
// Caching the settled value rather than the in-flight promise would let
|
|
// every request that arrives before the first one resolves start its own
|
|
// discovery fetch — a thundering herd at process start.
|
|
process.env.OIDC_ISSUER_MCP = entraIssuer();
|
|
process.env.OIDC_AUDIENCE = AUDIENCE;
|
|
discoveryResponse = {
|
|
status: 200,
|
|
body: JSON.stringify({ jwks_uri: `${origin}${entraKeysPath()}` }),
|
|
};
|
|
|
|
const authenticateBearer = await loadAuthenticateBearer();
|
|
const token = await signAccessToken(entraIssuer());
|
|
await Promise.all(
|
|
Array.from({ length: 5 }, () => authenticateBearer(`Bearer ${token}`)),
|
|
);
|
|
|
|
expect(discoveryPathsSeen()).toHaveLength(1);
|
|
});
|
|
});
|