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

205 lines
13 KiB
Markdown

---
title: "Tauri reload redirects to login (session lost)"
date: 2026-06-29
category: docs/solutions/ui-bugs/
module: frontend/auth
problem_type: ui_bug
component: authentication
symptoms:
- "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"
root_cause: logic_error
resolution_type: code_fix
severity: high
tags: [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 `/api``http://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:**
```ts
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`:**
```ts
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:**
```ts
async function fetchModels() {
const res = await fetch(url) // no Authorization header
// ...
}
```
**After — add `listModels()` to `ApiClient` and use it:**
```ts
// 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:**
```ts
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):**
```ts
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 `return`ed — **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 ?? null``null`, **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.
## Related Issues
- 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.