fischer-agentkit/docs/solutions/ui-bugs/tauri-reload-loses-session.md

13 KiB

title date category module problem_type component symptoms root_cause resolution_type severity tags
Tauri reload redirects to login (session lost) 2026-06-29 docs/solutions/ui-bugs/ frontend/auth ui_bug authentication
After login, reloading the Tauri WebView redirects to /login
Browser console shows CORS errors and 401 on /api/v1/llm/models
Backend logs show no /api/v1/auth/whoami request after reload
getRefreshToken() returns null despite a prior successful login
logic_error code_fix high
tauri
reload
session
auth
keychain
fallback
cors
localstorage

Tauri reload redirects to login (session lost)

Problem

In the Tauri desktop app (Vue 3 + TypeScript frontend, Python FastAPI sidecar backend), users were redirected to /login every time they right-clicked and reloaded the page after logging in. The refresh token — persisted via tauriAuthStorage to OS Keychain in Tauri (localStorage in Web) — was effectively lost on every reload, so startupCheck() could not rehydrate the session and the router guard fell through to the login route.

Symptoms

  • Right-click → Reload after login always redirects back to /login, even though login just succeeded.
  • Backend logs after reload showed no /api/v1/auth/whoami request, proving the refresh token never reached the rehydrate step.
  • Dev console showed 401 responses on /api/v1/llm/models with missing CORS headers (browser blocked the cross-origin response body).
  • ChatInput.vue's fetchModels() fired on a raw fetch() with no Authorization header, triggering the 401 above.
  • Tauri Rust shell (src-tauri/src/lib.rs) only registered 4 sidecar commands (start_backend, get_backend_port, stop_backend, check_backend_health) — the keychain commands (store_refresh_token / load_refresh_token / clear_refresh_token) were never registered, and no auth.rs file existed.

What Didn't Work

  • Fixing the /llm/models 401 alone did not stop reload→/login. The 401 was a symptom of the missing auth header on one fetch, not the cause of session loss. After attaching the Authorization header the models call succeeded, but reload still bounced to login.
  • Adding debug logs and asking the user to test was rejected by the user ("you can do this test yourself"). The key evidence — no /auth/whoami request in backend logs after reload — was already available and pointed directly at getRefreshToken() returning null. Switching to static code analysis of tauri-auth.ts exposed the fallback logic flaw without a runtime round-trip.
  • Registering the keychain Rust commands (auth.rs + keyring crate) was considered as the fix, but it is a larger change with its own failure modes (permission prompts, OS keyring unavailable). The localStorage-always-backup fix is smaller, sufficient, and strictly more robust.

Solution

1. CORS in dev mode (base.ts)

initApiBaseURL() in src/agentkit/server/frontend/src/api/base.ts unconditionally set _dynamicBaseURL = http://127.0.0.1:${port} whenever isTauri() was true — even in dev mode. This bypassed the Vite dev server proxy (which forwards /apihttp://localhost:8000 same-origin) and sent requests directly to port 8000. Auth failures returned 401 without CORS headers, and the browser blocked the response body, making the failure look like a network error.

Before:

export async function initApiBaseURL(): Promise<void> {
  if (isTauri()) {
    const port = await getBackendPort()
    _dynamicBaseURL = `http://127.0.0.1:${port}`
  }
}

After — guard with !import.meta.env.DEV:

export async function initApiBaseURL(): Promise<void> {
  if (isTauri() && !import.meta.env.DEV) {
    const port = await getBackendPort()
    _dynamicBaseURL = `http://127.0.0.1:${port}`
  }
  // In dev mode, _dynamicBaseURL stays empty — requests use relative URLs
  // which the Vite proxy forwards to the backend (same-origin, no CORS).
}

In dev, _dynamicBaseURL stays empty so all requests use relative URLs and flow through the Vite proxy. In Tauri production (where there is no Vite dev server), the dynamic port is resolved from the sidecar as before.

2. Missing Authorization header on /llm/models (ChatInput.vue)

ChatInput.vue's fetchModels() called fetch(url) directly, bypassing ApiClient (which extends BaseApiClient and auto-attaches the Authorization header via the token provider). The backend's AuthMiddleware rejected the bare request with 401.

Before:

async function fetchModels() {
  const res = await fetch(url)  // no Authorization header
  // ...
}

After — add listModels() to ApiClient and use it:

// client.ts
async listModels(): Promise<IModelsResponse> {
  return this.request<IModelsResponse>('/api/v1/llm/models')
}

// ChatInput.vue
async function fetchModels() {
  modelsLoading.value = true
  try {
    const data = await apiClient.listModels()
    availableModels.value = data.models || []
    selectedModel.value = data.default || (availableModels.value.length > 0 ? availableModels.value[0].id : undefined)
  } catch {
    availableModels.value = []
  } finally {
    modelsLoading.value = false
  }
}

Every authenticated endpoint must go through ApiClient so the token provider can attach the header consistently. Raw fetch() calls to /api/v1/* are an anti-pattern in this codebase.

3. Keychain fallback logic flaw (tauri-auth.ts) — the core reload bug

tauriAuthStorage in src/agentkit/server/frontend/src/api/tauri-auth.ts had a fatal fallback defect. Because the Tauri Rust shell does not register the keychain commands, tauriInvoke for those commands returns undefined without throwing. The old logic assumed "if invoke doesn't throw, it succeeded" — that assumption was wrong.

Before — the broken fallback:

async setRefreshToken(token: string): Promise<void> {
  if (isTauri()) {
    try {
      await tauriInvoke<void>('store_refresh_token', { token })
      return  // ← BUG: if invoke doesn't throw (returns undefined), returns WITHOUT writing localStorage
    } catch (e) { ... }
  }
  localSet(token)  // ← only runs if isTauri()=false OR invoke threw
}

async getRefreshToken(): Promise<string | null> {
  if (isTauri()) {
    try {
      const value = await tauriInvoke<string | null>('load_refresh_token')
      return value ?? null  // ← BUG: if invoke returns undefined, returns null WITHOUT reading localStorage
    } catch (e) { ... }
  }
  return localGet()  // ← only runs if isTauri()=false OR invoke threw
}

When the keychain commands are unregistered, setRefreshToken returned early without writing localStorage, and getRefreshToken returned null without reading localStorage. On reload, startupCheck() got null, never called /auth/whoami, and the router redirected to /login.

After — always write localStorage first (durable backup), then attempt keychain (best-effort):

async setRefreshToken(token: string): Promise<void> {
  localSet(token)  // ← ALWAYS write localStorage first
  if (isTauri()) {
    try {
      await tauriInvoke<void>('store_refresh_token', { token })
    } catch (e) {
      console.warn('[auth] Keychain write failed, localStorage backup used', e)
    }
  }
}

async getRefreshToken(): Promise<string | null> {
  if (isTauri()) {
    try {
      const value = await tauriInvoke<string | null>('load_refresh_token')
      if (value) return value  // ← only return if truthy; otherwise fallback
    } catch (e) {
      console.warn('[auth] Keychain read failed, falling back to localStorage', e)
    }
  }
  return localGet()  // ← fallback when keychain fails OR returns empty
}

async clearRefreshToken(): Promise<void> {
  localRemove()  // ← ALWAYS clear localStorage
  if (isTauri()) {
    try {
      await tauriInvoke<void>('clear_refresh_token')
    } catch (e) {
      console.warn('[auth] Keychain clear failed, localStorage already cleared', e)
    }
  }
}

Why This Works

The reload→/login chain was driven by the keychain fallback flaw, not by the 401. Tracing the failure path:

  1. On login, setRefreshToken(token) is called. Because the keychain commands are unregistered in the Rust shell, tauriInvoke('store_refresh_token', ...) returns undefined without throwing.
  2. The old setRefreshToken treated "did not throw" as "succeeded" and returned — localStorage was never written.
  3. On reload, startupCheck() calls getRefreshToken(). tauriInvoke('load_refresh_token') again returns undefined without throwing.
  4. The old getRefreshToken returned value ?? nullnull, never falling through to localGet() (which would also have returned null anyway, because nothing was written).
  5. startupCheck() sees null, skips the /auth/whoami call, leaves isAuthenticated=false.
  6. The router guard in router.beforeEach redirects to /login.

The critical behavioral assumption — "an unregistered Tauri command throws" — was wrong. Tauri's tauriInvoke may resolve to undefined for unregistered commands in some configurations; treating "no throw" as "succeeded" is unsafe for any best-effort native bridge.

The "always write localStorage first" pattern is robust because:

  • localStorage is the durable source of truth. It is always available in the WebView, has no permission prompts, and has no native-bridge layer that can silently no-op.
  • The keychain becomes a best-effort upgrade, not a load-bearing dependency. If it works, the token is stored more securely; if it fails (unregistered command, OS keyring locked, permission denied, native module missing), the localStorage copy still preserves the session.
  • getRefreshToken falls back to localStorage whenever the keychain returns a falsy value OR throws. The if (value) return value guard treats undefined/null/"" as failure, which is the correct contract for an unregistered-or-broken command.
  • The same pattern applies to clearRefreshToken — always clear localStorage so a broken keychain does not leave stale tokens behind.

The 401 fix (root cause 2) and the CORS fix (root cause 1) are independent hygiene improvements: they remove noise from the failure path so the real cause (no whoami request after reload) is observable in backend logs. But neither, by itself, would have stopped reload→/login.

Prevention

  • Write the durable backup FIRST, before the best-effort premium store. Any storage with a native-bridge dependency (Keychain, Keystore, OS credential vault) must be treated as an upgrade over a durable web-storage fallback — never as the only copy.
  • Treat undefined from tauriInvoke as failure. When a command may be unregistered, "did not throw" is not "succeeded." Use a truthy check on the return value (if (value) return value) and fall through to the fallback otherwise.
  • For auth state, verify the full reload path with backend log inspection. The UI can lie (cached tokens, in-memory state). After a reload, the only trustworthy signal is whether /api/v1/auth/whoami was actually called and what it returned. Absence of the request means the token never left the client.
  • Add a unit test that mocks tauriInvoke returning undefined and asserts: (a) setRefreshToken still writes localStorage, (b) getRefreshToken returns the localStorage value, (c) clearRefreshToken still removes the localStorage key. This is the smallest check that fails if the fallback logic regresses.
  • Route all authenticated fetches through ApiClient. Any raw fetch('/api/v1/...') is a defect waiting to happen — the BaseApiClient token provider is the single point that attaches the Authorization header.
  • In dev, prefer same-origin requests via the Vite proxy over direct backend URLs. initApiBaseURL should be a no-op in dev; the proxy handles same-origin translation and avoids CORS entirely.
  • Sidecar missing serve subcommand — Tauri production launch relies on the sidecar binary exposing a serve mode; this is unrelated to the reload bug but was discovered during investigation.
  • Keychain Rust commands not registered — src-tauri/src/lib.rs registers only 4 sidecar commands; store_refresh_token / load_refresh_token / clear_refresh_token are not wired up (no auth.rs exists). The localStorage-always-backup fix makes this non-blocking, but registering them (with keyring crate) would restore the intended secure-storage path.
  • Chat handler not passing user_id — observed during log inspection but not addressed by this fix; tracked separately.
  • Related doc: docs/solutions/integration-issues/jwt-secret-dev-mode-user-id-mismatch.md — same symptom (reload→/login) in the web/JWT-secret context. That doc's reload fix is scoped to the JWT-secret cause; this doc covers the Tauri-specific frontend causes.