Skip to content

Commit d8ec30d

Browse files
Zexiclaude
authored andcommitted
refactor: replace 401 redirect loop with auth-required error page, drop cookie
- Remove global fetch interceptor that caused infinite redirect loop between / and project subpaths (SPA auto-navigates from / to last project, which then gets 401 again, looping) - Add is401() detection in ErrorPage: shows "Authentication required" with a clear explanation instead of the generic "Something went wrong" - Remove oc_auth_token cookie entirely: the SPA already carries credentials in Authorization headers via createSdkForServer; the cookie was redundant and added by us, not upstream - Simplify uiRouterMiddleware to a trivial pass-through; the SPA always loads at any subpath without server-side auth blocking Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6e08050 commit d8ec30d

6 files changed

Lines changed: 53 additions & 61 deletions

File tree

packages/app/src/entry.tsx

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -153,20 +153,6 @@ if (import.meta.env.VITE_SENTRY_DSN) {
153153
})
154154
}
155155

156-
// Intercept 401 responses: if credentials are stale and we're not on the home
157-
// page already, navigate to "/" so the user can re-authenticate.
158-
const _nativeFetch = globalThis.fetch.bind(globalThis)
159-
const _interceptedFetch = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]): Promise<Response> => {
160-
const response = await _nativeFetch(input, init)
161-
if (response.status === 401 && window.location.pathname !== "/") {
162-
window.location.href = "/"
163-
return new Promise<Response>(() => {})
164-
}
165-
return response
166-
}
167-
Object.assign(_interceptedFetch, globalThis.fetch)
168-
globalThis.fetch = _interceptedFetch as unknown as typeof fetch
169-
170156
if (root instanceof HTMLElement) {
171157
const auth = authFromToken(new URLSearchParams(location.search).get("auth_token"))
172158
clearAuthToken()

packages/app/src/i18n/en.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,9 @@ export const dict = {
479479

480480
"error.page.title": "Something went wrong",
481481
"error.page.description": "An error occurred while loading the application.",
482+
"error.page.auth.title": "Authentication required",
483+
"error.page.auth.description": "This server requires a password. Open this page from the OpenCode desktop app, or use a URL that includes an auth token.",
484+
"error.page.auth.action.home": "Go to home",
482485
"error.page.details.label": "Error Details",
483486
"error.page.action.restart": "Restart",
484487
"error.page.action.report": "Report Error",

packages/app/src/i18n/zh.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,9 @@ export const dict = {
465465

466466
"error.page.title": "出了点问题",
467467
"error.page.description": "加载应用程序时发生错误。",
468+
"error.page.auth.title": "需要身份验证",
469+
"error.page.auth.description": "此服务器需要密码。请通过 OpenCode 桌面应用打开此页面,或使用包含 auth token 的链接。",
470+
"error.page.auth.action.home": "返回首页",
468471
"error.page.details.label": "错误详情",
469472
"error.page.action.restart": "重启",
470473
"error.page.action.report": "上报错误",

packages/app/src/pages/error.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,21 @@ function formatError(error: unknown, t: Translator): string {
214214
return formatErrorChain(error, t, 0)
215215
}
216216

217+
function is401(error: unknown, depth = 0): boolean {
218+
if (!error || depth > 5) return false
219+
if (isInitError(error) && error.name === "APIError") {
220+
return (error.data as { statusCode?: number }).statusCode === 401
221+
}
222+
if (error instanceof Error) {
223+
if (error.message.includes("401 Unauthorized")) return true
224+
return is401(error.cause, depth + 1)
225+
}
226+
if (typeof error === "object" && "status" in error) {
227+
return (error as { status?: number }).status === 401
228+
}
229+
return false
230+
}
231+
217232
interface ErrorPageProps {
218233
error: unknown
219234
}
@@ -254,6 +269,28 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
254269
})
255270
}
256271

272+
if (is401(props.error)) {
273+
return (
274+
<div class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center bg-background-base font-sans">
275+
<div class="w-2/3 max-w-md flex flex-col items-center justify-center gap-8">
276+
<Logo class="w-58.5 opacity-12 shrink-0" />
277+
<div class="flex flex-col items-center gap-2 text-center">
278+
<h1 class="text-lg font-medium text-text-strong">{language.t("error.page.auth.title")}</h1>
279+
<p class="text-sm text-text-weak">{language.t("error.page.auth.description")}</p>
280+
</div>
281+
<Button size="large" onClick={() => { window.location.href = "/" }}>
282+
{language.t("error.page.auth.action.home")}
283+
</Button>
284+
<Show when={platform.version}>
285+
{(version) => (
286+
<p class="text-xs text-text-weak">{language.t("error.page.version", { version: version() })}</p>
287+
)}
288+
</Show>
289+
</div>
290+
</div>
291+
)
292+
}
293+
257294
return (
258295
<div class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center bg-background-base font-sans">
259296
<div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8">

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

Lines changed: 7 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ 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"
109
const UNAUTHORIZED = 401
1110
// Use Bearer scheme so browsers don't show a native auth dialog on 401.
1211
// The server still accepts Authorization: Basic credentials from the app.
@@ -73,26 +72,11 @@ function credentialFromURL(url: URL, request: HttpServerRequest.HttpServerReques
7372
if (token) return decodeCredential(token)
7473
const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "")
7574
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])
8075
return Effect.succeed(emptyCredential())
8176
}
8277

83-
function setCookieHeader(
84-
response: HttpServerResponse.HttpServerResponse,
85-
token: string,
86-
): HttpServerResponse.HttpServerResponse {
87-
return HttpServerResponse.setHeader(
88-
response,
89-
"set-cookie",
90-
`${AUTH_TOKEN_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict`,
91-
)
92-
}
93-
9478
// Router middleware for all routes except the SPA catch-all. Requires auth for
95-
// non-public paths and sets a persistent cookie when auth_token is in the URL.
79+
// non-public paths (API, static assets, /doc). PTY ticket URLs bypass auth.
9680
export const authorizationRouterMiddleware = HttpRouter.middleware()(
9781
Effect.gen(function* () {
9882
const config = yield* ServerAuth.Config
@@ -104,45 +88,24 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()(
10488
const url = new URL(request.url, "http://localhost")
10589
if (isPublicUIPath(request.method, url.pathname)) return yield* effect
10690
if (hasPtyConnectTicketURL(url)) return yield* effect
107-
const token = url.searchParams.get(AUTH_TOKEN_QUERY)
10891
const credential = yield* credentialFromURL(url, request)
10992
if (!ServerAuth.authorized(credential, config)) {
11093
return HttpServerResponse.empty({
11194
status: UNAUTHORIZED,
11295
headers: { "www-authenticate": WWW_AUTHENTICATE },
11396
})
11497
}
115-
// Auth passed — get the response and attach cookie if token came via URL.
116-
// Direct header manipulation avoids appendPreResponseHandler which does not
117-
// fire reliably in the raw router middleware context.
118-
const response = yield* (effect as unknown as Effect.Effect<HttpServerResponse.HttpServerResponse, never, never>)
119-
return token ? setCookieHeader(response, token) : response
98+
return yield* (effect as unknown as Effect.Effect<HttpServerResponse.HttpServerResponse, never, never>)
12099
})
121100
}),
122101
)
123102

124-
// Router middleware for the SPA catch-all route (/*). Always serves content so
125-
// the browser can load the app shell at any subpath without a credential prompt.
126-
// API routes have their own auth layer; the SPA handles auth internally once loaded.
127-
// Sets a persistent cookie when a valid auth_token query param is supplied so that
128-
// subsequent SPA navigation (without the query param) remains authenticated.
103+
// Router middleware for the SPA catch-all route (/*). Always serves the app
104+
// shell so the browser can load the SPA at any subpath. The SPA reads the
105+
// auth_token query param client-side and carries credentials in Authorization
106+
// headers — no server-side session cookie is needed.
129107
export const uiRouterMiddleware = HttpRouter.middleware()(
130-
Effect.gen(function* () {
131-
const config = yield* ServerAuth.Config
132-
if (!ServerAuth.required(config)) return (effect) => effect
133-
134-
return (effect) =>
135-
Effect.gen(function* () {
136-
const request = yield* HttpServerRequest.HttpServerRequest
137-
const url = new URL(request.url, "http://localhost")
138-
const token = url.searchParams.get(AUTH_TOKEN_QUERY)
139-
// Always serve — the SPA handles auth internally via stored credentials.
140-
const response = yield* (effect as unknown as Effect.Effect<HttpServerResponse.HttpServerResponse, never, never>)
141-
if (!token) return response
142-
const credential = yield* credentialFromURL(url, request)
143-
return ServerAuth.authorized(credential, config) ? setCookieHeader(response, token) : response
144-
})
145-
}),
108+
Effect.succeed((effect) => effect),
146109
)
147110

148111
export const authorizationLayer = Layer.effect(

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,9 @@ const cors = (corsOptions?: CorsOptions) =>
110110
// - rootApiRoutes: typed /global/* and control routes; auth is declared by RootHttpApi.
111111
// - eventApiRoutes/rawInstanceRoutes: raw instance routes; auth and workspace routing happen as router middleware.
112112
// - instanceApiRoutes: schema routes; auth is declared on each group and workspace context is provided below.
113-
// - uiRoute: raw catch-all fallback; auth is router middleware so public static assets can bypass it.
113+
// - uiRoute: raw catch-all; served without auth so the SPA loads at any subpath.
114114
const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
115-
const uiAuthLayer = uiRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
115+
const uiRouterLayer = uiRouterMiddleware.layer
116116
const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
117117
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(
118118
Layer.provide([controlHandlers, globalHandlers]),
@@ -174,7 +174,7 @@ const uiRoute = HttpRouter.use((router) =>
174174
const client = yield* HttpClient.HttpClient
175175
yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client }))
176176
}),
177-
).pipe(Layer.provide(uiAuthLayer))
177+
).pipe(Layer.provide(uiRouterLayer))
178178

179179
export function createRoutes(corsOptions?: CorsOptions) {
180180
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, docRoute, uiRoute).pipe(

0 commit comments

Comments
 (0)