fix: switch from httpOnly cookies to localStorage Bearer token auth
Some checks failed
Build & Release / build (push) Has been cancelled
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:
@@ -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 }),
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user