From 465fa980850a06396c1915d6b802145ce8bf0aaa Mon Sep 17 00:00:00 2001 From: Shreyas Date: Tue, 14 Jul 2026 17:21:16 +0530 Subject: [PATCH] fix: desktop login loop caused by empty auth cookies (#6502) Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com> --- .../src/auth/strategies/jwt.strategy.ts | 14 ++++ .../std/kernel-interceptors/agent/index.ts | 25 +++++-- .../std/kernel-interceptors/native/index.ts | 16 ++++- .../std/kernel-interceptors/proxy/index.ts | 7 +- .../src/platform/auth/desktop/index.ts | 68 +++++++++++++++++++ 5 files changed, 122 insertions(+), 8 deletions(-) diff --git a/packages/hoppscotch-backend/src/auth/strategies/jwt.strategy.ts b/packages/hoppscotch-backend/src/auth/strategies/jwt.strategy.ts index 3b9b97c52..a5d8dc5cf 100644 --- a/packages/hoppscotch-backend/src/auth/strategies/jwt.strategy.ts +++ b/packages/hoppscotch-backend/src/auth/strategies/jwt.strategy.ts @@ -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 containing the token if found */ @@ -28,6 +38,10 @@ const extractFromCookie = (request: Request): O.Option => 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, + ), ); /** diff --git a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/agent/index.ts b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/agent/index.ts index feef90364..78aec496f 100644 --- a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/agent/index.ts +++ b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/agent/index.ts @@ -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) { diff --git a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/native/index.ts b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/native/index.ts index b3feeaec4..8987abd27 100644 --- a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/native/index.ts +++ b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/native/index.ts @@ -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 diff --git a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/proxy/index.ts b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/proxy/index.ts index 00c9217cf..e331abb36 100644 --- a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/proxy/index.ts +++ b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/proxy/index.ts @@ -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()}` diff --git a/packages/hoppscotch-selfhost-web/src/platform/auth/desktop/index.ts b/packages/hoppscotch-selfhost-web/src/platform/auth/desktop/index.ts index c590efb8a..22d503c57 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/auth/desktop/index.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/auth/desktop/index.ts @@ -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 = 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() + 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( @@ -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