Skip to content

Commit 74ebb41

Browse files
authored
fix(auth): normalize trailing slashes in auth login URLs (#15874)
1 parent 1663c11 commit 74ebb41

5 files changed

Lines changed: 140 additions & 10 deletions

File tree

packages/opencode/src/auth/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,18 @@ export namespace Auth {
5656
}
5757

5858
export async function set(key: string, info: Info) {
59+
const normalized = key.replace(/\/+$/, "")
5960
const data = await all()
60-
await Filesystem.writeJson(filepath, { ...data, [key]: info }, 0o600)
61+
if (normalized !== key) delete data[key]
62+
delete data[normalized + "/"]
63+
await Filesystem.writeJson(filepath, { ...data, [normalized]: info }, 0o600)
6164
}
6265

6366
export async function remove(key: string) {
67+
const normalized = key.replace(/\/+$/, "")
6468
const data = await all()
6569
delete data[key]
70+
delete data[normalized]
6671
await Filesystem.writeJson(filepath, data, 0o600)
6772
}
6873
}

packages/opencode/src/cli/cmd/auth.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,8 @@ export const AuthLoginCommand = cmd({
263263
UI.empty()
264264
prompts.intro("Add credential")
265265
if (args.url) {
266-
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any)
266+
const url = args.url.replace(/\/+$/, "")
267+
const wellknown = await fetch(`${url}/.well-known/opencode`).then((x) => x.json() as any)
267268
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
268269
const proc = Process.spawn(wellknown.auth.command, {
269270
stdout: "pipe",
@@ -279,12 +280,12 @@ export const AuthLoginCommand = cmd({
279280
prompts.outro("Done")
280281
return
281282
}
282-
await Auth.set(args.url, {
283+
await Auth.set(url, {
283284
type: "wellknown",
284285
key: wellknown.auth.env,
285286
token: token.trim(),
286287
})
287-
prompts.log.success("Logged into " + args.url)
288+
prompts.log.success("Logged into " + url)
288289
prompts.outro("Done")
289290
return
290291
}

packages/opencode/src/config/config.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,12 @@ export namespace Config {
8686
let result: Info = {}
8787
for (const [key, value] of Object.entries(auth)) {
8888
if (value.type === "wellknown") {
89+
const url = key.replace(/\/+$/, "")
8990
process.env[value.key] = value.token
90-
log.debug("fetching remote config", { url: `${key}/.well-known/opencode` })
91-
const response = await fetch(`${key}/.well-known/opencode`)
91+
log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
92+
const response = await fetch(`${url}/.well-known/opencode`)
9293
if (!response.ok) {
93-
throw new Error(`failed to fetch remote config from ${key}: ${response.status}`)
94+
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
9495
}
9596
const wellknown = (await response.json()) as any
9697
const remoteConfig = wellknown.config ?? {}
@@ -99,11 +100,11 @@ export namespace Config {
99100
result = mergeConfigConcatArrays(
100101
result,
101102
await load(JSON.stringify(remoteConfig), {
102-
dir: path.dirname(`${key}/.well-known/opencode`),
103-
source: `${key}/.well-known/opencode`,
103+
dir: path.dirname(`${url}/.well-known/opencode`),
104+
source: `${url}/.well-known/opencode`,
104105
}),
105106
)
106-
log.debug("loaded remote config from well-known", { url: key })
107+
log.debug("loaded remote config from well-known", { url })
107108
}
108109
}
109110

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { test, expect } from "bun:test"
2+
import { Auth } from "../../src/auth"
3+
4+
test("set normalizes trailing slashes in keys", async () => {
5+
await Auth.set("https://example.com/", {
6+
type: "wellknown",
7+
key: "TOKEN",
8+
token: "abc",
9+
})
10+
const data = await Auth.all()
11+
expect(data["https://example.com"]).toBeDefined()
12+
expect(data["https://example.com/"]).toBeUndefined()
13+
})
14+
15+
test("set cleans up pre-existing trailing-slash entry", async () => {
16+
// Simulate a pre-fix entry with trailing slash
17+
await Auth.set("https://example.com/", {
18+
type: "wellknown",
19+
key: "TOKEN",
20+
token: "old",
21+
})
22+
// Re-login with normalized key (as the CLI does post-fix)
23+
await Auth.set("https://example.com", {
24+
type: "wellknown",
25+
key: "TOKEN",
26+
token: "new",
27+
})
28+
const data = await Auth.all()
29+
const keys = Object.keys(data).filter((k) => k.includes("example.com"))
30+
expect(keys).toEqual(["https://example.com"])
31+
const entry = data["https://example.com"]!
32+
expect(entry.type).toBe("wellknown")
33+
if (entry.type === "wellknown") expect(entry.token).toBe("new")
34+
})
35+
36+
test("remove deletes both trailing-slash and normalized keys", async () => {
37+
await Auth.set("https://example.com", {
38+
type: "wellknown",
39+
key: "TOKEN",
40+
token: "abc",
41+
})
42+
await Auth.remove("https://example.com/")
43+
const data = await Auth.all()
44+
expect(data["https://example.com"]).toBeUndefined()
45+
expect(data["https://example.com/"]).toBeUndefined()
46+
})
47+
48+
test("set and remove are no-ops on keys without trailing slashes", async () => {
49+
await Auth.set("anthropic", {
50+
type: "api",
51+
key: "sk-test",
52+
})
53+
const data = await Auth.all()
54+
expect(data["anthropic"]).toBeDefined()
55+
await Auth.remove("anthropic")
56+
const after = await Auth.all()
57+
expect(after["anthropic"]).toBeUndefined()
58+
})

packages/opencode/test/config/config.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1535,6 +1535,71 @@ test("project config overrides remote well-known config", async () => {
15351535
}
15361536
})
15371537

1538+
test("wellknown URL with trailing slash is normalized", async () => {
1539+
const originalFetch = globalThis.fetch
1540+
let fetchedUrl: string | undefined
1541+
const mockFetch = mock((url: string | URL | Request) => {
1542+
const urlStr = url.toString()
1543+
if (urlStr.includes(".well-known/opencode")) {
1544+
fetchedUrl = urlStr
1545+
return Promise.resolve(
1546+
new Response(
1547+
JSON.stringify({
1548+
config: {
1549+
mcp: {
1550+
slack: {
1551+
type: "remote",
1552+
url: "https://slack.example.com/mcp",
1553+
enabled: true,
1554+
},
1555+
},
1556+
},
1557+
}),
1558+
{ status: 200 },
1559+
),
1560+
)
1561+
}
1562+
return originalFetch(url)
1563+
})
1564+
globalThis.fetch = mockFetch as unknown as typeof fetch
1565+
1566+
const originalAuthAll = Auth.all
1567+
Auth.all = mock(() =>
1568+
Promise.resolve({
1569+
"https://example.com/": {
1570+
type: "wellknown" as const,
1571+
key: "TEST_TOKEN",
1572+
token: "test-token",
1573+
},
1574+
}),
1575+
)
1576+
1577+
try {
1578+
await using tmp = await tmpdir({
1579+
git: true,
1580+
init: async (dir) => {
1581+
await Filesystem.write(
1582+
path.join(dir, "opencode.json"),
1583+
JSON.stringify({
1584+
$schema: "https://opencode.ai/config.json",
1585+
}),
1586+
)
1587+
},
1588+
})
1589+
await Instance.provide({
1590+
directory: tmp.path,
1591+
fn: async () => {
1592+
await Config.get()
1593+
// Trailing slash should be stripped — no double slash in the fetch URL
1594+
expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
1595+
},
1596+
})
1597+
} finally {
1598+
globalThis.fetch = originalFetch
1599+
Auth.all = originalAuthAll
1600+
}
1601+
})
1602+
15381603
describe("getPluginName", () => {
15391604
test("extracts name from file:// URL", () => {
15401605
expect(Config.getPluginName("file:///path/to/plugin/foo.js")).toBe("foo")

0 commit comments

Comments
 (0)