commit 11245744b3f9d2e2a869e1fdc197679e3989bf4e Author: Coupon Carl Date: Sat Mar 28 04:46:10 2026 +0000 feat: migrate authentication to Better-Auth (Phase 1) Replace hand-rolled JWT auth with Better-Auth session-based authentication. - Scaffold auth/ Node.js service with Better-Auth, bcrypt password compat, Postgres adapter mapped to existing users table - Add Alembic migration (002) creating sessions, accounts, verifications tables and migrating password hashes to accounts table - Update FastAPI auth dependency to validate sessions via shared DB (supports both cookie and Bearer token) - Remove registration/login/refresh endpoints from API gateway (now handled by Better-Auth service) - Update frontend to use better-auth/react client with httpOnly cookies (no tokens in localStorage or memory) - Rewrite auth store, Login, Register, Dashboard, Settings, ProtectedRoute to use session-based auth - Update all tests to create sessions directly in DB instead of JWT tokens Resolves CAR-27 See plan: CAR-26#document-plan Co-Authored-By: Paperclip diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6e16447 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Required: Generate with `openssl rand -base64 32` +BETTER_AUTH_SECRET=change-me-in-production-min-32-chars!! + +# Base URL of the auth service +BETTER_AUTH_URL=http://localhost:3001 + +# Shared PostgreSQL database +DATABASE_URL=postgresql://cartsnitch:cartsnitch@localhost:5432/cartsnitch + +# Port the auth service listens on +PORT=3001 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1028e89 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM node:22-alpine AS builder +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci +COPY tsconfig.json ./ +COPY src/ src/ +RUN npm run build + +FROM node:22-alpine +WORKDIR /app +ENV NODE_ENV=production +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev +COPY --from=builder /app/dist/ dist/ +USER 101 +EXPOSE 3001 +CMD ["node", "dist/index.js"] diff --git a/package.json b/package.json new file mode 100644 index 0000000..0071e27 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "@cartsnitch/auth", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "generate": "npx @better-auth/cli generate" + }, + "dependencies": { + "better-auth": "^1.2.0", + "pg": "^8.13.0", + "bcrypt": "^5.1.1" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/pg": "^8.11.0", + "@types/bcrypt": "^5.0.2", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } +} diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..33a0e05 --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,85 @@ +import { betterAuth } from "better-auth"; +import bcrypt from "bcrypt"; +import pg from "pg"; + +const { Pool } = pg; + +const pool = new Pool({ + connectionString: + process.env.DATABASE_URL ?? + "postgresql://cartsnitch:cartsnitch@localhost:5432/cartsnitch", +}); + +export const auth = betterAuth({ + database: pool, + basePath: "/auth", + secret: process.env.BETTER_AUTH_SECRET ?? "change-me-in-production-min-32-chars!!", + baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3001", + + emailAndPassword: { + enabled: true, + minPasswordLength: 8, + maxPasswordLength: 128, + password: { + hash: async (password: string) => { + return bcrypt.hash(password, 10); + }, + verify: async (data: { hash: string; password: string }) => { + return bcrypt.compare(data.password, data.hash); + }, + }, + }, + + session: { + expiresIn: 60 * 60 * 24 * 7, // 7 days + updateAge: 60 * 60 * 24, // refresh after 1 day + cookieCache: { + enabled: true, + maxAge: 5 * 60, // 5-minute cookie cache + }, + }, + + user: { + modelName: "users", + fields: { + name: "display_name", + emailVerified: "email_verified", + image: "image", + createdAt: "created_at", + updatedAt: "updated_at", + }, + }, + + account: { + modelName: "accounts", + fields: { + userId: "user_id", + accountId: "account_id", + providerId: "provider_id", + accessToken: "access_token", + refreshToken: "refresh_token", + accessTokenExpiresAt: "access_token_expires_at", + refreshTokenExpiresAt: "refresh_token_expires_at", + idToken: "id_token", + createdAt: "created_at", + updatedAt: "updated_at", + }, + }, + + verification: { + modelName: "verifications", + fields: { + expiresAt: "expires_at", + createdAt: "created_at", + updatedAt: "updated_at", + }, + }, + + trustedOrigins: [ + "http://localhost:3000", + "http://localhost:5173", + "https://cartsnitch.com", + "https://cartsnitch.farh.net", + "https://cartsnitch.dev.farh.net", + ], +}); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..843a97c --- /dev/null +++ b/src/index.ts @@ -0,0 +1,23 @@ +import { createServer } from "node:http"; +import { toNodeHandler } from "better-auth/node"; +import { auth } from "./auth.js"; + +const port = parseInt(process.env.PORT ?? "3001", 10); + +const handler = toNodeHandler(auth); + +const server = createServer(async (req, res) => { + // Health check + if (req.url === "/health" && req.method === "GET") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ status: "ok" })); + return; + } + + // All /auth/* routes handled by Better-Auth + await handler(req, res); +}); + +server.listen(port, "0.0.0.0", () => { + console.log(`CartSnitch auth service listening on port ${port}`); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..764b72a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "resolveJsonModule": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +}