Skip to content

v4 RestClient breaks token persistence on reload against HttpOnly-cookie Girder backends #364

Description

@arjunrajlab

@girder/components@4.0.0 (and likely earlier v4 releases) has three interacting issues in src/utils/restClient.js that, together, prevent reload-survives-auth when the Girder backend sets girderToken as HttpOnly (the default since girder/girder#3484, Dec 2023) and the front-end is served from a different origin than Girder (very common in dev: Vite/webpack on :5173/:8080 → Girder on a different port).

1. setCookieFromHash returns the entire URL hash as a "token" when no marker is present

restClient.js:19-29:

function setCookieFromHash(location) {
  const arr = location.hash.split(OauthTokenPrefix)
  const token = arr[arr.length - 1].split(OauthTokenSuffix)[0]
  if (token.length === GirderTokenLength) {
    // ... set cookie ...
  }
  return token   // <-- returned even when no marker matched
}

When window.location.hash is e.g. #/some/route/123 (i.e., any hash-routed SPA), the function returns "/some/route/123" as a "token". That value is then stored in this.token and sent as Girder-Token: /some/route/123 on every request → 401.

Pre-#3484, this code was dead in practice because cookies.get('girderToken') always succeeded first. Post-#3484, the JS cookie is gone, so this fallback runs and trips on the SPA's route hash.

Fix: only return the parsed token if it matched the marker; otherwise return null/empty.

2. The request interceptor sends Girder-Token even when empty, suppressing cookie auth

restClient.js:51-58:

this._axios.interceptors.request.use((config) => ({
  ...config,
  baseURL: this.apiRoot,
  headers: {
    [GirderToken]: this.token,   // <-- added unconditionally
    ...config.headers,
  },
}));

Girder's auth precedence is ?token=Girder-Token header → girderToken cookie. An empty Girder-Token header still takes the header path — the cookie is never consulted as a fallback. So even if a valid HttpOnly cookie is present, a request from this client with this.token === "" will 401.

Fix: in the interceptor, skip adding the header when this.token is falsy, so the request goes out with no Girder-Token and Girder falls through to the cookie.

3. withCredentials is only set on the login call

restClient.js:107:

const resp = await this.get('user/authentication', {
  // ...
  withCredentials: this.authenticateWithCredentials,
});

withCredentials is only attached to the user/authentication call, and is gated on the authenticateWithCredentials option (default false). Every other request goes out without credentials. This means:

  • Cookies are never sent cross-origin on any non-login request, so a deployment where the front-end origin differs from the Girder origin (dev typically, multi-origin prod sometimes) cannot use cookie-based auth at all.
  • The Set-Cookie returned by /user/authentication itself is silently dropped by the browser when the login request is cross-origin and authenticateWithCredentials is left at its default false. So the HttpOnly cookie never gets persisted to begin with.

Fix: apply withCredentials: this.authenticateWithCredentials to all requests via the interceptor, not just login. (Or, even better, default authenticateWithCredentials to true — there's no realistic CSRF surface on /user/me etc., and the current default makes cookie auth essentially decorative in cross-origin setups.)

Combined symptom

In a hash-routed SPA on a different origin from Girder, after PR #3484:

  1. User logs in. Set-Cookie: girderToken=...; HttpOnly is returned, but because authenticateWithCredentials defaulted to false and the request was cross-origin, the browser drops it. JS reads the token from the body and stays "logged in" via in-memory state + a JS-set host-only cookie at the front-end origin.
  2. User reloads. New RestClient instance. cookies.get('girderToken') at the front-end origin returns the JS-set cookie if the browser kept it (Chrome's localhost-port handling is inconsistent here; HttpOnly conflict on same name can also block the JS set). If not, falls to setCookieFromHash (issue Use preferred shorthand yarn commands #1) → returns garbage.
  3. First API call goes out with Girder-Token: <garbage> (or with Girder-Token: empty if the consumer sanitized Use preferred shorthand yarn commands #1) → 401. Cookie fallback never engages because of issues REST client utility #2 and CircleCI integration #3.

Suggested patch

--- a/src/utils/restClient.js
+++ b/src/utils/restClient.js
@@ function setCookieFromHash(location) {
-  if (token.length === GirderTokenLength) {
-    const expires = new Date()
-    expires.setDate(expires.getDate() + 365)
-    setCookieFromAuth({ token, expires })
-    location.hash = location.hash.replace(`${OauthTokenPrefix}${token}${OauthTokenSuffix}`, '')
-  }
-  return token
+  if (token.length !== GirderTokenLength) {
+    return null
+  }
+  const expires = new Date()
+  expires.setDate(expires.getDate() + 365)
+  setCookieFromAuth({ token, expires })
+  location.hash = location.hash.replace(`${OauthTokenPrefix}${token}${OauthTokenSuffix}`, '')
+  return token
 }
@@ this._axios.interceptors.request.use((config) => ({
-      ...config,
-      baseURL: this.apiRoot,
-      headers: {
-        [GirderToken]: this.token,
-        ...config.headers,
-      },
-    }));
+    this._axios.interceptors.request.use((config) => {
+      const headers = { ...(config.headers || {}) }
+      if (this.token) {
+        headers[GirderToken] = this.token
+      }
+      return {
+        ...config,
+        baseURL: this.apiRoot,
+        withCredentials: this.authenticateWithCredentials || config.withCredentials,
+        headers,
+      }
+    });

Reproducible with any Vue 3 + Vue Router (hash mode) + Girder 5.x backend on a separate origin from the front-end.

Versions:

  • @girder/components: 4.0.0
  • Girder backend: 5.0.7
  • Browser: Chrome 147

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions