86 lines
12 KiB
Plaintext
86 lines
12 KiB
Plaintext
<!DOCTYPE html>
|
|
<html lang="en"><head>
|
|
<meta charset="UTF-8">
|
|
<title>New Page</title>
|
|
<style>
|
|
@import url('https://styles.rokojori.com/get-font?family=Barlow&weights=100,400,700,900');
|
|
|
|
[data-theme="default-roject"] {
|
|
background: #0f1117;
|
|
color: #c8cce0;
|
|
font-family: 'Barlow', sans-serif;
|
|
font-size: 1rem;
|
|
line-height: 1.7;
|
|
}
|
|
|
|
[data-theme="default-roject"] h1 {
|
|
color: #7c8cff;
|
|
font-size: 2.5rem;
|
|
font-weight: 900;
|
|
font-style: italic;
|
|
text-transform: uppercase;
|
|
margin: 1.5rem 0 0.75rem;
|
|
}
|
|
|
|
[data-theme="default-roject"] h2 {
|
|
color: #7c8cff;
|
|
font-size: 1.6rem;
|
|
font-weight: 700;
|
|
margin: 1.25rem 0 0.5rem;
|
|
}
|
|
|
|
[data-theme="default-roject"] h3 {
|
|
color: #9ba4c7;
|
|
font-size: 1.2rem;
|
|
font-weight: 700;
|
|
margin: 1rem 0 0.4rem;
|
|
}
|
|
|
|
[data-theme="default-roject"] p {
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
|
|
[data-theme="default-roject"] b,
|
|
[data-theme="default-roject"] strong {
|
|
color: #e2e4ed;
|
|
font-weight: 700;
|
|
}
|
|
|
|
[data-theme="default-roject"] marked-text {
|
|
font-weight: 700;
|
|
color: hsl(190, 80%, 90%);
|
|
}
|
|
|
|
[data-theme="default-roject"] a {
|
|
color: hsl(200, 80%, 70%);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body data-theme="default-roject">
|
|
<page-header></page-header>
|
|
|
|
<page-root data-theme="default-roject">
|
|
<page-block class="pep-block-full">
|
|
<page-area contenteditable="true" style="outline: none;"><h1>Auth Update</h1><div>Fixing session refresh for good: two concrete bugs found, plus a central token-lifecycle architecture to replace ad-hoc per-call refresh handling.</div></page-area>
|
|
</page-block>
|
|
<page-block class="pep-block-two-col">
|
|
<page-area contenteditable="true" style="outline: none;"><h2>Update Loop & Activity Analyzer</h2><div>Token refreshing moves out of individual API calls and into one central unit that runs periodically and on user activity (via <code>ActivityAnalyser</code>), using server-authoritative time to decide when a refresh is actually due — not the browser's local clock.</div></page-area>
|
|
<page-area contenteditable="true" style="outline: none;"><h2>Call Refactoring</h2><div>API calls stop managing auth and retries themselves. A central guarded-call function checks whether it's safe to call before firing, and applies a configurable retry policy depending on whether the call was user-triggered, a normal editor action, or a silent background action.</div></page-area>
|
|
</page-block><page-block class="pep-block-two-col">
|
|
<page-area contenteditable="true" style="outline: none;"><h2>Root Cause: Two Separate Bugs</h2><div>Investigation found two distinct causes behind “auth fails after a while”, not one. <b>Browser:</b> refresh tokens are single-use — <code>rokojori-auth</code>'s <code>/api/auth/refresh</code> deletes the used token then issues a new pair (<code>routes/auth.ts</code> → <code>issueTokenPair</code>). When Roject's editor fires several parallel API calls right as the access token expires, each one independently triggers <code>jwtMiddleware</code>'s silent refresh (<code>auth-connector/source/server/auth.ts</code> → <code>tryRefresh</code>) using the same refresh-token cookie; the first call wins and rotates it, every other call already in flight gets a 401 against the now-deleted token and force-redirects to login even though the session is fine. <b>Electron:</b> <code>electron/main.ts</code> only ever refreshes once, in the startup <code>setTimeout</code> block. Nothing refreshes the Bearer token again for the rest of the running session, so once <code>ACCESS_TOKEN_TTL</code> (1h default) elapses, every subsequent request silently sends an expired token until the app is restarted.</div></page-area>
|
|
<page-area contenteditable="true" style="outline: none;"><h2>Central Token Updater <span style="opacity:0.6">(done, browser)</span></h2><div>Built as <code>source/auth/TokenUpdater.ts</code>: a periodic timer every <b>5 minutes</b> plus an immediate check on <code>ActivityAnalyser.onActive</code> (covering tab/window resume), each ping hitting the cheap <code>GET /api/auth/me</code>. Turned out the client-side exp/server-time-offset comparison originally planned here doesn't apply to the browser flow at all — the access token cookie is <code>httpOnly</code>, so client JS can never read its <code>exp</code> in the first place. The proactive decision moved server-side instead: <code>jwtMiddleware</code> (<code>auth-connector/source/server/auth.ts</code>) now rotates the cookie once the token is within <code>PROACTIVE_REFRESH_MARGIN_SEC</code> (15 min default) of its real, server-signed expiry — using the server's own clock, trivially authoritative, no offset math needed. The Updater's only job on the browser side is making sure a request happens often enough for the server to act on; it exposes an <code>EventSlot</code>-driven <code>valid | refreshing | expired | network-error</code> state that <code>editor-shell</code> currently reacts to directly (redirect to login on <code>expired</code>, <code>console.warn</code> on <code>network-error</code>) until Phase 3 routes this through the shared guarded-call wrapper instead. The exp/server-time-offset comparison as originally described still applies as designed — to <b>Electron</b> (Phase 5/7), where the token is a Bearer value actually held in the main process, not hidden behind a cookie. No reactive retry-on-401 fallback — deliberately skipped, tracked as a Nice To Have in the backlog if the 5-minute margin ever proves too wide.</div></page-area>
|
|
</page-block><page-block class="pep-block-two-col">
|
|
<page-area contenteditable="true" style="outline: none;"><h2>Multi-Session Coordination</h2><div><code>rokojori-auth</code> already supports multiple parallel sessions — every login creates an independent refresh-token row (<code>refreshTokens.create</code>) without invalidating others. The problem is that within one session, several writers can share the same refresh token and race each other. <b>Browser tabs</b> in the same profile share one cookie jar, so they're literally the same session; fix via leader election with the <b>Web Locks API</b> — confirmed as the approach — one tab holds an exclusive lock and runs the updater, others follow via <code>BroadcastChannel</code>. <b>Electron</b> deliberately will <i>not</i> get a single-instance lock — multiple projects need to run in parallel, each as its own instance, and each mints its own fully independent session (no shared <code>tokens.json</code>, nothing to race over). To keep this transparent instead of showing a login screen per instance: every running instance writes its current access token plus a timestamp to a shared heartbeat file every 10s (piggybacking on the updater's tick). A newly-starting instance checks that file on launch — if it was written within the last ~30s, it POSTs that token to a new <code>requireAuth</code>-guarded endpoint, <code>/api/auth/new-session</code>, which mints a brand-new independent token pair for the same user via <code>issueTokenPair</code> (the same call <code>/login</code> already uses, just triggered by an existing valid access token instead of a password). The new instance now owns its own refresh token from the start — never shared, never racing. If the heartbeat is stale or the mint call fails, it falls through to the normal login screen. This also replaces the plaintext-password auto-login currently in <code>main.ts</code> (<code>saveLastPassword</code>/<code>loadLastPassword</code>) with something safer — proof of a live session instead of a stored secret.</div></page-area>
|
|
<page-area contenteditable="true" style="outline: none;"><h2>Server-Side Refresh Tolerance</h2><div>Client-side coordination can't reach across process/app/device boundaries — a browser tab, an Electron instance, and a second device can all share the same refresh-token record with no way to elect one leader across them. The real fix has to live on the server: give a just-rotated refresh token a short grace window instead of deleting it immediately in <code>rokojori-auth/routes/auth.ts</code>, so a near-simultaneous second refresh call still succeeds instead of hard-401ing. This is the baseline correctness guarantee; client-side leader election (Web Locks) is only an optimization on top to reduce how often that grace window gets exercised.</div></page-area>
|
|
</page-block><page-block class="pep-block-two-col">
|
|
<page-area contenteditable="true" style="outline: none;"><h2>Guarded Calls & Retry Policy</h2><div>A single wrapper function classifies every call by retry tier: <b>user</b> actions (save, delete) never auto-retry — the user gets a warning and repeats manually; <b>editor</b> actions (autosave, sync) retry with backoff then surface failure; <b>silent</b> actions (layout persistence, telemetry) retry-or-not with no UI, but always log to the console as a <code>console.warn</code> so failures stay debuggable instead of vanishing silently. None of the three tiers retry against a confirmed-dead session (<code>expired</code> state) — only against transient <code>refreshing</code>/<code>network-error</code> states. Calls should also check the shared auth/network state proactively before firing, not just react to a failed response.</div></page-area>
|
|
<page-area contenteditable="true" style="outline: none;"><h2>Phases</h2><div><b>1.</b> Add a short grace window to refresh-token rotation in <code>rokojori-auth</code> so concurrent refresh calls stop hard-failing — fixes the browser race outright, independent of any client changes. <b>2.</b> Build the central Token Updater (periodic loop + <code>ActivityAnalyser.onActive</code> + server-time offset), replacing <code>jwtMiddleware</code>'s silent per-request refresh and <code>electron/main.ts</code>'s one-shot startup refresh. <b>3.</b> Refactor <code>apiFetch</code> and Electron's request path into the shared guarded-call function with the three-tier retry policy. <b>4.</b> Add Web Locks-based leader election across browser tabs, with <code>BroadcastChannel</code> token sharing. <b>5.</b> Move the Electron updater into the main process, no single-instance lock — each project runs as its own instance with its own independent session. <b>6.</b> Add <code>POST /api/auth/new-session</code> to <code>rokojori-auth</code> (mints a fresh token pair from an existing valid access token). <b>7.</b> Electron: write a heartbeat file (current access token + timestamp) every 10s from the updater tick; on startup, if the heartbeat is ≤ 30s old, silently mint a new session from it instead of showing the login screen; retire the plaintext <code>last-password.txt</code> auto-login in favour of this.</div></page-area>
|
|
</page-block><page-block class="pep-block-two-col">
|
|
<page-area contenteditable="true" style="outline: none;"><h2>Technical Details</h2><div>Server time offset: read the <code>Date</code> response header once, diff against local <code>Date.now()</code>, cache the offset, use <code>localNow + offset</code> for all expiry comparisons — ties directly into the existing clock-skew task instead of duplicating it. <code>ActivityAnalyser</code> (<code>library-ts/browser/dom/ActivityAnalyser.ts</code>) needs an <code>OnVisibilityChange</code> listener added alongside its existing focus/blur/mouse/touch set, since switching tabs within one window doesn't fire window focus/blur at all. Shared auth state should be an <code>EventSlot</code>-driven enum (<code>valid | refreshing | expired | network-error</code>) that both the updater and the guarded-call wrapper read and write, following existing project convention — no ad-hoc event buses.</div></page-area>
|
|
<page-area contenteditable="true" style="outline: none;"><h2>Open Questions</h2><div>All resolved. Proactive refresh runs every 5 minutes; Web Locks API confirmed for browser tab leader election; no reactive retry-on-401 (tracked as a Nice To Have in the backlog instead); silent-tier failures log via <code>console.warn</code>; Electron runs multiple concurrent instances with no single-instance lock, each minting its own independent session transparently via the heartbeat + <code>/api/auth/new-session</code> mechanism described under Multi-Session Coordination. One deliberately deferred hardening note for later: the heartbeat bootstrap currently reuses the general-purpose access token rather than a narrow-scope, short-lived bootstrap token — acceptable for now since it's no weaker than the existing on-disk <code>tokens.json</code>, but worth revisiting if the security surface needs tightening later.</div></page-area>
|
|
</page-block></page-root>
|
|
|
|
<page-footer></page-footer>
|
|
|
|
</body></html>
|