From 38341aa0a97448432b551bfa17f5caf41df4294c Mon Sep 17 00:00:00 2001 From: Ryan Moon Date: Fri, 3 Apr 2026 18:09:23 -0500 Subject: [PATCH] fix: switch from httpOnly cookies to localStorage Bearer token auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cookie-based auth was unreliable through Cloudflare/nginx proxy — cookie was being sent for some requests but not others. Switch to returning JWT in login response, storing in localStorage, and sending as Authorization Bearer header on all API calls. Eliminates all cookie/SameSite/Secure proxy issues. --- frontend/index.html | 32 +++++++++++++++++++++++++------- src/index.ts | 9 +++------ src/routes/auth.ts | 14 ++------------ 3 files changed, 30 insertions(+), 25 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 1a8138c..b1114b8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -453,16 +453,33 @@ let searchTimer = null; let pendingDeleteSlug = null; + function authHeaders() { + const token = localStorage.getItem('token'); + return token ? { 'Authorization': 'Bearer ' + token } : {}; + } + + function apiFetch(url, options = {}) { + return fetch(url, { + ...options, + headers: { ...authHeaders(), ...(options.headers || {}) }, + }); + } + // ── Auth ────────────────────────────────────────────────────────────────── async function checkAuth() { - const res = await fetch('/api/auth/me'); + if (!localStorage.getItem('token')) { + document.getElementById('login-view').style.display = 'flex'; + return; + } + const res = await apiFetch('/api/auth/me'); if (res.ok) { const data = await res.json(); setUser(data.username); showPage('customers'); loadCustomers(); } else { + localStorage.removeItem('token'); document.getElementById('login-view').style.display = 'flex'; } } @@ -487,6 +504,7 @@ }); const data = await res.json(); if (!res.ok) throw new Error(data.message ?? 'Login failed'); + localStorage.setItem('token', data.token); setUser(data.username); document.getElementById('login-view').style.display = 'none'; showPage('customers'); @@ -498,7 +516,7 @@ } async function logout() { - await fetch('/api/auth/logout', { method: 'POST' }); + localStorage.removeItem('token'); document.getElementById('app-view').style.display = 'none'; document.getElementById('login-view').style.display = 'flex'; document.getElementById('login-password').value = ''; @@ -525,7 +543,7 @@ const s = tableState; const params = new URLSearchParams({ page: s.page, limit: s.limit, sort: s.sort, order: s.order }); if (s.q) params.set('q', s.q); - const res = await fetch('/api/customers?' + params); + const res = await apiFetch('/api/customers?' + params); if (!res.ok) return; const { data, pagination } = await res.json(); tableState.total = pagination.total; @@ -642,7 +660,7 @@ btn.disabled = true; btn.textContent = 'Deleting…'; try { - const res = await fetch(`/api/customers/${slug}`, { method: 'DELETE' }); + const res = await apiFetch(`/api/customers/${slug}`, { method: 'DELETE' }); if (!res.ok) { const d = await res.json().catch(() => ({})); throw new Error(d.message ?? 'Delete failed'); @@ -659,7 +677,7 @@ async function removeRecord(slug) { if (!confirm(`Remove "${slug}" from the manager database only?\n\nThis will NOT delete the ArgoCD app or DO database — use this only for failed partial deployments.`)) return; - const res = await fetch(`/api/customers/${slug}/record`, { method: 'DELETE' }); + const res = await apiFetch(`/api/customers/${slug}/record`, { method: 'DELETE' }); if (res.ok) { loadCustomers(); } else { @@ -689,7 +707,7 @@ btn.textContent = 'Provisioning…'; status.className = 'status'; try { - const res = await fetch('/api/customers', { + const res = await apiFetch('/api/customers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ slug, name, modules, startDate, expirationDate }), @@ -724,7 +742,7 @@ return; } try { - const res = await fetch('/api/auth/password', { + const res = await apiFetch('/api/auth/password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }), diff --git a/src/index.ts b/src/index.ts index 9bd2e25..b604985 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,16 +17,13 @@ import { customerRoutes } from "./routes/customers"; const app = Fastify({ logger: true }); await app.register(cookiePlugin); -await app.register(jwtPlugin, { - secret: config.jwtSecret, - cookie: { cookieName: "token", signed: false }, -}); +await app.register(jwtPlugin, { secret: config.jwtSecret }); app.decorate("authenticate", async function (req: any, reply: any) { try { - await req.jwtVerify({ onlyCookie: true }); + await req.jwtVerify(); } catch { - reply.status(401).send({ message: "Unauthorized" }); + return reply.status(401).send({ message: "Unauthorized" }); } }); diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 70b2fbe..c5020b3 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -47,20 +47,10 @@ export async function authRoutes(app: FastifyInstance) { } const token = app.jwt.sign({ sub: user.id, username: user.username }, { expiresIn: "7d" }); - - reply.setCookie("token", token, { - httpOnly: true, - secure: true, - sameSite: "lax", - path: "/", - maxAge: 60 * 60 * 24 * 7, - }); - - return { username: user.username }; + return { username: user.username, token }; }); - app.post("/auth/logout", async (_req, reply) => { - reply.clearCookie("token", { path: "/" }); + app.post("/auth/logout", async () => { return { ok: true }; });