mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: aibridge BYOK (#23013)
### Changes **coder/coder:** - `coderd/aibridge/aibridge.go` — Added `HeaderCoderBYOKToken` constant, `IsBYOK()` helper, and updated `ExtractAuthToken` to check the BYOK header first. - `enterprise/aibridged/http.go` — BYOK-aware header stripping: in BYOK mode only the BYOK header is stripped (user's LLM credentials preserved); in centralized mode all auth headers are stripped. <hr/> **NOTE**: `X-Coder-Token` was removed! As of now `ExtractAuthToken` retrieves token either from `X-Coder-AI-Governance-BYOK-Token` or from `Authorization`/`X-Api-Key`. --------- Co-authored-by: Susana Ferreira <susana@coder.com> Co-authored-by: Danny Kopping <danny@coder.com>
This commit is contained in:
co-authored by
Susana Ferreira
Danny Kopping
parent
8576d1a9e9
commit
a86b8ab6f8
+20
-10
@@ -6,18 +6,28 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HeaderCoderAuth is an internal header used to pass the Coder token
|
||||
// from AI Proxy to AI Bridge for authentication. This header is stripped
|
||||
// by AI Bridge before forwarding requests to upstream providers.
|
||||
const HeaderCoderAuth = "X-Coder-Token"
|
||||
// HeaderCoderToken is a header set by clients opting into BYOK
|
||||
// (Bring Your Own Key) mode. It carries the Coder token so
|
||||
// that Authorization and X-Api-Key can carry the user's own LLM
|
||||
// credentials. When present, AI Bridge forwards the user's LLM
|
||||
// headers unchanged instead of injecting the centralized key.
|
||||
//
|
||||
// The AI Bridge proxy also sets this header automatically for clients
|
||||
// that use per-user LLM credentials but cannot set custom headers.
|
||||
const HeaderCoderToken = "X-Coder-AI-Governance-Token" //nolint:gosec // This is a header name, not a credential.
|
||||
|
||||
// ExtractAuthToken extracts an authorization token from HTTP headers.
|
||||
// It checks X-Coder-Token first (set by AI Proxy), then falls back
|
||||
// to Authorization header (Bearer token) and X-Api-Key header, which represent
|
||||
// the different ways clients authenticate against AI providers.
|
||||
// If none are present, an empty string is returned.
|
||||
// IsBYOK reports whether the request is using BYOK mode, determined
|
||||
// by the presence of the X-Coder-AI-Governance-Token header.
|
||||
func IsBYOK(header http.Header) bool {
|
||||
return strings.TrimSpace(header.Get(HeaderCoderToken)) != ""
|
||||
}
|
||||
|
||||
// ExtractAuthToken extracts a token from HTTP headers.
|
||||
// It checks the BYOK header first (set by clients opting into BYOK),
|
||||
// then falls back to Authorization: Bearer and X-Api-Key for direct
|
||||
// centralized mode. If none are present, an empty string is returned.
|
||||
func ExtractAuthToken(header http.Header) string {
|
||||
if token := strings.TrimSpace(header.Get(HeaderCoderAuth)); token != "" {
|
||||
if token := strings.TrimSpace(header.Get(HeaderCoderToken)); token != "" {
|
||||
return token
|
||||
}
|
||||
if auth := strings.TrimSpace(header.Get("Authorization")); auth != "" {
|
||||
|
||||
@@ -174,44 +174,107 @@ func TestServeHTTP_FailureModes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_CoderTokenRemoved(t *testing.T) {
|
||||
func TestServeHTTP_StripCoderToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mockHandler := &mockHandler{}
|
||||
cases := []struct {
|
||||
name string
|
||||
reqHeaders map[string]string
|
||||
expectPresent map[string]string // header → expected value
|
||||
expectAbsent []string // headers that must be gone
|
||||
}{
|
||||
{
|
||||
// Centralized: the client sets Authorization and X-Api-Key,
|
||||
// but does not include HeaderCoderToken.
|
||||
// All auth headers are stripped.
|
||||
name: "centralized",
|
||||
reqHeaders: map[string]string{
|
||||
"Authorization": "Bearer coder-token",
|
||||
"X-Api-Key": "sk-ant-api03-user-key",
|
||||
},
|
||||
expectAbsent: []string{
|
||||
"Authorization",
|
||||
"X-Api-Key",
|
||||
agplaibridge.HeaderCoderToken,
|
||||
},
|
||||
},
|
||||
{
|
||||
// BYOK with access token: Coder token in BYOK header,
|
||||
// user's access token in Authorization. Only the
|
||||
// BYOK header is stripped.
|
||||
name: "byok bearer token",
|
||||
reqHeaders: map[string]string{
|
||||
agplaibridge.HeaderCoderToken: "coder-token",
|
||||
"Authorization": "Bearer sk-ant-oat01-user-oauth-token",
|
||||
},
|
||||
expectPresent: map[string]string{
|
||||
"Authorization": "Bearer sk-ant-oat01-user-oauth-token",
|
||||
},
|
||||
expectAbsent: []string{
|
||||
agplaibridge.HeaderCoderToken,
|
||||
},
|
||||
},
|
||||
{
|
||||
// BYOK with personal API key: Coder token in BYOK header,
|
||||
// user's API key in X-Api-Key. Only the BYOK header is
|
||||
// stripped.
|
||||
name: "byok api key",
|
||||
reqHeaders: map[string]string{
|
||||
agplaibridge.HeaderCoderToken: "coder-token",
|
||||
"X-Api-Key": "sk-ant-api03-user-key",
|
||||
},
|
||||
expectPresent: map[string]string{
|
||||
"X-Api-Key": "sk-ant-api03-user-key",
|
||||
},
|
||||
expectAbsent: []string{
|
||||
agplaibridge.HeaderCoderToken,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
srv, client, pool := newTestServer(t)
|
||||
conn := &mockDRPCConn{}
|
||||
client.EXPECT().DRPCConn().AnyTimes().Return(conn)
|
||||
client.EXPECT().IsAuthorized(gomock.Any(), gomock.Any()).AnyTimes().Return(&proto.IsAuthorizedResponse{OwnerId: uuid.NewString()}, nil)
|
||||
pool.EXPECT().Acquire(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().Return(mockHandler, nil)
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
httpSrv := httptest.NewServer(srv)
|
||||
t.Cleanup(httpSrv.Close)
|
||||
mockH := &mockHandler{}
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, httpSrv.URL+"/openai/v1/chat/completions", nil)
|
||||
require.NoError(t, err)
|
||||
srv, client, pool := newTestServer(t)
|
||||
conn := &mockDRPCConn{}
|
||||
client.EXPECT().DRPCConn().AnyTimes().Return(conn)
|
||||
client.EXPECT().IsAuthorized(gomock.Any(), gomock.Any()).AnyTimes().Return(&proto.IsAuthorizedResponse{OwnerId: uuid.NewString()}, nil)
|
||||
pool.EXPECT().Acquire(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().Return(mockH, nil)
|
||||
|
||||
// X-Coder-Token is used for authentication and should be stripped.
|
||||
// Other authorization headers should be preserved.
|
||||
req.Header.Set(agplaibridge.HeaderCoderAuth, "coder-token")
|
||||
req.Header.Set("Authorization", "Bearer some-token")
|
||||
req.Header.Set("X-Api-Key", "some-api-key")
|
||||
httpSrv := httptest.NewServer(srv)
|
||||
t.Cleanup(httpSrv.Close)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, httpSrv.URL+"/openai/v1/chat/completions", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
for k, v := range tc.reqHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
// Verify X-Coder-Token was removed before forwarding to handler.
|
||||
require.NotNil(t, mockHandler.headersReceived)
|
||||
require.Empty(t, mockHandler.headersReceived.Get(agplaibridge.HeaderCoderAuth),
|
||||
"X-Coder-Token should be removed before forwarding to handler")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Verify other headers were preserved.
|
||||
require.Equal(t, "Bearer some-token", mockHandler.headersReceived.Get("Authorization"))
|
||||
require.Equal(t, "some-api-key", mockHandler.headersReceived.Get("X-Api-Key"))
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.NotNil(t, mockH.headersReceived)
|
||||
|
||||
for header, expected := range tc.expectPresent {
|
||||
require.Equal(t, expected, mockH.headersReceived.Get(header),
|
||||
"header %q should be preserved with value %q", header, expected)
|
||||
}
|
||||
for _, header := range tc.expectAbsent {
|
||||
require.Empty(t, mockH.headersReceived.Get(header),
|
||||
"header %q should be stripped", header)
|
||||
}
|
||||
// HeaderCoderToken should always be stripped
|
||||
require.Empty(t, mockH.headersReceived.Get(agplaibridge.HeaderCoderToken),
|
||||
"header %q should be stripped", agplaibridge.HeaderCoderToken)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractAuthToken(t *testing.T) {
|
||||
@@ -225,31 +288,6 @@ func TestExtractAuthToken(t *testing.T) {
|
||||
{
|
||||
name: "none",
|
||||
},
|
||||
{
|
||||
name: "x-coder-token/empty",
|
||||
headers: map[string]string{agplaibridge.HeaderCoderAuth: ""},
|
||||
},
|
||||
{
|
||||
name: "x-coder-token/ok",
|
||||
headers: map[string]string{agplaibridge.HeaderCoderAuth: "coder-token"},
|
||||
expectedKey: "coder-token",
|
||||
},
|
||||
{
|
||||
name: "x-coder-token/priority over authorization",
|
||||
headers: map[string]string{
|
||||
agplaibridge.HeaderCoderAuth: "coder-token",
|
||||
"Authorization": "Bearer other-token",
|
||||
},
|
||||
expectedKey: "coder-token",
|
||||
},
|
||||
{
|
||||
name: "x-coder-token/priority over x-api-key",
|
||||
headers: map[string]string{
|
||||
agplaibridge.HeaderCoderAuth: "coder-token",
|
||||
"X-Api-Key": "api-key",
|
||||
},
|
||||
expectedKey: "coder-token",
|
||||
},
|
||||
{
|
||||
name: "authorization/invalid",
|
||||
headers: map[string]string{"authorization": "invalid"},
|
||||
@@ -285,6 +323,27 @@ func TestExtractAuthToken(t *testing.T) {
|
||||
headers: map[string]string{"X-Api-Key": "key"},
|
||||
expectedKey: "key",
|
||||
},
|
||||
|
||||
// BYOK: X-Coder-AI-Governance-Token carries the Coder
|
||||
// token and has the highest priority.
|
||||
{
|
||||
name: "byok/empty",
|
||||
headers: map[string]string{agplaibridge.HeaderCoderToken: ""},
|
||||
},
|
||||
{
|
||||
name: "byok/ok",
|
||||
headers: map[string]string{agplaibridge.HeaderCoderToken: "coder-token"},
|
||||
expectedKey: "coder-token",
|
||||
},
|
||||
{
|
||||
name: "byok/priority over all",
|
||||
headers: map[string]string{
|
||||
agplaibridge.HeaderCoderToken: "coder-token",
|
||||
"Authorization": "Bearer oauth-token",
|
||||
"X-Api-Key": "api-key",
|
||||
},
|
||||
expectedKey: "coder-token",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
|
||||
@@ -37,6 +37,12 @@ func (s *Server) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
logger := s.logger.With(slog.F("path", r.URL.Path))
|
||||
|
||||
byok := agplaibridge.IsBYOK(r.Header)
|
||||
authMode := "centralized"
|
||||
if byok {
|
||||
authMode = "byok"
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(agplaibridge.ExtractAuthToken(r.Header))
|
||||
if key == "" {
|
||||
logger.Warn(ctx, "no auth key provided")
|
||||
@@ -44,8 +50,23 @@ func (s *Server) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Remove the Coder token header so it's not forwarded to upstream providers.
|
||||
r.Header.Del(agplaibridge.HeaderCoderAuth)
|
||||
// Strip every header that may carry the Coder token so it is
|
||||
// never forwarded to upstream providers. After stripping, the
|
||||
// aibridge library can treat the request as a normal LLM API call
|
||||
// with no Coder-specific information.
|
||||
if byok {
|
||||
// In BYOK mode the token is in X-Coder-AI-Governance-Token;
|
||||
// Authorization and X-Api-Key carry the user's own LLM credentials
|
||||
// and must be preserved.
|
||||
r.Header.Del(agplaibridge.HeaderCoderToken)
|
||||
} else {
|
||||
// In centralized mode the token may be in Authorization (the
|
||||
// documented path) or X-Api-Key (legacy clients that set
|
||||
// ANTHROPIC_API_KEY to their Coder token). Both are
|
||||
// stripped.
|
||||
r.Header.Del("Authorization")
|
||||
r.Header.Del("X-Api-Key")
|
||||
}
|
||||
|
||||
client, err := s.Client()
|
||||
if err != nil {
|
||||
@@ -56,7 +77,7 @@ func (s *Server) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
resp, err := client.IsAuthorized(ctx, &proto.IsAuthorizedRequest{Key: key})
|
||||
if err != nil {
|
||||
logger.Warn(ctx, "key authorization check failed", slog.Error(err))
|
||||
logger.Warn(ctx, "key authorization check failed", slog.Error(err), slog.F("auth_mode", authMode))
|
||||
http.Error(rw, ErrUnauthorized.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -714,6 +714,17 @@ func extractCoderTokenFromProxyAuth(proxyAuth string) string {
|
||||
return credentials[1]
|
||||
}
|
||||
|
||||
// extractCoderTokenFromBearerAuth extracts the bearer token from an
|
||||
// Authorization header. Returns empty string if the header is not a
|
||||
// valid "Bearer <token>" value.
|
||||
func extractCoderTokenFromBearerAuth(auth string) string {
|
||||
parts := strings.Fields(auth)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||
return ""
|
||||
}
|
||||
return parts[1]
|
||||
}
|
||||
|
||||
// newProxyAuthRequiredResponse creates a 407 Proxy Authentication Required
|
||||
// response with the appropriate challenge header. This is used both during
|
||||
// CONNECT handling and for decrypted requests missing authentication.
|
||||
@@ -866,8 +877,10 @@ func (s *Server) checkBlockedIPAndDial(ctx context.Context, network, addr string
|
||||
}
|
||||
|
||||
// handleRequest intercepts HTTP requests after MITM decryption.
|
||||
// - Requests to known AI providers are rewritten to aibridged, with the Coder token
|
||||
// (from ctx.UserData, set during CONNECT) set in the X-Coder-Token header.
|
||||
// - Requests to known AI providers are rewritten to point at aibridged.
|
||||
// In centralized mode the Coder token is already in the
|
||||
// Authorization header. For BYOK clients that cannot set custom
|
||||
// headers, the proxy injects the BYOK header.
|
||||
// - Unknown hosts are passed through to the original upstream.
|
||||
func (s *Server) handleRequest(req *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) {
|
||||
originalPath := req.URL.Path
|
||||
@@ -945,10 +958,7 @@ func (s *Server) handleRequest(req *http.Request, ctx *goproxy.ProxyCtx) (*http.
|
||||
req.URL = aiBridgeParsedURL
|
||||
req.Host = aiBridgeParsedURL.Host
|
||||
|
||||
// Set X-Coder-Token header for aibridged authentication.
|
||||
// Using a separate header preserves the original request headers,
|
||||
// which are forwarded to upstream providers.
|
||||
req.Header.Set(agplaibridge.HeaderCoderAuth, reqCtx.CoderToken)
|
||||
injectBYOKHeaderIfNeeded(req.Header, reqCtx.CoderToken)
|
||||
|
||||
// Set custom header for cross-service log correlation.
|
||||
// This allows correlating aibridgeproxyd logs with aibridged logs.
|
||||
@@ -967,6 +977,27 @@ func (s *Server) handleRequest(req *http.Request, ctx *goproxy.ProxyCtx) (*http.
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// injectBYOKHeaderIfNeeded sets HeaderCoderToken when the
|
||||
// Authorization header carries a bearer token that differs from the
|
||||
// Coder token, indicating the client is using its own LLM
|
||||
// credentials. Clients that can set custom headers
|
||||
// do this themselves; this handles clients that cannot.
|
||||
//
|
||||
// In centralized mode, Authorization carries the Coder token
|
||||
// itself, so aibridged discovers it via ExtractAuthToken
|
||||
// without any extra header.
|
||||
func injectBYOKHeaderIfNeeded(header http.Header, coderToken string) {
|
||||
// Don’t overwrite the header if it’s already set.
|
||||
if header.Get(agplaibridge.HeaderCoderToken) != "" {
|
||||
return
|
||||
}
|
||||
|
||||
bearer := extractCoderTokenFromBearerAuth(header.Get("Authorization"))
|
||||
if bearer != "" && bearer != coderToken {
|
||||
header.Set(agplaibridge.HeaderCoderToken, coderToken)
|
||||
}
|
||||
}
|
||||
|
||||
// handleResponse handles responses received from aibridged.
|
||||
// This is only called for MITM'd requests (allowlisted domains routed through aibridged).
|
||||
// Tunneled requests (non-allowlisted domains) bypass this handler entirely.
|
||||
|
||||
@@ -1370,12 +1370,13 @@ func TestProxy_MITM(t *testing.T) {
|
||||
metrics := aibridgeproxyd.NewMetrics(reg)
|
||||
|
||||
// Track what aibridged receives.
|
||||
var receivedPath, receivedCoderToken, receivedRequestID string
|
||||
var receivedPath, receivedAuthz, receivedBYOK, receivedRequestID string
|
||||
|
||||
// Create a mock aibridged server that captures requests.
|
||||
aibridgedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedPath = r.URL.Path
|
||||
receivedCoderToken = r.Header.Get(agplaibridge.HeaderCoderAuth)
|
||||
receivedAuthz = r.Header.Get("Authorization")
|
||||
receivedBYOK = r.Header.Get(agplaibridge.HeaderCoderToken)
|
||||
receivedRequestID = r.Header.Get(aibridgeproxyd.HeaderAIBridgeRequestID)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("hello from aibridged"))
|
||||
@@ -1427,11 +1428,14 @@ func TestProxy_MITM(t *testing.T) {
|
||||
certPool = getProxyCertPool(t)
|
||||
}
|
||||
|
||||
// Make a request through the proxy to the target URL.
|
||||
client := newProxyClient(t, srv, makeProxyAuthHeader("test-token"), certPool, false)
|
||||
// Simulate the primary proxy use case: the Coder
|
||||
// token is in Proxy-Authorization, and the user's
|
||||
// own LLM token is in Authorization.
|
||||
client := newProxyClient(t, srv, makeProxyAuthHeader("coder-token"), certPool, false)
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, targetURL, strings.NewReader(`{}`))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer user-llm-token")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
@@ -1449,7 +1453,7 @@ func TestProxy_MITM(t *testing.T) {
|
||||
// Verify request went to target server, not aibridged.
|
||||
require.Equal(t, "hello from tunneled", string(body))
|
||||
require.Empty(t, receivedPath, "aibridged should not receive tunneled requests")
|
||||
require.Empty(t, receivedCoderToken, "tunneled requests are not authenticated by the proxy")
|
||||
require.Empty(t, receivedAuthz, "tunneled requests should not reach aibridged")
|
||||
require.Empty(t, receivedRequestID, "tunneled requests should not have request ID header")
|
||||
|
||||
// Verify metrics for tunneled requests.
|
||||
@@ -1464,7 +1468,8 @@ func TestProxy_MITM(t *testing.T) {
|
||||
// Verify the request was routed to aibridged correctly.
|
||||
require.Equal(t, "hello from aibridged", string(body))
|
||||
require.Equal(t, tt.expectedPath, receivedPath)
|
||||
require.Equal(t, "test-token", receivedCoderToken, "MITM'd requests must include Coder token")
|
||||
require.Equal(t, "Bearer user-llm-token", receivedAuthz, "user's LLM credentials must be forwarded")
|
||||
require.Equal(t, "coder-token", receivedBYOK, "proxy must inject BYOK header with Coder token")
|
||||
require.NotEmpty(t, receivedRequestID, "MITM'd requests must include request ID header")
|
||||
_, err := uuid.Parse(receivedRequestID)
|
||||
require.NoError(t, err, "request ID must be a valid UUID")
|
||||
@@ -1482,6 +1487,95 @@ func TestProxy_MITM(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxy_MITM_BYOKInjection verifies that the proxy sets the BYOK header
|
||||
// when Authorization carries a bearer token different from the Coder
|
||||
// token. This handles clients that send per-user LLM credentials
|
||||
// but cannot set custom headers.
|
||||
func TestProxy_MITM_BYOKInjection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
coderToken := "coder-token"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
authzHeader string
|
||||
byokHeader string // pre-set by client; empty means not set
|
||||
expectBYOK bool
|
||||
expectBYOKVal string
|
||||
}{
|
||||
{
|
||||
// Centralized: Authorization carries the Coder token (same
|
||||
// value as Proxy-Authorization). No BYOK header is set.
|
||||
name: "Authorization matches Coder token",
|
||||
authzHeader: "Bearer " + coderToken,
|
||||
expectBYOK: false,
|
||||
},
|
||||
{
|
||||
// BYOK: Authorization carries the user's token,
|
||||
// which differs from the Coder token. The proxy injects
|
||||
// the BYOK header.
|
||||
name: "Authorization differs from Coder token",
|
||||
authzHeader: "Bearer client-access-token",
|
||||
expectBYOK: true,
|
||||
expectBYOKVal: coderToken,
|
||||
},
|
||||
{
|
||||
// Client already set the BYOK header (Claude Code, Codex).
|
||||
// The proxy must not overwrite it.
|
||||
name: "BYOK header already set by client — not overwritten",
|
||||
authzHeader: "Bearer client-access-token",
|
||||
byokHeader: "client-set-coder-token",
|
||||
expectBYOK: true,
|
||||
expectBYOKVal: "client-set-coder-token",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var receivedBYOKHeader, receivedAuthz string
|
||||
|
||||
aibridgedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedAuthz = r.Header.Get("Authorization")
|
||||
receivedBYOKHeader = r.Header.Get(agplaibridge.HeaderCoderToken)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(aibridgedServer.Close)
|
||||
|
||||
srv := newTestProxy(t,
|
||||
withCoderAccessURL(aibridgedServer.URL),
|
||||
withDomainAllowlist(aibridgeproxyd.HostCopilot),
|
||||
withAIBridgeProviderFromHost(nil),
|
||||
)
|
||||
|
||||
certPool := getProxyCertPool(t)
|
||||
client := newProxyClient(t, srv, makeProxyAuthHeader(coderToken), certPool, false)
|
||||
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://"+aibridgeproxyd.HostCopilot+"/chat/completions", strings.NewReader(`{}`))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", tt.authzHeader)
|
||||
if tt.byokHeader != "" {
|
||||
req.Header.Set(agplaibridge.HeaderCoderToken, tt.byokHeader)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, tt.authzHeader, receivedAuthz, "Authorization must be forwarded to aibridged")
|
||||
|
||||
if tt.expectBYOK {
|
||||
require.Equal(t, tt.expectBYOKVal, receivedBYOKHeader, "BYOK header must be set when Authorization differs from Coder token")
|
||||
} else {
|
||||
require.Empty(t, receivedBYOKHeader, "BYOK header must not be set")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestListenerTLS verifies that the proxy works correctly when its listener is wrapped in TLS.
|
||||
// It tests both tunneled and MITM'd requests through an HTTPS proxy listener.
|
||||
func TestListenerTLS(t *testing.T) {
|
||||
@@ -1785,7 +1879,8 @@ func TestUpstreamProxy(t *testing.T) {
|
||||
finalDestinationBody string
|
||||
aibridgeReceived bool
|
||||
aibridgePath string
|
||||
aibridgeCoderToken string
|
||||
aibridgeAuthz string
|
||||
aibridgeBYOK string
|
||||
aibridgeBody string
|
||||
)
|
||||
|
||||
@@ -1882,7 +1977,8 @@ func TestUpstreamProxy(t *testing.T) {
|
||||
aibridgeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
aibridgeReceived = true
|
||||
aibridgePath = r.URL.Path
|
||||
aibridgeCoderToken = r.Header.Get(agplaibridge.HeaderCoderAuth)
|
||||
aibridgeAuthz = r.Header.Get("Authorization")
|
||||
aibridgeBYOK = r.Header.Get(agplaibridge.HeaderCoderToken)
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
@@ -1937,7 +2033,8 @@ func TestUpstreamProxy(t *testing.T) {
|
||||
certPool = getProxyCertPool(t)
|
||||
}
|
||||
|
||||
// Create HTTP client configured to use aiproxy.
|
||||
// Create HTTP client configured to use aiproxy. Coder token
|
||||
// in Proxy-Authorization, user's LLM token in Authorization.
|
||||
client := newProxyClient(t, srv, makeProxyAuthHeader("test-coder-token"), certPool, false)
|
||||
|
||||
// Make request through aiproxy.
|
||||
@@ -1945,6 +2042,7 @@ func TestUpstreamProxy(t *testing.T) {
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, targetURL, strings.NewReader(requestBody))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer user-llm-token")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
@@ -1966,8 +2064,8 @@ func TestUpstreamProxy(t *testing.T) {
|
||||
"final destination should receive the exact request body")
|
||||
require.False(t, aibridgeReceived,
|
||||
"aibridge should NOT receive request for non-allowlisted domain")
|
||||
require.Empty(t, aibridgeCoderToken,
|
||||
"tunneled requests should not have Coder token")
|
||||
require.Empty(t, aibridgeAuthz,
|
||||
"tunneled requests should not reach aibridge")
|
||||
} else {
|
||||
require.False(t, upstreamProxyCONNECTReceived,
|
||||
"upstream proxy should NOT receive CONNECT for allowlisted domain")
|
||||
@@ -1975,8 +2073,10 @@ func TestUpstreamProxy(t *testing.T) {
|
||||
"aibridge should receive the MITM'd request")
|
||||
require.Equal(t, tt.expectedAIBridgePath, aibridgePath,
|
||||
"aibridge should receive rewritten path")
|
||||
require.Equal(t, "test-coder-token", aibridgeCoderToken,
|
||||
"aibridge should receive Coder token header")
|
||||
require.Equal(t, "Bearer user-llm-token", aibridgeAuthz,
|
||||
"user's LLM credentials must be forwarded")
|
||||
require.Equal(t, "test-coder-token", aibridgeBYOK,
|
||||
"proxy must inject BYOK header with Coder token")
|
||||
require.Equal(t, requestBody, aibridgeBody,
|
||||
"aibridge should receive the exact request body")
|
||||
require.False(t, finalDestinationReceived,
|
||||
|
||||
@@ -483,7 +483,7 @@ require (
|
||||
github.com/anthropics/anthropic-sdk-go v1.19.0
|
||||
github.com/brianvoe/gofakeit/v7 v7.14.0
|
||||
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225
|
||||
github.com/coder/aibridge v1.0.8-0.20260316151612-5c071a7db41b
|
||||
github.com/coder/aibridge v1.0.8-0.20260324203533-dd8c239e5566
|
||||
github.com/coder/aisdk-go v0.0.9
|
||||
github.com/coder/boundary v0.8.4-0.20260304164748-566aeea939ab
|
||||
github.com/coder/preview v1.0.8
|
||||
|
||||
@@ -314,8 +314,8 @@ github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
|
||||
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 h1:tRIViZ5JRmzdOEo5wUWngaGEFBG8OaE1o2GIHN5ujJ8=
|
||||
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225/go.mod h1:rNLVpYgEVeu1Zk29K64z6Od8RBP9DwqCu9OfCzh8MR4=
|
||||
github.com/coder/aibridge v1.0.8-0.20260316151612-5c071a7db41b h1:O470JUI+D8cuCSsPVSI6JMUq1JlKDdsPtk7feDCaLqQ=
|
||||
github.com/coder/aibridge v1.0.8-0.20260316151612-5c071a7db41b/go.mod h1:u6WvGLMQQbk3ByeOw+LBdVgDNc/v/ujAtUc6MfvzQb4=
|
||||
github.com/coder/aibridge v1.0.8-0.20260324203533-dd8c239e5566 h1:DK+a7Q9bPpTyq7ePaz81Ihauyp1ilXNhF8MI+7rmZpA=
|
||||
github.com/coder/aibridge v1.0.8-0.20260324203533-dd8c239e5566/go.mod h1:u6WvGLMQQbk3ByeOw+LBdVgDNc/v/ujAtUc6MfvzQb4=
|
||||
github.com/coder/aisdk-go v0.0.9 h1:Vzo/k2qwVGLTR10ESDeP2Ecek1SdPfZlEjtTfMveiVo=
|
||||
github.com/coder/aisdk-go v0.0.9/go.mod h1:KF6/Vkono0FJJOtWtveh5j7yfNrSctVTpwgweYWSp5M=
|
||||
github.com/coder/boundary v0.8.4-0.20260304164748-566aeea939ab h1:HrlxyTmMQpOHfSKzRU1vf5TxrmV6vL5OiWq+Dvn5qh0=
|
||||
|
||||
Reference in New Issue
Block a user