Skip to content

Commit a60dad1

Browse files
committed
feat: enforce bearer auth on all /api/* routes and add tests
Apply HTTP bearer token authorization to every /api/* request in the cloudsession package by using Hono's built-in bearerAuth middleware at the app.use("/api/*") level. This replaces the per-route isAuthorizedAdminRequest guard with a single middleware that protects all API endpoints uniformly. Key changes: - cloudsession/src/index.tsx: add bearerAuth middleware (hono/bearer-auth) to app.use("/api/*"). Token validated against SESSIONS_RPC_SHARED_KEY; if unset, all API requests are rejected. Remove the now-redundant isAuthorizedAdminRequest helper and its manual check in GET /api/sessions. - opencode/src/share/share-next.ts: change rpcHeaders() to emit the standard Authorization: Bearer <token> header instead of the custom x-opencode-share-key header. Spread rpcHeaders() into all three HTTP transport fetch calls (POST /api/share, POST /api/share/:id/sync, DELETE /api/share/:id). - cloudsession/src/api.test.ts: add SESSIONS_RPC_SHARED_KEY to the test env, pass Authorization: Bearer in every request helper, and add a new "API Authorization" suite with 9 tests covering unauthenticated (401), wrong-token (401), and authorised (200) scenarios for key endpoints. - cloudsession/src/index.test.ts: add SESSIONS_RPC_SHARED_KEY to the test env and include Authorization: Bearer headers in all API requests. - packages/opencode/test/share/share-next.test.ts: new test file with 5 source-level assertions verifying the bearer header format and that every HTTP fetch call in share-next.ts spreads rpcHeaders(). https://claude.ai/code/session_01HbjvMV8GaSbrjysBmUwM8P
1 parent 3480177 commit a60dad1

5 files changed

Lines changed: 201 additions & 29 deletions

File tree

packages/cloudsession/src/api.test.ts

Lines changed: 118 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import type { AgentSession, SyncInfo, SessionIndex } from "./types"
44
import { createTestFileDiff, createTestMessage, createTestModel, createTestPart, createTestSession } from "./test-utils"
55

66
const SHARED_SECRET = "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
7+
const API_KEY = "test-api-key-abc123"
78

89
type TestEnv = {
910
SESSIONS_STORE: R2Bucket
1011
SESSIONS_SHARED_SECRET: string
12+
SESSIONS_RPC_SHARED_KEY: string
1113
API_DOMAIN: string
1214
SESSIONS_BROADCAST: DurableObjectNamespace
1315
}
@@ -84,6 +86,7 @@ function createEnv(): TestEnv {
8486
return {
8587
SESSIONS_STORE: createMockR2Bucket(),
8688
SESSIONS_SHARED_SECRET: SHARED_SECRET,
89+
SESSIONS_RPC_SHARED_KEY: API_KEY,
8790
API_DOMAIN: "opencode.api.com",
8891
SESSIONS_BROADCAST: createMockDONamespace(),
8992
}
@@ -102,7 +105,7 @@ async function createShare(sessionID: string, env: TestEnv) {
102105
"http://localhost/api/share",
103106
{
104107
method: "POST",
105-
headers: { "Content-Type": "application/json" },
108+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${env.SESSIONS_RPC_SHARED_KEY}` },
106109
body: JSON.stringify({ sessionID }),
107110
},
108111
env,
@@ -119,7 +122,7 @@ async function syncShare(
119122
`http://localhost/api/share/${shareID}/sync`,
120123
{
121124
method: "POST",
122-
headers: { "Content-Type": "application/json" },
125+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${env.SESSIONS_RPC_SHARED_KEY}` },
123126
body: JSON.stringify(payload),
124127
},
125128
env,
@@ -132,7 +135,7 @@ async function deleteShare(shareID: string, env: TestEnv, secret: string) {
132135
`http://localhost/api/share/${shareID}`,
133136
{
134137
method: "DELETE",
135-
headers: { "Content-Type": "application/json" },
138+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${env.SESSIONS_RPC_SHARED_KEY}` },
136139
body: JSON.stringify({ secret }),
137140
},
138141
env,
@@ -141,23 +144,35 @@ async function deleteShare(shareID: string, env: TestEnv, secret: string) {
141144
}
142145

143146
async function getShare(shareID: string, env: TestEnv) {
144-
const response = await request(`http://localhost/api/share/${shareID}`, { method: "GET" }, env)
147+
const response = await request(
148+
`http://localhost/api/share/${shareID}`,
149+
{ method: "GET", headers: { Authorization: `Bearer ${env.SESSIONS_RPC_SHARED_KEY}` } },
150+
env,
151+
)
145152
if (!response.ok) {
146153
return { response, data: null }
147154
}
148155
return { response, data: await parseJson<AgentSession>(response) }
149156
}
150157

151158
async function getMetadata(shareID: string, env: TestEnv) {
152-
const response = await request(`http://localhost/api/share/${shareID}/metadata`, { method: "GET" }, env)
159+
const response = await request(
160+
`http://localhost/api/share/${shareID}/metadata`,
161+
{ method: "GET", headers: { Authorization: `Bearer ${env.SESSIONS_RPC_SHARED_KEY}` } },
162+
env,
163+
)
153164
if (!response.ok) {
154165
return { response, data: null }
155166
}
156167
return { response, data: await parseJson<SessionIndex>(response) }
157168
}
158169

159170
async function listSessions(env: TestEnv) {
160-
const response = await request("http://localhost/api/sessions", { method: "GET" }, env)
171+
const response = await request(
172+
"http://localhost/api/sessions",
173+
{ method: "GET", headers: { Authorization: `Bearer ${env.SESSIONS_RPC_SHARED_KEY}` } },
174+
env,
175+
)
161176
return {
162177
response,
163178
data: await parseJson<{ sessions: SessionIndex[]; count: number }>(response),
@@ -636,3 +651,100 @@ describe("GET /", () => {
636651
expect(response.headers.get("Location")).toBe("/sessions")
637652
})
638653
})
654+
655+
describe("API Authorization (bearer auth middleware)", () => {
656+
let env: TestEnv
657+
658+
beforeEach(() => {
659+
env = createEnv()
660+
})
661+
662+
test("rejects /api/share POST with no token (401)", async () => {
663+
const response = await request(
664+
"http://localhost/api/share",
665+
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionID: "x" }) },
666+
env,
667+
)
668+
expect(response.status).toBe(401)
669+
})
670+
671+
test("rejects /api/share POST with wrong token (401)", async () => {
672+
const response = await request(
673+
"http://localhost/api/share",
674+
{
675+
method: "POST",
676+
headers: { "Content-Type": "application/json", Authorization: "Bearer wrong-key" },
677+
body: JSON.stringify({ sessionID: "x" }),
678+
},
679+
env,
680+
)
681+
expect(response.status).toBe(401)
682+
})
683+
684+
test("rejects /api/sessions GET with no token (401)", async () => {
685+
const response = await request("http://localhost/api/sessions", { method: "GET" }, env)
686+
expect(response.status).toBe(401)
687+
})
688+
689+
test("rejects /api/sessions GET with wrong token (401)", async () => {
690+
const response = await request(
691+
"http://localhost/api/sessions",
692+
{ method: "GET", headers: { Authorization: "Bearer wrong-key" } },
693+
env,
694+
)
695+
expect(response.status).toBe(401)
696+
})
697+
698+
test("rejects /api/share/:id GET with no token (401)", async () => {
699+
const response = await request("http://localhost/api/share/someid", { method: "GET" }, env)
700+
expect(response.status).toBe(401)
701+
})
702+
703+
test("rejects /api/share/:id/sync POST with no token (401)", async () => {
704+
const response = await request(
705+
"http://localhost/api/share/someid/sync",
706+
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ secret: "x", data: [] }) },
707+
env,
708+
)
709+
expect(response.status).toBe(401)
710+
})
711+
712+
test("rejects /api/share/:id DELETE with no token (401)", async () => {
713+
const response = await request(
714+
"http://localhost/api/share/someid",
715+
{ method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ secret: "x" }) },
716+
env,
717+
)
718+
expect(response.status).toBe(401)
719+
})
720+
721+
test("allows /api/share POST with valid token", async () => {
722+
const response = await request(
723+
"http://localhost/api/share",
724+
{
725+
method: "POST",
726+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${API_KEY}` },
727+
body: JSON.stringify({ sessionID: "valid-session" }),
728+
},
729+
env,
730+
)
731+
expect(response.status).toBe(200)
732+
})
733+
734+
test("rejects all /api/* routes when SESSIONS_RPC_SHARED_KEY is not configured", async () => {
735+
const unconfiguredEnv = {
736+
...createEnv(),
737+
SESSIONS_RPC_SHARED_KEY: "",
738+
}
739+
const response = await request(
740+
"http://localhost/api/share",
741+
{
742+
method: "POST",
743+
headers: { "Content-Type": "application/json", Authorization: "Bearer anything" },
744+
body: JSON.stringify({ sessionID: "x" }),
745+
},
746+
unconfiguredEnv,
747+
)
748+
expect(response.status).toBe(401)
749+
})
750+
})

packages/cloudsession/src/index.test.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from "./test-utils"
1313

1414
const sharedSecret = "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
15+
const apiKey = "test-api-key-abc123"
1516

1617
const createMockR2Bucket = () => {
1718
const storage = new Map<string, string>()
@@ -67,6 +68,7 @@ const createMockR2Bucket = () => {
6768
const createEnv = () => ({
6869
SESSIONS_STORE: createMockR2Bucket(),
6970
SESSIONS_SHARED_SECRET: sharedSecret,
71+
SESSIONS_RPC_SHARED_KEY: apiKey,
7072
API_DOMAIN: "test.opencode.ai",
7173
SESSIONS_BROADCAST: {
7274
idFromName: () => ({ toString: () => "mock-id" }),
@@ -88,7 +90,7 @@ const createShare = async (sessionID: string, env: ReturnType<typeof createEnv>)
8890
"http://localhost/api/share",
8991
{
9092
method: "POST",
91-
headers: { "Content-Type": "application/json" },
93+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${env.SESSIONS_RPC_SHARED_KEY}` },
9294
body: JSON.stringify({ sessionID }),
9395
},
9496
env,
@@ -106,7 +108,7 @@ const syncShare = async (
106108
`http://localhost/api/share/${shareID}/sync`,
107109
{
108110
method: "POST",
109-
headers: { "Content-Type": "application/json" },
111+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${env.SESSIONS_RPC_SHARED_KEY}` },
110112
body: JSON.stringify(payload),
111113
},
112114
env,
@@ -150,7 +152,11 @@ describe("Sessions API", () => {
150152
expect(syncResult.syncCount).toBe(1)
151153

152154
// Retrieve from GET /api/share/:id.
153-
const shareResponse = await request(`http://localhost/api/share/${share.id}`, { method: "GET" }, env)
155+
const shareResponse = await request(
156+
`http://localhost/api/share/${share.id}`,
157+
{ method: "GET", headers: { Authorization: `Bearer ${env.SESSIONS_RPC_SHARED_KEY}` } },
158+
env,
159+
)
154160
expect(shareResponse.status).toBe(200)
155161

156162
const shareSession = await parseJson<AgentSession>(shareResponse)
@@ -178,7 +184,11 @@ describe("Sessions API", () => {
178184
data: [{ type: "session", data: createTestSession({ id: "session-b" }) }],
179185
})
180186

181-
const response = await request("http://localhost/api/sessions", { method: "GET" }, env)
187+
const response = await request(
188+
"http://localhost/api/sessions",
189+
{ method: "GET", headers: { Authorization: `Bearer ${env.SESSIONS_RPC_SHARED_KEY}` } },
190+
env,
191+
)
182192
expect(response.status).toBe(200)
183193

184194
const result = await parseJson<{ sessions: SessionIndex[]; count: number }>(response)

packages/cloudsession/src/index.tsx

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Hono } from "hono"
22
import { cors } from "hono/cors"
3+
import { bearerAuth } from "hono/bearer-auth"
34
import { newWorkersRpcResponse } from "capnweb"
45
import { zValidator } from "@hono/zod-validator"
56
import { z } from "zod"
@@ -34,23 +35,20 @@ function isAuthorizedRpcRequest(c: { req: { header: (name: string) => string | u
3435
return received === configured
3536
}
3637

37-
function isAuthorizedAdminRequest(c: { req: { header: (name: string) => string | undefined }; env: Env }) {
38-
// The admin key reuses the same SESSIONS_RPC_SHARED_KEY env var.
39-
// If it is not configured the endpoint is inaccessible to prevent
40-
// unauthenticated enumeration of all sessions.
41-
const configured = c.env.SESSIONS_RPC_SHARED_KEY
42-
if (!configured) return false
43-
const received = c.req.header("x-opencode-share-key")
44-
return received === configured
45-
}
46-
4738
/**
4839
* Main Hono application
4940
*/
5041
const app = new Hono<{ Bindings: Env }>()
5142

52-
// Enable CORS for API routes only (not for WebSocket or HTML routes)
53-
app.use("/api/*", cors())
43+
// Enable CORS and require bearer auth for all API routes.
44+
// SESSIONS_RPC_SHARED_KEY must be configured — if absent every request is rejected.
45+
app.use(
46+
"/api/*",
47+
cors(),
48+
bearerAuth({
49+
verifyToken: (token, c) => !!c.env.SESSIONS_RPC_SHARED_KEY && token === c.env.SESSIONS_RPC_SHARED_KEY,
50+
}),
51+
)
5452

5553
app.all("/rpc/share", async (c) => {
5654
if (!isAuthorizedRpcRequest(c)) {
@@ -332,9 +330,6 @@ app.get("/api/share/:id/metadata", async (c) => {
332330
* GET /api/sessions
333331
*/
334332
app.get("/api/sessions", async (c) => {
335-
if (!isAuthorizedAdminRequest(c)) {
336-
return c.json({ error: "Unauthorized" }, 401)
337-
}
338333
const { index } = getStorageAdapter(c)
339334
const list = await index.list({ prefix: "index/" })
340335

packages/opencode/src/share/share-next.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export namespace ShareNext {
2828

2929
function rpcHeaders(): Record<string, string> | undefined {
3030
if (!rpcKey) return undefined
31-
return { "x-opencode-share-key": rpcKey }
31+
return { Authorization: `Bearer ${rpcKey}` }
3232
}
3333

3434
// Single reused RPC session — avoids re-creating the HTTP client on every call.
@@ -89,7 +89,7 @@ export namespace ShareNext {
8989
const [baseUrl, initialData] = await Promise.all([getUrl(), initialDataPromise])
9090
result = await fetch(`${baseUrl}/api/share`, {
9191
method: "POST",
92-
headers: { "Content-Type": "application/json" },
92+
headers: { "Content-Type": "application/json", ...rpcHeaders() },
9393
body: JSON.stringify({ sessionID }),
9494
})
9595
.then((x) => x.json())
@@ -183,7 +183,7 @@ export namespace ShareNext {
183183
async function syncHttp(share: { id: string; secret: string }, data: Data[], baseUrl: string) {
184184
await fetch(`${baseUrl}/api/share/${share.id}/sync`, {
185185
method: "POST",
186-
headers: { "Content-Type": "application/json" },
186+
headers: { "Content-Type": "application/json", ...rpcHeaders() },
187187
body: JSON.stringify({ secret: share.secret, data }),
188188
})
189189
}
@@ -201,7 +201,7 @@ export namespace ShareNext {
201201
const baseUrl = await getUrl()
202202
await fetch(`${baseUrl}/api/share/${share.id}`, {
203203
method: "DELETE",
204-
headers: { "Content-Type": "application/json" },
204+
headers: { "Content-Type": "application/json", ...rpcHeaders() },
205205
body: JSON.stringify({ secret: share.secret }),
206206
})
207207
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* Tests for ShareNext bearer-auth changes.
3+
*
4+
* Source-level assertions are used for the opencode package because share-next.ts
5+
* is imported transitively by many integration test files, making module-level
6+
* env var injection unreliable across the full test suite. The behavioural
7+
* verification lives in the cloudsession package's api.test.ts which exercises
8+
* the full request/response cycle end-to-end.
9+
*/
10+
import { describe, test, expect } from "bun:test"
11+
12+
describe("ShareNext — bearer auth header format (source verification)", () => {
13+
test("rpcHeaders() uses Authorization: Bearer, not x-opencode-share-key", async () => {
14+
const src = await Bun.file("./src/share/share-next.ts").text()
15+
// New bearer format must be present
16+
expect(src).toContain("Authorization: `Bearer ${rpcKey}`")
17+
// The old custom header must be gone
18+
expect(src).not.toContain('"x-opencode-share-key"')
19+
})
20+
21+
test("POST /api/share fetch call spreads rpcHeaders() into headers", async () => {
22+
const src = await Bun.file("./src/share/share-next.ts").text()
23+
// The create-share fetch must spread auth headers
24+
expect(src).toContain("/api/share`,")
25+
// Find the fetch call block for /api/share (POST)
26+
const match = src.match(/fetch\(`\$\{baseUrl\}\/api\/share`,[\s\S]{0,300}?\}[\s\S]{0,50}?\)/)
27+
expect(match).not.toBeNull()
28+
expect(match![0]).toContain("...rpcHeaders()")
29+
})
30+
31+
test("POST /api/share/:id/sync fetch call spreads rpcHeaders() into headers", async () => {
32+
const src = await Bun.file("./src/share/share-next.ts").text()
33+
const match = src.match(/fetch\(`\$\{baseUrl\}\/api\/share\/\$\{share\.id\}\/sync`,[\s\S]{0,300}?\}[\s\S]{0,50}?\)/)
34+
expect(match).not.toBeNull()
35+
expect(match![0]).toContain("...rpcHeaders()")
36+
})
37+
38+
test("DELETE /api/share/:id fetch call spreads rpcHeaders() into headers", async () => {
39+
const src = await Bun.file("./src/share/share-next.ts").text()
40+
const match = src.match(/fetch\(`\$\{baseUrl\}\/api\/share\/\$\{share\.id\}`,[\s\S]{0,300}?\}[\s\S]{0,50}?\)/)
41+
expect(match).not.toBeNull()
42+
expect(match![0]).toContain("...rpcHeaders()")
43+
})
44+
45+
test("no fetch call with Content-Type header omits rpcHeaders()", async () => {
46+
const src = await Bun.file("./src/share/share-next.ts").text()
47+
// Every headers block that has Content-Type must also spread rpcHeaders()
48+
const headerBlocks = src.match(/headers:\s*\{[^}]+\}/g) ?? []
49+
for (const block of headerBlocks) {
50+
if (block.includes("Content-Type")) {
51+
expect(block).toContain("...rpcHeaders()")
52+
}
53+
}
54+
})
55+
})

0 commit comments

Comments
 (0)