@testdriverai/agent 7.11.164-test → 7.11.165-test
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -3,8 +3,25 @@
|
|
|
3
3
|
*
|
|
4
4
|
* OAuth 2.0 Resource Server helpers for the standalone TestDriver MCP server's
|
|
5
5
|
* Streamable HTTP transport. Identity is delegated to Auth0 — this server only
|
|
6
|
-
* *validates* the Bearer JWTs Auth0 issues for the TestDriver API audience
|
|
7
|
-
*
|
|
6
|
+
* *validates* the Bearer JWTs Auth0 issues for the TestDriver API audience.
|
|
7
|
+
*
|
|
8
|
+
* Auth0 cannot be advertised as the authorization server directly. MCP clients
|
|
9
|
+
* discover it, register through Dynamic Client Registration — which at Auth0
|
|
10
|
+
* always yields a *third-party* client — and then call `/authorize` with the
|
|
11
|
+
* RFC 8707 `resource` parameter and no `audience`. Auth0 ignores `resource`,
|
|
12
|
+
* falls back to its default `/userinfo` audience, and rejects the request:
|
|
13
|
+
* "The userinfo audience is not allowed for third party clients." Even if it
|
|
14
|
+
* succeeded, a `/userinfo`-audience token is opaque and would fail the JWT
|
|
15
|
+
* verification below, which requires `aud === MCP_AUDIENCE`.
|
|
16
|
+
*
|
|
17
|
+
* So this server advertises *itself* as the authorization server (RFC 8414) and
|
|
18
|
+
* proxies exactly one endpoint: `/authorize` redirects to Auth0 with
|
|
19
|
+
* `audience=<MCP_AUDIENCE>` injected. Token exchange, client registration and
|
|
20
|
+
* JWKS point straight at Auth0, so no credentials pass through us. The metadata
|
|
21
|
+
* `issuer` is therefore this server while issued tokens carry Auth0's `iss`;
|
|
22
|
+
* that is inherent to the proxy pattern and fine because access tokens are
|
|
23
|
+
* opaque to OAuth clients — only we, the resource server, read `iss`, and we
|
|
24
|
+
* check it against TRUSTED_ISSUERS.
|
|
8
25
|
*
|
|
9
26
|
* When OAuth is disabled (no `TD_MCP_AUTH=oauth`), everything here is a no-op so
|
|
10
27
|
* local stdio / trusted-network usage is unchanged.
|
|
@@ -13,8 +30,33 @@ import type { IncomingMessage } from "http";
|
|
|
13
30
|
import { type JWTPayload } from "jose";
|
|
14
31
|
/** Whether OAuth enforcement is enabled for the HTTP transport. */
|
|
15
32
|
export declare function isOAuthEnabled(): boolean;
|
|
16
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* RFC 9728 protected resource metadata document.
|
|
35
|
+
*
|
|
36
|
+
* `authorization_servers` is *this* server, not Auth0: clients must go through
|
|
37
|
+
* our `/authorize` so the Auth0 API audience gets injected (see the file
|
|
38
|
+
* header). They then discover the rest from our RFC 8414 document below.
|
|
39
|
+
*/
|
|
17
40
|
export declare function protectedResourceMetadata(req: IncomingMessage, mcpPath: string): Record<string, unknown>;
|
|
41
|
+
/**
|
|
42
|
+
* RFC 8414 authorization server metadata. Only `authorization_endpoint` is
|
|
43
|
+
* ours; token exchange, Dynamic Client Registration and JWKS go straight to
|
|
44
|
+
* Auth0, so no client credentials or codes pass through this server.
|
|
45
|
+
*/
|
|
46
|
+
export declare function authorizationServerMetadata(req: IncomingMessage): Record<string, unknown>;
|
|
47
|
+
/**
|
|
48
|
+
* Translate an inbound request to our `/authorize` into the upstream Auth0
|
|
49
|
+
* authorization URL. Every client parameter is forwarded verbatim — including
|
|
50
|
+
* `state`, `code_challenge` and `redirect_uri`, which stay end-to-end between
|
|
51
|
+
* the client and Auth0 — with two changes:
|
|
52
|
+
*
|
|
53
|
+
* - `audience` is set to MCP_AUDIENCE. This is the whole point of the hop:
|
|
54
|
+
* without it Auth0 defaults to the `/userinfo` audience and refuses the
|
|
55
|
+
* third-party (DCR-registered) client outright.
|
|
56
|
+
* - `resource` is dropped. Auth0 keys tokens on `audience`, and forwarding a
|
|
57
|
+
* resource indicator alongside it only risks the two disagreeing.
|
|
58
|
+
*/
|
|
59
|
+
export declare function authorizeRedirectUrl(requestUrl: URL): string;
|
|
18
60
|
/** Build the `WWW-Authenticate` challenge header value. */
|
|
19
61
|
export declare function wwwAuthenticate(req: IncomingMessage, opts?: {
|
|
20
62
|
error?: string;
|
|
@@ -3,8 +3,25 @@
|
|
|
3
3
|
*
|
|
4
4
|
* OAuth 2.0 Resource Server helpers for the standalone TestDriver MCP server's
|
|
5
5
|
* Streamable HTTP transport. Identity is delegated to Auth0 — this server only
|
|
6
|
-
* *validates* the Bearer JWTs Auth0 issues for the TestDriver API audience
|
|
7
|
-
*
|
|
6
|
+
* *validates* the Bearer JWTs Auth0 issues for the TestDriver API audience.
|
|
7
|
+
*
|
|
8
|
+
* Auth0 cannot be advertised as the authorization server directly. MCP clients
|
|
9
|
+
* discover it, register through Dynamic Client Registration — which at Auth0
|
|
10
|
+
* always yields a *third-party* client — and then call `/authorize` with the
|
|
11
|
+
* RFC 8707 `resource` parameter and no `audience`. Auth0 ignores `resource`,
|
|
12
|
+
* falls back to its default `/userinfo` audience, and rejects the request:
|
|
13
|
+
* "The userinfo audience is not allowed for third party clients." Even if it
|
|
14
|
+
* succeeded, a `/userinfo`-audience token is opaque and would fail the JWT
|
|
15
|
+
* verification below, which requires `aud === MCP_AUDIENCE`.
|
|
16
|
+
*
|
|
17
|
+
* So this server advertises *itself* as the authorization server (RFC 8414) and
|
|
18
|
+
* proxies exactly one endpoint: `/authorize` redirects to Auth0 with
|
|
19
|
+
* `audience=<MCP_AUDIENCE>` injected. Token exchange, client registration and
|
|
20
|
+
* JWKS point straight at Auth0, so no credentials pass through us. The metadata
|
|
21
|
+
* `issuer` is therefore this server while issued tokens carry Auth0's `iss`;
|
|
22
|
+
* that is inherent to the proxy pattern and fine because access tokens are
|
|
23
|
+
* opaque to OAuth clients — only we, the resource server, read `iss`, and we
|
|
24
|
+
* check it against TRUSTED_ISSUERS.
|
|
8
25
|
*
|
|
9
26
|
* When OAuth is disabled (no `TD_MCP_AUTH=oauth`), everything here is a no-op so
|
|
10
27
|
* local stdio / trusted-network usage is unchanged.
|
|
@@ -14,7 +31,12 @@ import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
|
14
31
|
export function isOAuthEnabled() {
|
|
15
32
|
return (process.env.TD_MCP_AUTH || "").toLowerCase() === "oauth";
|
|
16
33
|
}
|
|
17
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* The Auth0 API audience every access token must be minted for. This is what we
|
|
36
|
+
* inject into the proxied `/authorize` and what `authenticate()` asserts as
|
|
37
|
+
* `aud`. Note this is NOT the RFC 8707 resource identifier (that is the MCP
|
|
38
|
+
* endpoint URL, see `resourceUrl()`); Auth0 keys tokens on `audience` alone.
|
|
39
|
+
*/
|
|
18
40
|
const MCP_AUDIENCE = process.env.TD_AUTH0_AUDIENCE || "https://api.testdriver.ai";
|
|
19
41
|
/** Auth0 issuer(s) trusted to mint tokens. Comma-separated env override. */
|
|
20
42
|
const TRUSTED_ISSUERS = (process.env.TD_MCP_TRUSTED_ISSUERS ||
|
|
@@ -26,6 +48,16 @@ const TRUSTED_ISSUERS = (process.env.TD_MCP_TRUSTED_ISSUERS ||
|
|
|
26
48
|
.split(",")
|
|
27
49
|
.map((s) => s.trim())
|
|
28
50
|
.filter(Boolean);
|
|
51
|
+
/**
|
|
52
|
+
* The Auth0 tenant we actually send users to. TRUSTED_ISSUERS may list several
|
|
53
|
+
* tenants we *accept* tokens from (staging alongside production); the first is
|
|
54
|
+
* the one we hand off to. Kept without a trailing slash for URL building —
|
|
55
|
+
* TRUSTED_ISSUERS entries keep theirs, because `iss` matching is exact.
|
|
56
|
+
*/
|
|
57
|
+
const AUTH0_ORIGIN = (TRUSTED_ISSUERS[0] || "https://replayable.us.auth0.com/")
|
|
58
|
+
.replace(/\/$/, "");
|
|
59
|
+
/** Scopes advertised in both metadata documents. */
|
|
60
|
+
const SCOPES_SUPPORTED = ["openid", "profile", "email", "offline_access"];
|
|
29
61
|
// One remote JWKS per issuer; jose caches keys and handles rotation.
|
|
30
62
|
const jwksByIssuer = new Map();
|
|
31
63
|
function jwksForIssuer(issuer) {
|
|
@@ -45,6 +77,18 @@ function requestScheme(req) {
|
|
|
45
77
|
const socket = req.socket;
|
|
46
78
|
return socket && socket.encrypted ? "https" : "http";
|
|
47
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* The public origin clients reach us on — the authorization server identifier
|
|
82
|
+
* we advertise, so it must match the host the metadata was fetched from.
|
|
83
|
+
* `TD_MCP_PUBLIC_URL` overrides it when the proxy hop rewrites the host.
|
|
84
|
+
*/
|
|
85
|
+
function publicOrigin(req) {
|
|
86
|
+
if (process.env.TD_MCP_PUBLIC_URL) {
|
|
87
|
+
return process.env.TD_MCP_PUBLIC_URL.replace(/\/$/, "");
|
|
88
|
+
}
|
|
89
|
+
const host = req.headers["x-forwarded-host"] || req.headers.host;
|
|
90
|
+
return `${requestScheme(req)}://${host}`;
|
|
91
|
+
}
|
|
48
92
|
/** The public URL clients use to reach this server's MCP endpoint. */
|
|
49
93
|
function resourceUrl(req, mcpPath) {
|
|
50
94
|
if (process.env.TD_MCP_RESOURCE) {
|
|
@@ -53,16 +97,72 @@ function resourceUrl(req, mcpPath) {
|
|
|
53
97
|
const host = req.headers["x-forwarded-host"] || req.headers.host;
|
|
54
98
|
return `${requestScheme(req)}://${host}${mcpPath}`;
|
|
55
99
|
}
|
|
56
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* RFC 9728 protected resource metadata document.
|
|
102
|
+
*
|
|
103
|
+
* `authorization_servers` is *this* server, not Auth0: clients must go through
|
|
104
|
+
* our `/authorize` so the Auth0 API audience gets injected (see the file
|
|
105
|
+
* header). They then discover the rest from our RFC 8414 document below.
|
|
106
|
+
*/
|
|
57
107
|
export function protectedResourceMetadata(req, mcpPath) {
|
|
58
108
|
return {
|
|
59
109
|
resource: resourceUrl(req, mcpPath),
|
|
60
|
-
authorization_servers:
|
|
110
|
+
authorization_servers: [publicOrigin(req)],
|
|
61
111
|
bearer_methods_supported: ["header"],
|
|
62
|
-
scopes_supported:
|
|
112
|
+
scopes_supported: SCOPES_SUPPORTED,
|
|
63
113
|
resource_documentation: "https://docs.testdriver.ai",
|
|
64
114
|
};
|
|
65
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* RFC 8414 authorization server metadata. Only `authorization_endpoint` is
|
|
118
|
+
* ours; token exchange, Dynamic Client Registration and JWKS go straight to
|
|
119
|
+
* Auth0, so no client credentials or codes pass through this server.
|
|
120
|
+
*/
|
|
121
|
+
export function authorizationServerMetadata(req) {
|
|
122
|
+
const base = publicOrigin(req);
|
|
123
|
+
return {
|
|
124
|
+
issuer: base,
|
|
125
|
+
authorization_endpoint: `${base}/authorize`,
|
|
126
|
+
token_endpoint: `${AUTH0_ORIGIN}/oauth/token`,
|
|
127
|
+
registration_endpoint: `${AUTH0_ORIGIN}/oidc/register`,
|
|
128
|
+
revocation_endpoint: `${AUTH0_ORIGIN}/oauth/revoke`,
|
|
129
|
+
jwks_uri: `${AUTH0_ORIGIN}/.well-known/jwks.json`,
|
|
130
|
+
response_types_supported: ["code"],
|
|
131
|
+
response_modes_supported: ["query"],
|
|
132
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
133
|
+
// OAuth 2.1 / MCP clients are public clients using PKCE.
|
|
134
|
+
code_challenge_methods_supported: ["S256"],
|
|
135
|
+
token_endpoint_auth_methods_supported: [
|
|
136
|
+
"none",
|
|
137
|
+
"client_secret_post",
|
|
138
|
+
"client_secret_basic",
|
|
139
|
+
],
|
|
140
|
+
scopes_supported: SCOPES_SUPPORTED,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Translate an inbound request to our `/authorize` into the upstream Auth0
|
|
145
|
+
* authorization URL. Every client parameter is forwarded verbatim — including
|
|
146
|
+
* `state`, `code_challenge` and `redirect_uri`, which stay end-to-end between
|
|
147
|
+
* the client and Auth0 — with two changes:
|
|
148
|
+
*
|
|
149
|
+
* - `audience` is set to MCP_AUDIENCE. This is the whole point of the hop:
|
|
150
|
+
* without it Auth0 defaults to the `/userinfo` audience and refuses the
|
|
151
|
+
* third-party (DCR-registered) client outright.
|
|
152
|
+
* - `resource` is dropped. Auth0 keys tokens on `audience`, and forwarding a
|
|
153
|
+
* resource indicator alongside it only risks the two disagreeing.
|
|
154
|
+
*/
|
|
155
|
+
export function authorizeRedirectUrl(requestUrl) {
|
|
156
|
+
const target = new URL(`${AUTH0_ORIGIN}/authorize`);
|
|
157
|
+
for (const [key, value] of requestUrl.searchParams) {
|
|
158
|
+
if (key === "audience" || key === "resource") {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
target.searchParams.append(key, value);
|
|
162
|
+
}
|
|
163
|
+
target.searchParams.set("audience", MCP_AUDIENCE);
|
|
164
|
+
return target.toString();
|
|
165
|
+
}
|
|
66
166
|
/** Build the `WWW-Authenticate` challenge header value. */
|
|
67
167
|
export function wwwAuthenticate(req, opts) {
|
|
68
168
|
const host = req.headers["x-forwarded-host"] || req.headers.host;
|
|
@@ -23,7 +23,7 @@ import { z } from "zod";
|
|
|
23
23
|
import * as core from "./core/actions.js";
|
|
24
24
|
import { NoActiveSessionError } from "./core/actions.js";
|
|
25
25
|
import { resolveE2bTemplateId, resolveOs } from "./env-utils.js";
|
|
26
|
-
import { authenticate, isOAuthEnabled, protectedResourceMetadata, wwwAuthenticate } from "./http-auth.js";
|
|
26
|
+
import { authenticate, authorizationServerMetadata, authorizeRedirectUrl, isOAuthEnabled, protectedResourceMetadata, wwwAuthenticate } from "./http-auth.js";
|
|
27
27
|
import { SessionStartInputSchema } from "./provision-types.js";
|
|
28
28
|
// =============================================================================
|
|
29
29
|
// Sentry
|
|
@@ -1739,9 +1739,11 @@ async function startHttpServer() {
|
|
|
1739
1739
|
res.end(JSON.stringify({ status: "ok", server: "testdriver", version, sessions: connections.size }));
|
|
1740
1740
|
return;
|
|
1741
1741
|
}
|
|
1742
|
-
// RFC 9728 protected-resource metadata
|
|
1743
|
-
//
|
|
1744
|
-
//
|
|
1742
|
+
// RFC 9728 protected-resource metadata, pointing OAuth clients at this
|
|
1743
|
+
// server as the authorization server (see http-auth.ts for why we sit in
|
|
1744
|
+
// front of Auth0 rather than advertising it directly). Only advertised
|
|
1745
|
+
// when OAuth is enforced. Some clients append the resource path to the
|
|
1746
|
+
// well-known URL, so match both.
|
|
1745
1747
|
if (isOAuthEnabled() &&
|
|
1746
1748
|
req.method === "GET" &&
|
|
1747
1749
|
(url.pathname === "/.well-known/oauth-protected-resource" ||
|
|
@@ -1750,6 +1752,27 @@ async function startHttpServer() {
|
|
|
1750
1752
|
res.end(JSON.stringify(protectedResourceMetadata(req, mcpPath)));
|
|
1751
1753
|
return;
|
|
1752
1754
|
}
|
|
1755
|
+
// RFC 8414 authorization-server metadata. Clients look for it at the
|
|
1756
|
+
// OAuth path and, per OpenID Discovery, sometimes the OIDC one; both
|
|
1757
|
+
// describe the same hop, so serve both.
|
|
1758
|
+
if (isOAuthEnabled() &&
|
|
1759
|
+
req.method === "GET" &&
|
|
1760
|
+
(url.pathname === "/.well-known/oauth-authorization-server" ||
|
|
1761
|
+
url.pathname === `/.well-known/oauth-authorization-server${mcpPath}` ||
|
|
1762
|
+
url.pathname === "/.well-known/openid-configuration")) {
|
|
1763
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1764
|
+
res.end(JSON.stringify(authorizationServerMetadata(req)));
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
// The one proxied endpoint: redirect to Auth0 with the API audience
|
|
1768
|
+
// injected. `state`, PKCE and `redirect_uri` pass through untouched, so
|
|
1769
|
+
// the callback goes straight from Auth0 back to the client and the code
|
|
1770
|
+
// is exchanged at Auth0's token endpoint without touching this server.
|
|
1771
|
+
if (isOAuthEnabled() && req.method === "GET" && url.pathname === "/authorize") {
|
|
1772
|
+
res.writeHead(302, { location: authorizeRedirectUrl(url), "cache-control": "no-store" });
|
|
1773
|
+
res.end();
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1753
1776
|
if (url.pathname !== mcpPath) {
|
|
1754
1777
|
res.writeHead(404, { "content-type": "application/json" });
|
|
1755
1778
|
res.end(JSON.stringify({ error: "Not found", hint: `MCP endpoint is ${mcpPath}` }));
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for http-auth.ts
|
|
3
|
+
*
|
|
4
|
+
* Focus: the OAuth hop this server puts in front of Auth0. Auth0 rejects a
|
|
5
|
+
* DCR-registered (third-party) client that reaches `/authorize` without an
|
|
6
|
+
* `audience` — "The userinfo audience is not allowed for third party clients"
|
|
7
|
+
* — and ignores the RFC 8707 `resource` parameter MCP clients send instead.
|
|
8
|
+
* These tests pin the three things that together avoid that:
|
|
9
|
+
*
|
|
10
|
+
* 1. we advertise ourselves, not Auth0, as the authorization server,
|
|
11
|
+
* 2. our RFC 8414 document routes only `/authorize` through us,
|
|
12
|
+
* 3. that redirect injects the API audience and preserves state/PKCE.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { IncomingMessage } from "http";
|
|
16
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
17
|
+
import {
|
|
18
|
+
authorizationServerMetadata,
|
|
19
|
+
authorizeRedirectUrl,
|
|
20
|
+
protectedResourceMetadata,
|
|
21
|
+
} from "./http-auth.js";
|
|
22
|
+
|
|
23
|
+
const AUTH0 = "https://replayable.us.auth0.com";
|
|
24
|
+
const AUDIENCE = "https://api.testdriver.ai";
|
|
25
|
+
|
|
26
|
+
/** Minimal IncomingMessage stand-in: only headers and socket are read. */
|
|
27
|
+
function fakeReq(
|
|
28
|
+
headers: Record<string, string> = {},
|
|
29
|
+
encrypted = false,
|
|
30
|
+
): IncomingMessage {
|
|
31
|
+
return {
|
|
32
|
+
headers: { host: "mcp.example.com", ...headers },
|
|
33
|
+
socket: { encrypted },
|
|
34
|
+
} as unknown as IncomingMessage;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The authorize request Claude sends today, which Auth0 refused. */
|
|
38
|
+
function clientAuthorizeUrl(
|
|
39
|
+
extra: Record<string, string> = {},
|
|
40
|
+
): URL {
|
|
41
|
+
const url = new URL("https://mcp.example.com/authorize");
|
|
42
|
+
const params: Record<string, string> = {
|
|
43
|
+
response_type: "code",
|
|
44
|
+
client_id: "dcr-generated-client",
|
|
45
|
+
redirect_uri: "https://claude.ai/api/mcp/auth_callback",
|
|
46
|
+
scope: "openid profile email offline_access",
|
|
47
|
+
state: "opaque-state",
|
|
48
|
+
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
|
49
|
+
code_challenge_method: "S256",
|
|
50
|
+
resource: "https://mcp.example.com/mcp",
|
|
51
|
+
...extra,
|
|
52
|
+
};
|
|
53
|
+
for (const [k, v] of Object.entries(params)) {
|
|
54
|
+
url.searchParams.set(k, v);
|
|
55
|
+
}
|
|
56
|
+
return url;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
delete process.env.TD_MCP_PUBLIC_URL;
|
|
61
|
+
delete process.env.TD_MCP_RESOURCE;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// protectedResourceMetadata (RFC 9728)
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
describe("protectedResourceMetadata", () => {
|
|
69
|
+
it("advertises this server as the authorization server, not Auth0", () => {
|
|
70
|
+
const doc = protectedResourceMetadata(fakeReq(), "/mcp");
|
|
71
|
+
// Pointing straight at Auth0 is what produced the userinfo-audience error.
|
|
72
|
+
expect(doc.authorization_servers).toEqual(["http://mcp.example.com"]);
|
|
73
|
+
expect(JSON.stringify(doc.authorization_servers)).not.toContain("auth0");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("keeps the MCP endpoint as the RFC 8707 resource identifier", () => {
|
|
77
|
+
const doc = protectedResourceMetadata(fakeReq(), "/mcp");
|
|
78
|
+
expect(doc.resource).toBe("http://mcp.example.com/mcp");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("honours a TLS-terminating proxy's forwarded scheme and host", () => {
|
|
82
|
+
const doc = protectedResourceMetadata(
|
|
83
|
+
fakeReq({
|
|
84
|
+
"x-forwarded-proto": "https",
|
|
85
|
+
"x-forwarded-host": "td-test-mcp-server.fly.dev",
|
|
86
|
+
}),
|
|
87
|
+
"/mcp",
|
|
88
|
+
);
|
|
89
|
+
expect(doc.authorization_servers).toEqual([
|
|
90
|
+
"https://td-test-mcp-server.fly.dev",
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// authorizationServerMetadata (RFC 8414)
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
describe("authorizationServerMetadata", () => {
|
|
100
|
+
it("names itself as issuer so discovery validation matches the fetch URL", () => {
|
|
101
|
+
const doc = authorizationServerMetadata(
|
|
102
|
+
fakeReq({ "x-forwarded-proto": "https" }),
|
|
103
|
+
);
|
|
104
|
+
expect(doc.issuer).toBe("https://mcp.example.com");
|
|
105
|
+
expect(doc.authorization_endpoint).toBe("https://mcp.example.com/authorize");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("sends token exchange, registration and JWKS straight to Auth0", () => {
|
|
109
|
+
const doc = authorizationServerMetadata(fakeReq());
|
|
110
|
+
// No credentials or codes should route through this server.
|
|
111
|
+
expect(doc.token_endpoint).toBe(`${AUTH0}/oauth/token`);
|
|
112
|
+
expect(doc.registration_endpoint).toBe(`${AUTH0}/oidc/register`);
|
|
113
|
+
expect(doc.jwks_uri).toBe(`${AUTH0}/.well-known/jwks.json`);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("advertises PKCE, as OAuth 2.1 public clients require", () => {
|
|
117
|
+
const doc = authorizationServerMetadata(fakeReq());
|
|
118
|
+
expect(doc.code_challenge_methods_supported).toEqual(["S256"]);
|
|
119
|
+
expect(doc.token_endpoint_auth_methods_supported).toContain("none");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("lets TD_MCP_PUBLIC_URL override a host the proxy hop rewrote", () => {
|
|
123
|
+
process.env.TD_MCP_PUBLIC_URL = "https://mcp.testdriver.ai/";
|
|
124
|
+
const doc = authorizationServerMetadata(fakeReq());
|
|
125
|
+
expect(doc.issuer).toBe("https://mcp.testdriver.ai");
|
|
126
|
+
expect(doc.authorization_endpoint).toBe(
|
|
127
|
+
"https://mcp.testdriver.ai/authorize",
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// authorizeRedirectUrl — the fix itself
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
describe("authorizeRedirectUrl", () => {
|
|
137
|
+
it("injects the API audience Auth0 needs from third-party clients", () => {
|
|
138
|
+
const target = new URL(authorizeRedirectUrl(clientAuthorizeUrl()));
|
|
139
|
+
expect(target.origin + target.pathname).toBe(`${AUTH0}/authorize`);
|
|
140
|
+
expect(target.searchParams.get("audience")).toBe(AUDIENCE);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("passes state, PKCE and redirect_uri through untouched", () => {
|
|
144
|
+
const source = clientAuthorizeUrl();
|
|
145
|
+
const target = new URL(authorizeRedirectUrl(source));
|
|
146
|
+
for (const key of [
|
|
147
|
+
"response_type",
|
|
148
|
+
"client_id",
|
|
149
|
+
"redirect_uri",
|
|
150
|
+
"scope",
|
|
151
|
+
"state",
|
|
152
|
+
"code_challenge",
|
|
153
|
+
"code_challenge_method",
|
|
154
|
+
]) {
|
|
155
|
+
expect(target.searchParams.get(key)).toBe(source.searchParams.get(key));
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("drops the resource indicator Auth0 ignores", () => {
|
|
160
|
+
const target = new URL(authorizeRedirectUrl(clientAuthorizeUrl()));
|
|
161
|
+
expect(target.searchParams.has("resource")).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("replaces a client-supplied audience rather than duplicating it", () => {
|
|
165
|
+
const target = new URL(
|
|
166
|
+
authorizeRedirectUrl(
|
|
167
|
+
clientAuthorizeUrl({ audience: "https://wrong.example.com" }),
|
|
168
|
+
),
|
|
169
|
+
);
|
|
170
|
+
expect(target.searchParams.getAll("audience")).toEqual([AUDIENCE]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("forwards repeated parameters without collapsing them", () => {
|
|
174
|
+
const source = clientAuthorizeUrl();
|
|
175
|
+
source.searchParams.append("prompt", "consent");
|
|
176
|
+
source.searchParams.append("prompt", "login");
|
|
177
|
+
const target = new URL(authorizeRedirectUrl(source));
|
|
178
|
+
expect(target.searchParams.getAll("prompt")).toEqual(["consent", "login"]);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -3,8 +3,25 @@
|
|
|
3
3
|
*
|
|
4
4
|
* OAuth 2.0 Resource Server helpers for the standalone TestDriver MCP server's
|
|
5
5
|
* Streamable HTTP transport. Identity is delegated to Auth0 — this server only
|
|
6
|
-
* *validates* the Bearer JWTs Auth0 issues for the TestDriver API audience
|
|
7
|
-
*
|
|
6
|
+
* *validates* the Bearer JWTs Auth0 issues for the TestDriver API audience.
|
|
7
|
+
*
|
|
8
|
+
* Auth0 cannot be advertised as the authorization server directly. MCP clients
|
|
9
|
+
* discover it, register through Dynamic Client Registration — which at Auth0
|
|
10
|
+
* always yields a *third-party* client — and then call `/authorize` with the
|
|
11
|
+
* RFC 8707 `resource` parameter and no `audience`. Auth0 ignores `resource`,
|
|
12
|
+
* falls back to its default `/userinfo` audience, and rejects the request:
|
|
13
|
+
* "The userinfo audience is not allowed for third party clients." Even if it
|
|
14
|
+
* succeeded, a `/userinfo`-audience token is opaque and would fail the JWT
|
|
15
|
+
* verification below, which requires `aud === MCP_AUDIENCE`.
|
|
16
|
+
*
|
|
17
|
+
* So this server advertises *itself* as the authorization server (RFC 8414) and
|
|
18
|
+
* proxies exactly one endpoint: `/authorize` redirects to Auth0 with
|
|
19
|
+
* `audience=<MCP_AUDIENCE>` injected. Token exchange, client registration and
|
|
20
|
+
* JWKS point straight at Auth0, so no credentials pass through us. The metadata
|
|
21
|
+
* `issuer` is therefore this server while issued tokens carry Auth0's `iss`;
|
|
22
|
+
* that is inherent to the proxy pattern and fine because access tokens are
|
|
23
|
+
* opaque to OAuth clients — only we, the resource server, read `iss`, and we
|
|
24
|
+
* check it against TRUSTED_ISSUERS.
|
|
8
25
|
*
|
|
9
26
|
* When OAuth is disabled (no `TD_MCP_AUTH=oauth`), everything here is a no-op so
|
|
10
27
|
* local stdio / trusted-network usage is unchanged.
|
|
@@ -18,7 +35,12 @@ export function isOAuthEnabled(): boolean {
|
|
|
18
35
|
return (process.env.TD_MCP_AUTH || "").toLowerCase() === "oauth";
|
|
19
36
|
}
|
|
20
37
|
|
|
21
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* The Auth0 API audience every access token must be minted for. This is what we
|
|
40
|
+
* inject into the proxied `/authorize` and what `authenticate()` asserts as
|
|
41
|
+
* `aud`. Note this is NOT the RFC 8707 resource identifier (that is the MCP
|
|
42
|
+
* endpoint URL, see `resourceUrl()`); Auth0 keys tokens on `audience` alone.
|
|
43
|
+
*/
|
|
22
44
|
const MCP_AUDIENCE =
|
|
23
45
|
process.env.TD_AUTH0_AUDIENCE || "https://api.testdriver.ai";
|
|
24
46
|
|
|
@@ -35,6 +57,18 @@ const TRUSTED_ISSUERS = (
|
|
|
35
57
|
.map((s) => s.trim())
|
|
36
58
|
.filter(Boolean);
|
|
37
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The Auth0 tenant we actually send users to. TRUSTED_ISSUERS may list several
|
|
62
|
+
* tenants we *accept* tokens from (staging alongside production); the first is
|
|
63
|
+
* the one we hand off to. Kept without a trailing slash for URL building —
|
|
64
|
+
* TRUSTED_ISSUERS entries keep theirs, because `iss` matching is exact.
|
|
65
|
+
*/
|
|
66
|
+
const AUTH0_ORIGIN = (TRUSTED_ISSUERS[0] || "https://replayable.us.auth0.com/")
|
|
67
|
+
.replace(/\/$/, "");
|
|
68
|
+
|
|
69
|
+
/** Scopes advertised in both metadata documents. */
|
|
70
|
+
const SCOPES_SUPPORTED = ["openid", "profile", "email", "offline_access"];
|
|
71
|
+
|
|
38
72
|
// One remote JWKS per issuer; jose caches keys and handles rotation.
|
|
39
73
|
const jwksByIssuer = new Map<
|
|
40
74
|
string,
|
|
@@ -62,6 +96,19 @@ function requestScheme(req: IncomingMessage): string {
|
|
|
62
96
|
return socket && socket.encrypted ? "https" : "http";
|
|
63
97
|
}
|
|
64
98
|
|
|
99
|
+
/**
|
|
100
|
+
* The public origin clients reach us on — the authorization server identifier
|
|
101
|
+
* we advertise, so it must match the host the metadata was fetched from.
|
|
102
|
+
* `TD_MCP_PUBLIC_URL` overrides it when the proxy hop rewrites the host.
|
|
103
|
+
*/
|
|
104
|
+
function publicOrigin(req: IncomingMessage): string {
|
|
105
|
+
if (process.env.TD_MCP_PUBLIC_URL) {
|
|
106
|
+
return process.env.TD_MCP_PUBLIC_URL.replace(/\/$/, "");
|
|
107
|
+
}
|
|
108
|
+
const host = req.headers["x-forwarded-host"] || req.headers.host;
|
|
109
|
+
return `${requestScheme(req)}://${host}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
65
112
|
/** The public URL clients use to reach this server's MCP endpoint. */
|
|
66
113
|
function resourceUrl(req: IncomingMessage, mcpPath: string): string {
|
|
67
114
|
if (process.env.TD_MCP_RESOURCE) {
|
|
@@ -71,20 +118,80 @@ function resourceUrl(req: IncomingMessage, mcpPath: string): string {
|
|
|
71
118
|
return `${requestScheme(req)}://${host}${mcpPath}`;
|
|
72
119
|
}
|
|
73
120
|
|
|
74
|
-
/**
|
|
121
|
+
/**
|
|
122
|
+
* RFC 9728 protected resource metadata document.
|
|
123
|
+
*
|
|
124
|
+
* `authorization_servers` is *this* server, not Auth0: clients must go through
|
|
125
|
+
* our `/authorize` so the Auth0 API audience gets injected (see the file
|
|
126
|
+
* header). They then discover the rest from our RFC 8414 document below.
|
|
127
|
+
*/
|
|
75
128
|
export function protectedResourceMetadata(
|
|
76
129
|
req: IncomingMessage,
|
|
77
130
|
mcpPath: string,
|
|
78
131
|
): Record<string, unknown> {
|
|
79
132
|
return {
|
|
80
133
|
resource: resourceUrl(req, mcpPath),
|
|
81
|
-
authorization_servers:
|
|
134
|
+
authorization_servers: [publicOrigin(req)],
|
|
82
135
|
bearer_methods_supported: ["header"],
|
|
83
|
-
scopes_supported:
|
|
136
|
+
scopes_supported: SCOPES_SUPPORTED,
|
|
84
137
|
resource_documentation: "https://docs.testdriver.ai",
|
|
85
138
|
};
|
|
86
139
|
}
|
|
87
140
|
|
|
141
|
+
/**
|
|
142
|
+
* RFC 8414 authorization server metadata. Only `authorization_endpoint` is
|
|
143
|
+
* ours; token exchange, Dynamic Client Registration and JWKS go straight to
|
|
144
|
+
* Auth0, so no client credentials or codes pass through this server.
|
|
145
|
+
*/
|
|
146
|
+
export function authorizationServerMetadata(
|
|
147
|
+
req: IncomingMessage,
|
|
148
|
+
): Record<string, unknown> {
|
|
149
|
+
const base = publicOrigin(req);
|
|
150
|
+
return {
|
|
151
|
+
issuer: base,
|
|
152
|
+
authorization_endpoint: `${base}/authorize`,
|
|
153
|
+
token_endpoint: `${AUTH0_ORIGIN}/oauth/token`,
|
|
154
|
+
registration_endpoint: `${AUTH0_ORIGIN}/oidc/register`,
|
|
155
|
+
revocation_endpoint: `${AUTH0_ORIGIN}/oauth/revoke`,
|
|
156
|
+
jwks_uri: `${AUTH0_ORIGIN}/.well-known/jwks.json`,
|
|
157
|
+
response_types_supported: ["code"],
|
|
158
|
+
response_modes_supported: ["query"],
|
|
159
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
160
|
+
// OAuth 2.1 / MCP clients are public clients using PKCE.
|
|
161
|
+
code_challenge_methods_supported: ["S256"],
|
|
162
|
+
token_endpoint_auth_methods_supported: [
|
|
163
|
+
"none",
|
|
164
|
+
"client_secret_post",
|
|
165
|
+
"client_secret_basic",
|
|
166
|
+
],
|
|
167
|
+
scopes_supported: SCOPES_SUPPORTED,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Translate an inbound request to our `/authorize` into the upstream Auth0
|
|
173
|
+
* authorization URL. Every client parameter is forwarded verbatim — including
|
|
174
|
+
* `state`, `code_challenge` and `redirect_uri`, which stay end-to-end between
|
|
175
|
+
* the client and Auth0 — with two changes:
|
|
176
|
+
*
|
|
177
|
+
* - `audience` is set to MCP_AUDIENCE. This is the whole point of the hop:
|
|
178
|
+
* without it Auth0 defaults to the `/userinfo` audience and refuses the
|
|
179
|
+
* third-party (DCR-registered) client outright.
|
|
180
|
+
* - `resource` is dropped. Auth0 keys tokens on `audience`, and forwarding a
|
|
181
|
+
* resource indicator alongside it only risks the two disagreeing.
|
|
182
|
+
*/
|
|
183
|
+
export function authorizeRedirectUrl(requestUrl: URL): string {
|
|
184
|
+
const target = new URL(`${AUTH0_ORIGIN}/authorize`);
|
|
185
|
+
for (const [key, value] of requestUrl.searchParams) {
|
|
186
|
+
if (key === "audience" || key === "resource") {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
target.searchParams.append(key, value);
|
|
190
|
+
}
|
|
191
|
+
target.searchParams.set("audience", MCP_AUDIENCE);
|
|
192
|
+
return target.toString();
|
|
193
|
+
}
|
|
194
|
+
|
|
88
195
|
/** Build the `WWW-Authenticate` challenge header value. */
|
|
89
196
|
export function wwwAuthenticate(
|
|
90
197
|
req: IncomingMessage,
|
package/mcp-server/src/server.ts
CHANGED
|
@@ -29,7 +29,7 @@ import { z } from "zod";
|
|
|
29
29
|
import * as core from "./core/actions.js";
|
|
30
30
|
import { NoActiveSessionError, type ActionResult } from "./core/actions.js";
|
|
31
31
|
import { resolveE2bTemplateId, resolveOs } from "./env-utils.js";
|
|
32
|
-
import { authenticate, isOAuthEnabled, protectedResourceMetadata, wwwAuthenticate } from "./http-auth.js";
|
|
32
|
+
import { authenticate, authorizationServerMetadata, authorizeRedirectUrl, isOAuthEnabled, protectedResourceMetadata, wwwAuthenticate } from "./http-auth.js";
|
|
33
33
|
import { SessionStartInputSchema, type SessionStartInput } from "./provision-types.js";
|
|
34
34
|
import { type SessionState } from "./session.js";
|
|
35
35
|
|
|
@@ -2117,9 +2117,11 @@ async function startHttpServer() {
|
|
|
2117
2117
|
return;
|
|
2118
2118
|
}
|
|
2119
2119
|
|
|
2120
|
-
// RFC 9728 protected-resource metadata
|
|
2121
|
-
//
|
|
2122
|
-
//
|
|
2120
|
+
// RFC 9728 protected-resource metadata, pointing OAuth clients at this
|
|
2121
|
+
// server as the authorization server (see http-auth.ts for why we sit in
|
|
2122
|
+
// front of Auth0 rather than advertising it directly). Only advertised
|
|
2123
|
+
// when OAuth is enforced. Some clients append the resource path to the
|
|
2124
|
+
// well-known URL, so match both.
|
|
2123
2125
|
if (
|
|
2124
2126
|
isOAuthEnabled() &&
|
|
2125
2127
|
req.method === "GET" &&
|
|
@@ -2131,6 +2133,31 @@ async function startHttpServer() {
|
|
|
2131
2133
|
return;
|
|
2132
2134
|
}
|
|
2133
2135
|
|
|
2136
|
+
// RFC 8414 authorization-server metadata. Clients look for it at the
|
|
2137
|
+
// OAuth path and, per OpenID Discovery, sometimes the OIDC one; both
|
|
2138
|
+
// describe the same hop, so serve both.
|
|
2139
|
+
if (
|
|
2140
|
+
isOAuthEnabled() &&
|
|
2141
|
+
req.method === "GET" &&
|
|
2142
|
+
(url.pathname === "/.well-known/oauth-authorization-server" ||
|
|
2143
|
+
url.pathname === `/.well-known/oauth-authorization-server${mcpPath}` ||
|
|
2144
|
+
url.pathname === "/.well-known/openid-configuration")
|
|
2145
|
+
) {
|
|
2146
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
2147
|
+
res.end(JSON.stringify(authorizationServerMetadata(req)));
|
|
2148
|
+
return;
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
// The one proxied endpoint: redirect to Auth0 with the API audience
|
|
2152
|
+
// injected. `state`, PKCE and `redirect_uri` pass through untouched, so
|
|
2153
|
+
// the callback goes straight from Auth0 back to the client and the code
|
|
2154
|
+
// is exchanged at Auth0's token endpoint without touching this server.
|
|
2155
|
+
if (isOAuthEnabled() && req.method === "GET" && url.pathname === "/authorize") {
|
|
2156
|
+
res.writeHead(302, { location: authorizeRedirectUrl(url), "cache-control": "no-store" });
|
|
2157
|
+
res.end();
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2134
2161
|
if (url.pathname !== mcpPath) {
|
|
2135
2162
|
res.writeHead(404, { "content-type": "application/json" });
|
|
2136
2163
|
res.end(JSON.stringify({ error: "Not found", hint: `MCP endpoint is ${mcpPath}` }));
|