fix: desktop login loop caused by empty auth cookies (#6502)

Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
Shreyas
2026-07-14 17:21:16 +05:30
committed by GitHub
parent bfc00f899c
commit 465fa98085
5 changed files with 122 additions and 8 deletions

View File

@@ -21,6 +21,16 @@ import {
/**
* Extracts an access token from a cookie in the request.
*
* A blank cookie is treated as absent so `extractToken` uses the
* `Authorization` header. `O.fromNullable` alone keeps `''` as `Some('')`,
* and a whitespace-only value (`access_token= `) is non-empty by length,
* so both would be read in preference to a valid bearer token and reject
* the request with an unusable credential. A real JWT has no whitespace,
* so the trim-and-length check drops both.
*
* The value may not be a string: `cookie-parser` JSON-decodes `j:`-prefixed
* cookies into objects, where calling `.trim()` throws before auth fallback.
*
* @param request - Express Request object
* @returns Option<string> containing the token if found
*/
@@ -28,6 +38,10 @@ const extractFromCookie = (request: Request): O.Option<string> =>
pipe(
O.fromNullable(request.cookies),
O.chain((cookies) => O.fromNullable(cookies['access_token'])),
O.filter(
(token): token is string =>
typeof token === 'string' && token.trim().length > 0,
),
);
/**

View File

@@ -126,7 +126,20 @@ export class AgentKernelInterceptorService
const effectiveRequest = this.store.completeRequest(
preProcessRelayRequest(request)
)
await this.cookieJar.applyCookiesToRequest(effectiveRequest)
// A caller opts a request out of the shared cookie jar by setting
// `meta.options.cookies` to false. The desktop auth module sets it
// on its own bearer-authenticated backend calls so the interceptor
// skips both attaching a captured auth cookie to them and capturing
// one from their responses. Without that, a stale or blank
// `access_token` cookie was read in preference to the bearer token
// and desktop login stalled. Read from the original request because
// `completeRequest` rebuilds `meta` from domain settings.
const useCookieJar = request.meta?.options?.cookies !== false
if (useCookieJar) {
await this.cookieJar.applyCookiesToRequest(effectiveRequest)
}
const existingUserAgentHeader = Object.keys(
effectiveRequest.headers || {}
@@ -204,10 +217,12 @@ export class AgentKernelInterceptorService
multiHeaders: multiHeaders.length > 0 ? multiHeaders : undefined,
}
await this.cookieJar.captureResponseCookies(
transformedResponse,
effectiveRequest.url
)
if (useCookieJar) {
await this.cookieJar.captureResponseCookies(
transformedResponse,
effectiveRequest.url
)
}
return E.right(transformedResponse)
} catch (e) {

View File

@@ -185,7 +185,19 @@ export class NativeKernelInterceptorService
preProcessRelayRequest(request)
)
await this.cookieJar.applyCookiesToRequest(effectiveRequest)
// A caller opts a request out of the shared cookie jar by setting
// `meta.options.cookies` to false. The desktop auth module sets it
// on its own bearer-authenticated backend calls so the interceptor
// skips both attaching a captured auth cookie to them and capturing
// one from their responses. Without that, a stale or blank
// `access_token` cookie was read in preference to the bearer token
// and desktop login stalled. Read from the original request because
// `completeRequest` rebuilds `meta` from domain settings.
const useCookieJar = request.meta?.options?.cookies !== false
if (useCookieJar) {
await this.cookieJar.applyCookiesToRequest(effectiveRequest)
}
const existingUserAgentHeader = Object.keys(
effectiveRequest.headers || {}
@@ -212,7 +224,7 @@ export class NativeKernelInterceptorService
setRelayExecution(relayExecution)
const relayResponse = await relayExecution.response
if (E.isRight(relayResponse)) {
if (E.isRight(relayResponse) && useCookieJar) {
await this.cookieJar.captureResponseCookies(
relayResponse.right,
effectiveRequest.url

View File

@@ -245,7 +245,12 @@ export class ProxyKernelInterceptorService
// Same shared send path as native and agent. proxyscotch returns
// Set-Cookie as a header string rather than structured cookies, so
// receive-side capture for the proxy path is a separate follow-up.
await this.cookieJar.applyCookiesToRequest(processedRequest)
// A caller opts out of the jar by setting `meta.options.cookies` to
// false, so the interceptor skips attaching a captured auth cookie
// to the desktop auth module's bearer-authenticated backend calls.
if (request.meta?.options?.cookies !== false) {
await this.cookieJar.applyCookiesToRequest(processedRequest)
}
let content: ContentType
const multipartKey = `proxyRequestData-${v4()}`

View File

@@ -12,6 +12,7 @@ import { parseBodyAsJSON } from "@hoppscotch/common/helpers/functional/json"
import { AuthEvent, AuthPlatformDef } from "@hoppscotch/common/platform/auth"
import { PersistenceService } from "@hoppscotch/common/services/persistence"
import { KernelInterceptorService } from "@hoppscotch/common/services/kernel-interceptor.service"
import { CookieJarService } from "@hoppscotch/common/services/cookie-jar.service"
import Login from "@app/components/Login.vue"
import { getAllowedAuthProviders, updateUserDisplayName } from "./api"
@@ -46,6 +47,22 @@ const isGettingInitialUser: Ref<null | boolean> = ref(null)
const persistenceService = getService(PersistenceService)
const interceptorService = getService(KernelInterceptorService)
// Deferred: this module is evaluated before `initKernel`, so a top-level
// `getService(CookieJarService)` would permanently fail the jar's init and
// the cleanup below would see an empty jar.
const getCookieJarService = () => getService(CookieJarService)
// Every backend call in this module authenticates with a bearer token
// from local config, so none of them should touch the shared request
// cookie jar. Without this the interceptor captures the backend's auth
// `Set-Cookie` into the jar and reattaches it to later calls, where a
// stale or blank `access_token` cookie is read in preference to the
// bearer token, the request is rejected, and desktop login stalls.
// Used as each request's `meta` so it opts out of jar attach and
// capture. A factory returns a fresh object per call, so a request that
// later needs other `meta.options` can add them inline without a shared
// top-level spread overwriting the whole `meta`.
const noCookieJarMeta = () => ({ options: { cookies: false } })
async function logout() {
const { response } = interceptorService.execute({
@@ -53,6 +70,7 @@ async function logout() {
url: `${import.meta.env.VITE_BACKEND_API_URL}/auth/logout`,
version: "HTTP/1.1",
method: "GET",
meta: noCookieJarMeta(),
})
await response
@@ -117,6 +135,7 @@ async function getInitialUserDetails(): Promise<
}
}`,
}),
meta: noCookieJarMeta(),
})
const responseBytes = await response
@@ -167,6 +186,50 @@ async function setUser(user: HoppUserWithAuthDetail | null) {
)
}
// Older installs captured the backend's auth `Set-Cookie` into the shared
// jar before the opt-out above existed, so a blank or stale `access_token`
// or `refresh_token` cookie can still be persisted from a prior version and
// keep the login loop going. Remove any such entry for the backend host
// once on init. New installs never capture these, so this only heals state
// an earlier build left behind.
async function clearPersistedAuthCookiesFromJar() {
const cookieJarService = getCookieJarService()
await cookieJarService.whenReady()
const authCookieNames = new Set(["access_token", "refresh_token"])
const backendHosts = new Set<string>()
for (const rawUrl of [
import.meta.env.VITE_BACKEND_API_URL,
import.meta.env.VITE_BACKEND_GQL_URL,
]) {
try {
backendHosts.add(
cookieJarService.canonStoreDomain(new URL(rawUrl).hostname)
)
} catch {
// A malformed env URL leaves no host to clear.
}
}
const targets: Array<{ domain: string; name: string; path: string }> = []
for (const host of backendHosts) {
const bucket = cookieJarService.cookieJar.value.get(host) ?? []
for (const cookie of bucket) {
if (authCookieNames.has(cookie.name)) {
targets.push({
domain: cookie.domain,
name: cookie.name,
path: cookie.path,
})
}
}
}
if (targets.length > 0) {
await cookieJarService.deleteCookies(targets)
}
}
export async function setInitialUser() {
isGettingInitialUser.value = true
const res = await getInitialUserDetails()
@@ -232,6 +295,7 @@ async function refreshToken() {
headers: {
Authorization: `Bearer ${refreshToken}`,
},
meta: noCookieJarMeta(),
})
const res = await response
@@ -269,6 +333,7 @@ async function sendMagicLink(email: string) {
"Content-Type": "application/json",
},
content: content.json({ email }),
meta: noCookieJarMeta(),
})
const res = await response
@@ -381,6 +446,7 @@ export const def: AuthPlatformDef = {
const loginState = await persistenceService.getLocalConfig("login_state")
const probableUser = JSON.parse(loginState ?? "null")
probableUser$.next(probableUser)
await clearPersistedAuthCookiesFromJar()
await setInitialUser()
await listen<string>(
@@ -474,6 +540,7 @@ export const def: AuthPlatformDef = {
token: verifyToken,
deviceIdentifier,
}),
meta: noCookieJarMeta(),
})
const res = await response
@@ -542,6 +609,7 @@ export const def: AuthPlatformDef = {
"Content-Type": "application/json",
...this.getBackendHeaders(),
},
meta: noCookieJarMeta(),
})
const res = await response