@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 and
7
- * advertises Auth0 as the authorization server via RFC 9728 metadata.
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
- /** RFC 9728 protected resource metadata document. */
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 and
7
- * advertises Auth0 as the authorization server via RFC 9728 metadata.
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
- /** Canonical resource identifier (RFC 8707) = the Auth0 API audience. */
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
- /** RFC 9728 protected resource metadata document. */
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: TRUSTED_ISSUERS.map((iss) => iss.replace(/\/$/, "")),
110
+ authorization_servers: [publicOrigin(req)],
61
111
  bearer_methods_supported: ["header"],
62
- scopes_supported: ["openid", "profile", "email"],
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 so OAuth clients discover Auth0.
1743
- // Only advertised when OAuth is enforced. Some clients append the resource
1744
- // path to the well-known URL, so match both.
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 and
7
- * advertises Auth0 as the authorization server via RFC 9728 metadata.
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
- /** Canonical resource identifier (RFC 8707) = the Auth0 API audience. */
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
- /** RFC 9728 protected resource metadata document. */
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: TRUSTED_ISSUERS.map((iss) => iss.replace(/\/$/, "")),
134
+ authorization_servers: [publicOrigin(req)],
82
135
  bearer_methods_supported: ["header"],
83
- scopes_supported: ["openid", "profile", "email"],
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,
@@ -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 so OAuth clients discover Auth0.
2121
- // Only advertised when OAuth is enforced. Some clients append the resource
2122
- // path to the well-known URL, so match both.
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}` }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testdriverai/agent",
3
- "version": "7.11.164-test",
3
+ "version": "7.11.165-test",
4
4
  "description": "Next generation autonomous AI agent for end-to-end testing of web & desktop",
5
5
  "main": "sdk.js",
6
6
  "types": "sdk.d.ts",