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 |
|
logic_error | code_fix | high |
|
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/whoamirequest, proving the refresh token never reached the rehydrate step. - Dev console showed 401 responses on
/api/v1/llm/modelswith missing CORS headers (browser blocked the cross-origin response body). ChatInput.vue'sfetchModels()fired on a rawfetch()with noAuthorizationheader, 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 noauth.rsfile existed.
What Didn't Work
- Fixing the
/llm/models401 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/whoamirequest in backend logs after reload — was already available and pointed directly atgetRefreshToken()returningnull. Switching to static code analysis oftauri-auth.tsexposed the fallback logic flaw without a runtime round-trip. - Registering the keychain Rust commands (
auth.rs+keyringcrate) 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:
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:
- On login,
setRefreshToken(token)is called. Because the keychain commands are unregistered in the Rust shell,tauriInvoke('store_refresh_token', ...)returnsundefinedwithout throwing. - The old
setRefreshTokentreated "did not throw" as "succeeded" andreturned — localStorage was never written. - On reload,
startupCheck()callsgetRefreshToken().tauriInvoke('load_refresh_token')again returnsundefinedwithout throwing. - The old
getRefreshTokenreturnedvalue ?? null→null, never falling through tolocalGet()(which would also have returnednullanyway, because nothing was written). startupCheck()seesnull, skips the/auth/whoamicall, leavesisAuthenticated=false.- The router guard in
router.beforeEachredirects 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.
getRefreshTokenfalls back to localStorage whenever the keychain returns a falsy value OR throws. Theif (value) return valueguard treatsundefined/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
undefinedfromtauriInvokeas 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/whoamiwas actually called and what it returned. Absence of the request means the token never left the client. - Add a unit test that mocks
tauriInvokereturningundefinedand asserts: (a)setRefreshTokenstill writes localStorage, (b)getRefreshTokenreturns the localStorage value, (c)clearRefreshTokenstill removes the localStorage key. This is the smallest check that fails if the fallback logic regresses. - Route all authenticated fetches through
ApiClient. Any rawfetch('/api/v1/...')is a defect waiting to happen — theBaseApiClienttoken provider is the single point that attaches theAuthorizationheader. - In dev, prefer same-origin requests via the Vite proxy over direct backend URLs.
initApiBaseURLshould be a no-op in dev; the proxy handles same-origin translation and avoids CORS entirely.
Related Issues
- Sidecar missing
servesubcommand — Tauri production launch relies on the sidecar binary exposing aservemode; this is unrelated to the reload bug but was discovered during investigation. - Keychain Rust commands not registered —
src-tauri/src/lib.rsregisters only 4 sidecar commands;store_refresh_token/load_refresh_token/clear_refresh_tokenare not wired up (noauth.rsexists). The localStorage-always-backup fix makes this non-blocking, but registering them (withkeyringcrate) 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.