Skip to content

Commit 3f3f606

Browse files
Zexiclaude
authored andcommitted
fix: cookie auth for SPA subpaths + validate server credentials before save
- Use appendPreResponseHandler to set oc_auth_token cookie when auth_token query param is valid, so browser SPA subpath navigation works without 401 - Validate new inbound server credentials against running server health endpoint before saving, to prevent locking out on credential typo Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d56cbc8 commit 3f3f606

4 files changed

Lines changed: 48 additions & 3 deletions

File tree

packages/app/src/components/dialog-select-server.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -677,6 +677,31 @@ export function DialogSelectServer() {
677677
return
678678
}
679679

680+
// When credentials are changing, verify the new credentials actually work
681+
// against the currently running server before persisting them. This prevents
682+
// saving a typo that would lock the user out on the next request.
683+
const credentialsChanged = !current || current.username !== next.username || current.password !== next.password
684+
if (credentialsChanged && next.enabled && next.password) {
685+
const runtimeConfig = platform.localServerRuntimeConfig?.()
686+
const runningPort = runtimeConfig?.port ?? current?.port
687+
if (runningPort) {
688+
const testHttp: ServerConnection.HttpBase = {
689+
url: `http://localhost:${runningPort}`,
690+
username: next.username || undefined,
691+
password: next.password,
692+
}
693+
const result = await checkServerHealth(testHttp)
694+
if (!result.healthy) {
695+
showToast({
696+
variant: "error",
697+
title: language.t("common.requestFailed"),
698+
description: language.t("dialog.server.inbound.credentialInvalid"),
699+
})
700+
return
701+
}
702+
}
703+
}
704+
680705
setStore("inboundServer", "saving", true)
681706
try {
682707
await platform.setLocalServerConfig(next)

packages/app/src/i18n/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,7 @@ export const dict = {
361361
"dialog.server.inbound.portPlaceholder": "Port",
362362
"dialog.server.inbound.portRequired": "Enter a port",
363363
"dialog.server.inbound.portInvalid": "Enter a port between 1 and 65535",
364+
"dialog.server.inbound.credentialInvalid": "The credentials do not match the running server. Check your username and password.",
364365
"server.row.noUsername": "no username",
365366

366367
"dialog.project.edit.title": "Edit project",

packages/app/src/i18n/zh.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,7 @@ export const dict = {
367367
"dialog.server.inbound.portPlaceholder": "端口",
368368
"dialog.server.inbound.portRequired": "请输入端口",
369369
"dialog.server.inbound.portInvalid": "请输入 1 到 65535 之间的端口",
370+
"dialog.server.inbound.credentialInvalid": "凭据与运行中的服务器不匹配,请检查用户名和密码。",
370371

371372
"dialog.project.edit.title": "编辑项目",
372373
"dialog.project.edit.name": "名称",

packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { hasPtyConnectTicketURL } from "@/server/shared/pty-ticket"
66
import { isPublicUIPath } from "@/server/shared/public-ui"
77

88
const AUTH_TOKEN_QUERY = "auth_token"
9+
const AUTH_TOKEN_COOKIE = "oc_auth_token"
910
const UNAUTHORIZED = 401
1011
// Use Bearer scheme so browsers don't show a native auth dialog on 401.
1112
// The server still accepts Authorization: Basic credentials from the app.
@@ -72,6 +73,10 @@ function credentialFromURL(url: URL, request: HttpServerRequest.HttpServerReques
7273
if (token) return decodeCredential(token)
7374
const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "")
7475
if (match) return decodeCredential(match[1])
76+
// Fall back to cookie set on a previous authenticated request
77+
const cookieHeader = request.headers.cookie ?? ""
78+
const cookieMatch = new RegExp(`(?:^|;\\s*)${AUTH_TOKEN_COOKIE}=([^;]+)`).exec(cookieHeader)
79+
if (cookieMatch) return decodeCredential(cookieMatch[1])
7580
return Effect.succeed(emptyCredential())
7681
}
7782

@@ -102,9 +107,22 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()(
102107
const url = new URL(request.url, "http://localhost")
103108
if (isPublicUIPath(request.method, url.pathname)) return yield* effect
104109
if (hasPtyConnectTicketURL(url)) return yield* effect
105-
return yield* credentialFromURL(url, request).pipe(
106-
Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
107-
)
110+
const token = url.searchParams.get(AUTH_TOKEN_QUERY)
111+
const credential = yield* credentialFromURL(url, request)
112+
// When auth_token comes via URL and is valid, set a persistent cookie so the
113+
// browser can navigate to SPA subpaths without repeating the query param.
114+
if (token && ServerAuth.authorized(credential, config)) {
115+
yield* HttpEffect.appendPreResponseHandler((_req, response) =>
116+
Effect.succeed(
117+
HttpServerResponse.setHeader(
118+
response,
119+
"set-cookie",
120+
`${AUTH_TOKEN_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict`,
121+
),
122+
),
123+
)
124+
}
125+
return yield* validateRawCredential(effect, credential, config)
108126
})
109127
}),
110128
)

0 commit comments

Comments
 (0)