Skip to content

Commit ef753a9

Browse files
committed
fix(copilot): inject BYOK bearer proxy auth
1 parent ed2eebd commit ef753a9

3 files changed

Lines changed: 144 additions & 4 deletions

File tree

extensions/copilot/src/byok-proxy.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,50 @@ describe("createCopilotByokProxy", () => {
8282
}
8383
});
8484

85+
it("injects resolved bearer auth when the SDK request omits Authorization", async () => {
86+
ssrfRuntimeMock.fetchWithSsrFGuard.mockResolvedValue({
87+
response: new Response("ok", { status: 200 }),
88+
release: vi.fn(async () => undefined),
89+
});
90+
const resolvedProvider = resolveCopilotProvider({
91+
model: {
92+
provider: "tencent-tokenplan",
93+
api: "openai-completions",
94+
id: "hy3",
95+
baseUrl: "https://tokenplan.example/v1",
96+
authHeader: true,
97+
},
98+
resolvedApiKey: "tokenplan-secret",
99+
});
100+
101+
const proxy = await createCopilotByokProxy(resolvedProvider);
102+
103+
try {
104+
const response = await fetch(`${proxy?.provider.provider?.baseUrl}/chat/completions`, {
105+
method: "POST",
106+
headers: {
107+
"content-type": "application/json",
108+
},
109+
body: JSON.stringify({ model: "hy3" }),
110+
});
111+
112+
expect(response.status).toBe(200);
113+
expect(ssrfRuntimeMock.fetchWithSsrFGuard).toHaveBeenCalledWith(
114+
expect.objectContaining({
115+
url: "https://tokenplan.example/v1/chat/completions",
116+
init: expect.objectContaining({
117+
headers: expect.objectContaining({
118+
authorization: "Bearer tokenplan-secret",
119+
"content-type": "application/json",
120+
}),
121+
}),
122+
}),
123+
);
124+
} finally {
125+
await proxy?.close();
126+
}
127+
});
128+
85129
it("aborts in-flight upstream fetches when the proxy closes", async () => {
86130
let upstreamSignal: AbortSignal | undefined;
87131
ssrfRuntimeMock.fetchWithSsrFGuard.mockImplementation(async ({ init }: any) => {
@@ -164,4 +208,38 @@ describe("createCopilotByokProxy", () => {
164208
await proxy?.close();
165209
}
166210
});
211+
212+
it("does not inject bearer auth on nonce-less Azure SDK paths", async () => {
213+
ssrfRuntimeMock.fetchWithSsrFGuard.mockResolvedValue({
214+
response: new Response("azure-ok", { status: 200 }),
215+
release: vi.fn(async () => undefined),
216+
});
217+
const resolvedProvider = resolveCopilotProvider({
218+
model: {
219+
provider: "custom-azure",
220+
api: "azure-openai-responses",
221+
id: "deployment-gpt",
222+
baseUrl: "https://example.openai.azure.com/openai/v1",
223+
authHeader: true,
224+
},
225+
resolvedApiKey: "azure-bearer",
226+
});
227+
228+
const proxy = await createCopilotByokProxy(resolvedProvider);
229+
230+
try {
231+
const response = await fetch(`${proxy?.provider.provider?.baseUrl}/openai/v1/responses`, {
232+
method: "POST",
233+
body: JSON.stringify({ model: "deployment-gpt" }),
234+
});
235+
236+
expect(response.status).toBe(200);
237+
const call = ssrfRuntimeMock.fetchWithSsrFGuard.mock.calls[0]?.[0] as
238+
| { init?: { headers?: Record<string, string> } }
239+
| undefined;
240+
expect(call?.init?.headers).not.toHaveProperty("authorization");
241+
} finally {
242+
await proxy?.close();
243+
}
244+
});
167245
});

extensions/copilot/src/byok-proxy.ts

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export type CopilotByokProxyHandle = {
1515
};
1616

1717
type HeaderValue = string | number | string[] | undefined;
18+
type ProviderConfig = NonNullable<ResolvedCopilotProvider["provider"]>;
1819

1920
export async function createCopilotByokProxy(
2021
resolvedProvider: ResolvedCopilotProvider,
@@ -32,6 +33,7 @@ export async function createCopilotByokProxy(
3233
const targetPathPrefix = trimTrailingSlash(targetBaseUrl.pathname);
3334
const proxyPathPrefix = `/${nonce}${targetPathPrefix}`;
3435
const acceptsAzureSdkPaths = providerConfig.type === "azure";
36+
const upstreamBearerAuthorization = resolveUpstreamBearerAuthorization(providerConfig);
3537
const activeFetches = new Set<AbortController>();
3638
const server = createServer((req, res) => {
3739
void handleProxyRequest(req, res, {
@@ -40,6 +42,7 @@ export async function createCopilotByokProxy(
4042
proxyPathPrefix,
4143
targetBaseUrl,
4244
targetPathPrefix,
45+
upstreamBearerAuthorization,
4346
});
4447
});
4548

@@ -88,6 +91,7 @@ async function handleProxyRequest(
8891
proxyPathPrefix: string;
8992
targetBaseUrl: URL;
9093
targetPathPrefix: string;
94+
upstreamBearerAuthorization: string | undefined;
9195
},
9296
): Promise<void> {
9397
let guarded: Awaited<ReturnType<typeof fetchWithSsrFGuard>> | undefined;
@@ -101,6 +105,7 @@ async function handleProxyRequest(
101105
}
102106
});
103107
try {
108+
const canInjectBearerAuthorization = isNonceProtectedProxyRequest(req, params.proxyPathPrefix);
104109
const url = resolveTargetUrl(req, params);
105110
if (!url) {
106111
res.writeHead(404);
@@ -112,7 +117,11 @@ async function handleProxyRequest(
112117
url: url.toString(),
113118
init: {
114119
method: req.method,
115-
headers: normalizeProxyRequestHeaders(req.headers),
120+
headers: buildProxyRequestHeaders(req.headers, {
121+
upstreamBearerAuthorization: canInjectBearerAuthorization
122+
? params.upstreamBearerAuthorization
123+
: undefined,
124+
}),
116125
signal: upstreamAbort.signal,
117126
...(body ? { body: toFetchBody(body) } : {}),
118127
},
@@ -129,9 +138,9 @@ async function handleProxyRequest(
129138
return;
130139
}
131140
await finished(
132-
Readable.fromWeb(
133-
guarded.response.body as unknown as NodeReadableStream<Uint8Array>,
134-
).pipe(res),
141+
Readable.fromWeb(guarded.response.body as unknown as NodeReadableStream<Uint8Array>).pipe(
142+
res,
143+
),
135144
);
136145
} catch (error) {
137146
if (res.destroyed || res.writableEnded) {
@@ -190,6 +199,14 @@ function isAzureSdkProxyPath(pathname: string): boolean {
190199
return pathname === "/openai" || pathname.startsWith("/openai/");
191200
}
192201

202+
function isNonceProtectedProxyRequest(req: IncomingMessage, proxyPathPrefix: string): boolean {
203+
const incomingUrl = new URL(req.url ?? "/", `http://${LOOPBACK_HOST}`);
204+
return (
205+
incomingUrl.pathname === proxyPathPrefix ||
206+
incomingUrl.pathname.startsWith(`${proxyPathPrefix}/`)
207+
);
208+
}
209+
193210
async function readBody(req: IncomingMessage): Promise<Buffer | undefined> {
194211
const chunks: Buffer[] = [];
195212
for await (const chunk of req) {
@@ -219,6 +236,25 @@ function normalizeProxyRequestHeaders(headers: IncomingMessage["headers"]): Reco
219236
return out;
220237
}
221238

239+
function buildProxyRequestHeaders(
240+
headers: IncomingMessage["headers"],
241+
params: { upstreamBearerAuthorization: string | undefined },
242+
): Record<string, string> {
243+
const out = normalizeProxyRequestHeaders(headers);
244+
if (params.upstreamBearerAuthorization && !hasHeader(out, "authorization")) {
245+
// The SDK declares bearerToken as Authorization auth, but some BYOK
246+
// adapter paths can omit it before reaching our loopback proxy. The proxy
247+
// owns the final guarded hop, so inject only when the SDK left it absent.
248+
out["authorization"] = params.upstreamBearerAuthorization;
249+
}
250+
return out;
251+
}
252+
253+
function resolveUpstreamBearerAuthorization(providerConfig: ProviderConfig): string | undefined {
254+
const bearerToken = providerConfig.bearerToken?.trim();
255+
return bearerToken ? `Bearer ${bearerToken}` : undefined;
256+
}
257+
222258
function normalizeProxyResponseHeaders(headers: Headers): Record<string, string> {
223259
const out: Record<string, string> = {};
224260
headers.forEach((value, key) => {
@@ -236,6 +272,10 @@ function normalizeHeaderValue(value: HeaderValue): string | undefined {
236272
return Array.isArray(value) ? value.join(", ") : String(value);
237273
}
238274

275+
function hasHeader(headers: Record<string, string>, target: string): boolean {
276+
return Object.keys(headers).some((key) => key.toLowerCase() === target);
277+
}
278+
239279
function isHopByHopHeader(key: string): boolean {
240280
switch (key.toLowerCase()) {
241281
case "connection":

extensions/copilot/src/provider-bridge.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,28 @@ describe("resolveCopilotProvider", () => {
7373
expect(supportsCopilotByokProviderShape({ baseUrl: "https://proxy.example/v1" })).toBe(true);
7474
});
7575

76+
it("maps OpenAI Chat Completions BYOK bearer auth with the provider-local model id", () => {
77+
const result = resolveCopilotProvider({
78+
model: {
79+
provider: "tencent-tokenplan",
80+
api: "openai-completions",
81+
id: "hy3",
82+
baseUrl: "https://tokenplan.example/v1",
83+
authHeader: true,
84+
},
85+
resolvedApiKey: "secret-key",
86+
});
87+
88+
expect(result.provider).toMatchObject({
89+
type: "openai",
90+
wireApi: "completions",
91+
baseUrl: "https://tokenplan.example/v1",
92+
modelId: "hy3",
93+
wireModel: "hy3",
94+
bearerToken: "secret-key",
95+
});
96+
});
97+
7698
it("changes the BYOK compatibility fingerprint when token limits change", () => {
7799
const base = {
78100
provider: "custom-proxy",

0 commit comments

Comments
 (0)