mirror of
https://github.com/hoppscotch/hoppscotch.git
synced 2026-08-02 02:09:37 -04:00
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:
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()}`
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user