fix(auth): add multi-layer auth injection for desktop app compatibility
Desktop app sets OPENCODE_SERVER_PASSWORD which activates basicAuth on the server, but the SDK client provided to plugins lacks auth headers. The previous setConfig-only approach may silently fail depending on SDK version. Add belt-and-suspenders fallback chain: 1. setConfig headers (existing) 2. request interceptors 3. fetch wrapper via getConfig/setConfig 4. mutable _config.fetch wrapper 5. top-level client.fetch wrapper Replace console.warn with structured log() for better diagnostics.
This commit is contained in:
@@ -53,24 +53,194 @@ describe("opencode-server-auth", () => {
|
||||
process.env.OPENCODE_SERVER_PASSWORD = "secret"
|
||||
delete process.env.OPENCODE_SERVER_USERNAME
|
||||
|
||||
let receivedConfig: { headers: Record<string, string> } | undefined
|
||||
let receivedHeadersConfig: { headers: Record<string, string> } | undefined
|
||||
const client = {
|
||||
_client: {
|
||||
setConfig: (config: { headers: Record<string, string> }) => {
|
||||
receivedConfig = config
|
||||
setConfig: (config: { headers?: Record<string, string> }) => {
|
||||
if (config.headers) {
|
||||
receivedHeadersConfig = { headers: config.headers }
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
injectServerAuthIntoClient(client)
|
||||
|
||||
expect(receivedConfig).toEqual({
|
||||
expect(receivedHeadersConfig).toEqual({
|
||||
headers: {
|
||||
Authorization: "Basic b3BlbmNvZGU6c2VjcmV0",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("#given server password #when injecting wraps internal fetch #then wrapped fetch adds Authorization header", async () => {
|
||||
//#given
|
||||
process.env.OPENCODE_SERVER_PASSWORD = "secret"
|
||||
delete process.env.OPENCODE_SERVER_USERNAME
|
||||
|
||||
let receivedAuthorization: string | null = null
|
||||
const baseFetch = async (request: Request): Promise<Response> => {
|
||||
receivedAuthorization = request.headers.get("Authorization")
|
||||
return new Response("ok")
|
||||
}
|
||||
|
||||
type InternalConfig = {
|
||||
fetch?: (request: Request) => Promise<Response>
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
let currentConfig: InternalConfig = {
|
||||
fetch: baseFetch,
|
||||
headers: {},
|
||||
}
|
||||
|
||||
const client = {
|
||||
_client: {
|
||||
getConfig: (): InternalConfig => ({ ...currentConfig }),
|
||||
setConfig: (config: InternalConfig): InternalConfig => {
|
||||
currentConfig = { ...currentConfig, ...config }
|
||||
return { ...currentConfig }
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
injectServerAuthIntoClient(client)
|
||||
if (!currentConfig.fetch) {
|
||||
throw new Error("expected fetch to be set")
|
||||
}
|
||||
await currentConfig.fetch(new Request("http://example.com"))
|
||||
|
||||
//#then
|
||||
expect(receivedAuthorization ?? "").toBe("Basic b3BlbmNvZGU6c2VjcmV0")
|
||||
})
|
||||
|
||||
test("#given server password #when internal has _config.fetch but no setConfig #then fetch is wrapped and injects Authorization", async () => {
|
||||
//#given
|
||||
process.env.OPENCODE_SERVER_PASSWORD = "secret"
|
||||
delete process.env.OPENCODE_SERVER_USERNAME
|
||||
|
||||
let receivedAuthorization: string | null = null
|
||||
const baseFetch = async (request: Request): Promise<Response> => {
|
||||
receivedAuthorization = request.headers.get("Authorization")
|
||||
return new Response("ok")
|
||||
}
|
||||
|
||||
const internal = {
|
||||
_config: {
|
||||
fetch: baseFetch,
|
||||
},
|
||||
}
|
||||
|
||||
const client = {
|
||||
_client: internal,
|
||||
}
|
||||
|
||||
//#when
|
||||
injectServerAuthIntoClient(client)
|
||||
await internal._config.fetch(new Request("http://example.com"))
|
||||
|
||||
//#then
|
||||
expect(receivedAuthorization ?? "").toBe("Basic b3BlbmNvZGU6c2VjcmV0")
|
||||
})
|
||||
|
||||
test("#given server password #when client has top-level fetch #then fetch is wrapped and injects Authorization", async () => {
|
||||
//#given
|
||||
process.env.OPENCODE_SERVER_PASSWORD = "secret"
|
||||
delete process.env.OPENCODE_SERVER_USERNAME
|
||||
|
||||
let receivedAuthorization: string | null = null
|
||||
const baseFetch = async (request: Request): Promise<Response> => {
|
||||
receivedAuthorization = request.headers.get("Authorization")
|
||||
return new Response("ok")
|
||||
}
|
||||
|
||||
const client = {
|
||||
fetch: baseFetch,
|
||||
}
|
||||
|
||||
//#when
|
||||
injectServerAuthIntoClient(client)
|
||||
await client.fetch(new Request("http://example.com"))
|
||||
|
||||
//#then
|
||||
expect(receivedAuthorization ?? "").toBe("Basic b3BlbmNvZGU6c2VjcmV0")
|
||||
})
|
||||
|
||||
test("#given server password #when interceptors are available #then request interceptor injects Authorization", async () => {
|
||||
//#given
|
||||
process.env.OPENCODE_SERVER_PASSWORD = "secret"
|
||||
delete process.env.OPENCODE_SERVER_USERNAME
|
||||
|
||||
let registeredInterceptor:
|
||||
| ((request: Request, options: { headers?: Headers }) => Promise<Request> | Request)
|
||||
| undefined
|
||||
|
||||
const client = {
|
||||
_client: {
|
||||
interceptors: {
|
||||
request: {
|
||||
use: (
|
||||
interceptor: (request: Request, options: { headers?: Headers }) => Promise<Request> | Request
|
||||
): number => {
|
||||
registeredInterceptor = interceptor
|
||||
return 0
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
injectServerAuthIntoClient(client)
|
||||
if (!registeredInterceptor) {
|
||||
throw new Error("expected interceptor to be registered")
|
||||
}
|
||||
const request = new Request("http://example.com")
|
||||
const result = await registeredInterceptor(request, {})
|
||||
|
||||
//#then
|
||||
expect(result.headers.get("Authorization")).toBe("Basic b3BlbmNvZGU6c2VjcmV0")
|
||||
})
|
||||
|
||||
test("#given no server password #when injecting into client with fetch #then does not wrap fetch", async () => {
|
||||
//#given
|
||||
delete process.env.OPENCODE_SERVER_PASSWORD
|
||||
delete process.env.OPENCODE_SERVER_USERNAME
|
||||
|
||||
let receivedAuthorization: string | null = null
|
||||
const baseFetch = async (request: Request): Promise<Response> => {
|
||||
receivedAuthorization = request.headers.get("Authorization")
|
||||
return new Response("ok")
|
||||
}
|
||||
|
||||
type InternalConfig = { fetch?: (request: Request) => Promise<Response> }
|
||||
let currentConfig: InternalConfig = { fetch: baseFetch }
|
||||
let setConfigCalled = false
|
||||
|
||||
const client = {
|
||||
_client: {
|
||||
getConfig: (): InternalConfig => ({ ...currentConfig }),
|
||||
setConfig: (config: InternalConfig): InternalConfig => {
|
||||
setConfigCalled = true
|
||||
currentConfig = { ...currentConfig, ...config }
|
||||
return { ...currentConfig }
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
injectServerAuthIntoClient(client)
|
||||
if (!currentConfig.fetch) {
|
||||
throw new Error("expected fetch to exist")
|
||||
}
|
||||
await currentConfig.fetch(new Request("http://example.com"))
|
||||
|
||||
//#then
|
||||
expect(setConfigCalled).toBe(false)
|
||||
expect(receivedAuthorization).toBeNull()
|
||||
})
|
||||
|
||||
test("#given server password #when client has no _client #then does not throw", () => {
|
||||
process.env.OPENCODE_SERVER_PASSWORD = "secret"
|
||||
const client = {}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { log } from "./logger"
|
||||
|
||||
/**
|
||||
* Builds HTTP Basic Auth header from environment variables.
|
||||
*
|
||||
@@ -15,6 +17,132 @@ export function getServerBasicAuthHeader(): string | undefined {
|
||||
return `Basic ${token}`
|
||||
}
|
||||
|
||||
type UnknownRecord = Record<string, unknown>
|
||||
|
||||
function isRecord(value: unknown): value is UnknownRecord {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function isRequestFetch(value: unknown): value is (request: Request) => Promise<Response> {
|
||||
return typeof value === "function"
|
||||
}
|
||||
|
||||
function wrapRequestFetch(
|
||||
baseFetch: (request: Request) => Promise<Response>,
|
||||
auth: string
|
||||
): (request: Request) => Promise<Response> {
|
||||
return async (request: Request): Promise<Response> => {
|
||||
const headers = new Headers(request.headers)
|
||||
headers.set("Authorization", auth)
|
||||
return baseFetch(new Request(request, { headers }))
|
||||
}
|
||||
}
|
||||
|
||||
function getInternalClient(client: unknown): UnknownRecord | null {
|
||||
if (!isRecord(client)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const internal = client["_client"]
|
||||
return isRecord(internal) ? internal : null
|
||||
}
|
||||
|
||||
function tryInjectViaSetConfigHeaders(internal: UnknownRecord, auth: string): boolean {
|
||||
const setConfig = internal["setConfig"]
|
||||
if (typeof setConfig !== "function") {
|
||||
return false
|
||||
}
|
||||
|
||||
setConfig({
|
||||
headers: {
|
||||
Authorization: auth,
|
||||
},
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function tryInjectViaInterceptors(internal: UnknownRecord, auth: string): boolean {
|
||||
const interceptors = internal["interceptors"]
|
||||
if (!isRecord(interceptors)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const requestInterceptors = interceptors["request"]
|
||||
if (!isRecord(requestInterceptors)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const use = requestInterceptors["use"]
|
||||
if (typeof use !== "function") {
|
||||
return false
|
||||
}
|
||||
|
||||
use((request: Request): Request => {
|
||||
if (!request.headers.get("Authorization")) {
|
||||
request.headers.set("Authorization", auth)
|
||||
}
|
||||
return request
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function tryInjectViaFetchWrapper(internal: UnknownRecord, auth: string): boolean {
|
||||
const getConfig = internal["getConfig"]
|
||||
const setConfig = internal["setConfig"]
|
||||
if (typeof getConfig !== "function" || typeof setConfig !== "function") {
|
||||
return false
|
||||
}
|
||||
|
||||
const config = getConfig()
|
||||
if (!isRecord(config)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const fetchValue = config["fetch"]
|
||||
if (!isRequestFetch(fetchValue)) {
|
||||
return false
|
||||
}
|
||||
|
||||
setConfig({
|
||||
fetch: wrapRequestFetch(fetchValue, auth),
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function tryInjectViaMutableInternalConfig(internal: UnknownRecord, auth: string): boolean {
|
||||
const configValue = internal["_config"]
|
||||
if (!isRecord(configValue)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const fetchValue = configValue["fetch"]
|
||||
if (!isRequestFetch(fetchValue)) {
|
||||
return false
|
||||
}
|
||||
|
||||
configValue["fetch"] = wrapRequestFetch(fetchValue, auth)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function tryInjectViaTopLevelFetch(client: unknown, auth: string): boolean {
|
||||
if (!isRecord(client)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const fetchValue = client["fetch"]
|
||||
if (!isRequestFetch(fetchValue)) {
|
||||
return false
|
||||
}
|
||||
|
||||
client["fetch"] = wrapRequestFetch(fetchValue, auth)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects HTTP Basic Auth header into the OpenCode SDK client.
|
||||
*
|
||||
@@ -34,36 +162,29 @@ export function injectServerAuthIntoClient(client: unknown): void {
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
typeof client !== "object" ||
|
||||
client === null ||
|
||||
!("_client" in client) ||
|
||||
typeof (client as { _client: unknown })._client !== "object" ||
|
||||
(client as { _client: unknown })._client === null
|
||||
) {
|
||||
throw new Error(
|
||||
"[opencode-server-auth] OPENCODE_SERVER_PASSWORD is set but SDK client structure is incompatible. " +
|
||||
"This may indicate an OpenCode SDK version mismatch."
|
||||
)
|
||||
const internal = getInternalClient(client)
|
||||
if (internal) {
|
||||
const injectedHeaders = tryInjectViaSetConfigHeaders(internal, auth)
|
||||
const injectedInterceptors = tryInjectViaInterceptors(internal, auth)
|
||||
const injectedFetch = tryInjectViaFetchWrapper(internal, auth)
|
||||
const injectedMutable = tryInjectViaMutableInternalConfig(internal, auth)
|
||||
|
||||
const injected = injectedHeaders || injectedInterceptors || injectedFetch || injectedMutable
|
||||
|
||||
if (!injected) {
|
||||
log("[opencode-server-auth] OPENCODE_SERVER_PASSWORD is set but SDK client structure is incompatible", {
|
||||
keys: Object.keys(internal),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const internal = (client as { _client: { setConfig?: (config: { headers: Record<string, string> }) => void } })
|
||||
._client
|
||||
|
||||
if (typeof internal.setConfig !== "function") {
|
||||
throw new Error(
|
||||
"[opencode-server-auth] OPENCODE_SERVER_PASSWORD is set but SDK client._client.setConfig is not a function. " +
|
||||
"This may indicate an OpenCode SDK version mismatch."
|
||||
)
|
||||
const injected = tryInjectViaTopLevelFetch(client, auth)
|
||||
if (!injected) {
|
||||
log("[opencode-server-auth] OPENCODE_SERVER_PASSWORD is set but no compatible SDK client found")
|
||||
}
|
||||
|
||||
internal.setConfig({
|
||||
headers: {
|
||||
Authorization: auth,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.warn(`[opencode-server-auth] Failed to inject server auth: ${message}`)
|
||||
log("[opencode-server-auth] Failed to inject server auth", { message })
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user