From d95e9215e868971042ebb98cf1d6860da213f693 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 18 Feb 2026 12:44:31 +0000 Subject: [PATCH] feat: add cookie-based auth utilities for browser user flows --- src/lib/auth.js | 60 +++++++++++++++++++++++++++++++++++++++++++++++ test/auth.test.js | 48 +++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 src/lib/auth.js create mode 100644 test/auth.test.js diff --git a/src/lib/auth.js b/src/lib/auth.js new file mode 100644 index 0000000..c465ebc --- /dev/null +++ b/src/lib/auth.js @@ -0,0 +1,60 @@ +"use strict"; + +const COOKIE_NAME = "xartaudio_user"; + +function parseCookies(cookieHeader) { + if (!cookieHeader) { + return {}; + } + + return String(cookieHeader) + .split(";") + .map((part) => part.trim()) + .filter(Boolean) + .reduce((acc, pair) => { + const eq = pair.indexOf("="); + if (eq <= 0) { + return acc; + } + const key = pair.slice(0, eq).trim(); + const value = pair.slice(eq + 1).trim(); + acc[key] = decodeURIComponent(value); + return acc; + }, {}); +} + +function serializeUserCookie(userId, maxAgeSeconds) { + if (!userId) { + throw new Error("user_id_required"); + } + + const encoded = encodeURIComponent(String(userId)); + const maxAge = Number.isInteger(maxAgeSeconds) && maxAgeSeconds > 0 + ? maxAgeSeconds + : 60 * 60 * 24 * 30; + + return `${COOKIE_NAME}=${encoded}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`; +} + +function clearUserCookie() { + return `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`; +} + +function getAuthenticatedUserId(headers) { + const safeHeaders = headers || {}; + + if (safeHeaders["x-user-id"]) { + return safeHeaders["x-user-id"]; + } + + const cookies = parseCookies(safeHeaders.cookie || ""); + return cookies[COOKIE_NAME] || null; +} + +module.exports = { + COOKIE_NAME, + parseCookies, + serializeUserCookie, + clearUserCookie, + getAuthenticatedUserId, +}; diff --git a/test/auth.test.js b/test/auth.test.js new file mode 100644 index 0000000..90ca03c --- /dev/null +++ b/test/auth.test.js @@ -0,0 +1,48 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { + COOKIE_NAME, + parseCookies, + serializeUserCookie, + clearUserCookie, + getAuthenticatedUserId, +} = require("../src/lib/auth"); + +test("parseCookies handles multiple cookies", () => { + const cookies = parseCookies("a=1; xartaudio_user=user-1; b=hello%20world"); + assert.equal(cookies.a, "1"); + assert.equal(cookies.xartaudio_user, "user-1"); + assert.equal(cookies.b, "hello world"); +}); + +test("serializeUserCookie builds secure-ish cookie string", () => { + const cookie = serializeUserCookie("user-1", 120); + assert.match(cookie, new RegExp(`^${COOKIE_NAME}=user-1;`)); + assert.match(cookie, /HttpOnly/); + assert.match(cookie, /SameSite=Lax/); + assert.match(cookie, /Max-Age=120/); +}); + +test("clearUserCookie expires session cookie", () => { + const cookie = clearUserCookie(); + assert.match(cookie, /Max-Age=0/); +}); + +test("getAuthenticatedUserId prefers x-user-id header", () => { + const userId = getAuthenticatedUserId({ + "x-user-id": "header-user", + cookie: "xartaudio_user=cookie-user", + }); + + assert.equal(userId, "header-user"); +}); + +test("getAuthenticatedUserId falls back to cookie", () => { + const userId = getAuthenticatedUserId({ + cookie: "xartaudio_user=cookie-user", + }); + + assert.equal(userId, "cookie-user"); +});