fix(auth): normalize trailing slashes in auth login URLs (#15874)

This commit is contained in:
Matt Silverlock 2026-03-03 13:35:49 -05:00 committed by GitHub
parent 1663c11f40
commit 74ebb4147f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 140 additions and 10 deletions

View File

@ -56,13 +56,18 @@ export namespace Auth {
}
export async function set(key: string, info: Info) {
const normalized = key.replace(/\/+$/, "")
const data = await all()
await Filesystem.writeJson(filepath, { ...data, [key]: info }, 0o600)
if (normalized !== key) delete data[key]
delete data[normalized + "/"]
await Filesystem.writeJson(filepath, { ...data, [normalized]: info }, 0o600)
}
export async function remove(key: string) {
const normalized = key.replace(/\/+$/, "")
const data = await all()
delete data[key]
delete data[normalized]
await Filesystem.writeJson(filepath, data, 0o600)
}
}

View File

@ -263,7 +263,8 @@ export const AuthLoginCommand = cmd({
UI.empty()
prompts.intro("Add credential")
if (args.url) {
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any)
const url = args.url.replace(/\/+$/, "")
const wellknown = await fetch(`${url}/.well-known/opencode`).then((x) => x.json() as any)
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const proc = Process.spawn(wellknown.auth.command, {
stdout: "pipe",
@ -279,12 +280,12 @@ export const AuthLoginCommand = cmd({
prompts.outro("Done")
return
}
await Auth.set(args.url, {
await Auth.set(url, {
type: "wellknown",
key: wellknown.auth.env,
token: token.trim(),
})
prompts.log.success("Logged into " + args.url)
prompts.log.success("Logged into " + url)
prompts.outro("Done")
return
}

View File

@ -86,11 +86,12 @@ export namespace Config {
let result: Info = {}
for (const [key, value] of Object.entries(auth)) {
if (value.type === "wellknown") {
const url = key.replace(/\/+$/, "")
process.env[value.key] = value.token
log.debug("fetching remote config", { url: `${key}/.well-known/opencode` })
const response = await fetch(`${key}/.well-known/opencode`)
log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
const response = await fetch(`${url}/.well-known/opencode`)
if (!response.ok) {
throw new Error(`failed to fetch remote config from ${key}: ${response.status}`)
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
}
const wellknown = (await response.json()) as any
const remoteConfig = wellknown.config ?? {}
@ -99,11 +100,11 @@ export namespace Config {
result = mergeConfigConcatArrays(
result,
await load(JSON.stringify(remoteConfig), {
dir: path.dirname(`${key}/.well-known/opencode`),
source: `${key}/.well-known/opencode`,
dir: path.dirname(`${url}/.well-known/opencode`),
source: `${url}/.well-known/opencode`,
}),
)
log.debug("loaded remote config from well-known", { url: key })
log.debug("loaded remote config from well-known", { url })
}
}

View File

@ -0,0 +1,58 @@
import { test, expect } from "bun:test"
import { Auth } from "../../src/auth"
test("set normalizes trailing slashes in keys", async () => {
await Auth.set("https://example.com/", {
type: "wellknown",
key: "TOKEN",
token: "abc",
})
const data = await Auth.all()
expect(data["https://example.com"]).toBeDefined()
expect(data["https://example.com/"]).toBeUndefined()
})
test("set cleans up pre-existing trailing-slash entry", async () => {
// Simulate a pre-fix entry with trailing slash
await Auth.set("https://example.com/", {
type: "wellknown",
key: "TOKEN",
token: "old",
})
// Re-login with normalized key (as the CLI does post-fix)
await Auth.set("https://example.com", {
type: "wellknown",
key: "TOKEN",
token: "new",
})
const data = await Auth.all()
const keys = Object.keys(data).filter((k) => k.includes("example.com"))
expect(keys).toEqual(["https://example.com"])
const entry = data["https://example.com"]!
expect(entry.type).toBe("wellknown")
if (entry.type === "wellknown") expect(entry.token).toBe("new")
})
test("remove deletes both trailing-slash and normalized keys", async () => {
await Auth.set("https://example.com", {
type: "wellknown",
key: "TOKEN",
token: "abc",
})
await Auth.remove("https://example.com/")
const data = await Auth.all()
expect(data["https://example.com"]).toBeUndefined()
expect(data["https://example.com/"]).toBeUndefined()
})
test("set and remove are no-ops on keys without trailing slashes", async () => {
await Auth.set("anthropic", {
type: "api",
key: "sk-test",
})
const data = await Auth.all()
expect(data["anthropic"]).toBeDefined()
await Auth.remove("anthropic")
const after = await Auth.all()
expect(after["anthropic"]).toBeUndefined()
})

View File

@ -1535,6 +1535,71 @@ test("project config overrides remote well-known config", async () => {
}
})
test("wellknown URL with trailing slash is normalized", async () => {
const originalFetch = globalThis.fetch
let fetchedUrl: string | undefined
const mockFetch = mock((url: string | URL | Request) => {
const urlStr = url.toString()
if (urlStr.includes(".well-known/opencode")) {
fetchedUrl = urlStr
return Promise.resolve(
new Response(
JSON.stringify({
config: {
mcp: {
slack: {
type: "remote",
url: "https://slack.example.com/mcp",
enabled: true,
},
},
},
}),
{ status: 200 },
),
)
}
return originalFetch(url)
})
globalThis.fetch = mockFetch as unknown as typeof fetch
const originalAuthAll = Auth.all
Auth.all = mock(() =>
Promise.resolve({
"https://example.com/": {
type: "wellknown" as const,
key: "TEST_TOKEN",
token: "test-token",
},
}),
)
try {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Config.get()
// Trailing slash should be stripped — no double slash in the fetch URL
expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
},
})
} finally {
globalThis.fetch = originalFetch
Auth.all = originalAuthAll
}
})
describe("getPluginName", () => {
test("extracts name from file:// URL", () => {
expect(Config.getPluginName("file:///path/to/plugin/foo.js")).toBe("foo")