fix: switch from httpOnly cookies to localStorage Bearer token auth
Some checks failed
Build & Release / build (push) Has been cancelled

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.
This commit is contained in:
Ryan Moon
2026-04-03 18:09:23 -05:00
parent 74df8e8cb0
commit 38341aa0a9
3 changed files with 30 additions and 25 deletions

View File

@@ -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 }),

View File

@@ -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" });
}
});

View File

@@ -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 };
});