chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
import "dotenv/config";
|
||||
import jsonServer from "json-server";
|
||||
import cors from "cors";
|
||||
import cookieParser from 'cookie-parser';
|
||||
import jwt from "jsonwebtoken";
|
||||
import { nanoid } from "nanoid";
|
||||
import { Low } from "lowdb";
|
||||
import { JSONFile } from "lowdb/node";
|
||||
|
||||
// -------------------- Env & Defaults --------------------
|
||||
const {
|
||||
JWT_SECRET = process.env.JWT_SECRET,
|
||||
ACCESS_TTL_SEC = process.env.ACCESS_TTL_SEC, // 15 minutes
|
||||
REFRESH_TTL_SEC = process.env.REFRESH_TTL_SEC, // 7 days
|
||||
CLIENT_ORIGIN = process.env.CLIENT_ORIGIN,
|
||||
PORT = "4001", // match your NEXT_PUBLIC_API_URL host:port
|
||||
} = process.env;
|
||||
|
||||
const ACCESS_TTL = parseInt(ACCESS_TTL_SEC, 10);
|
||||
const REFRESH_TTL = parseInt(REFRESH_TTL_SEC, 10);
|
||||
|
||||
// -------------------- App & DB Setup --------------------
|
||||
const app = jsonServer.create();
|
||||
const router = jsonServer.router("db.json");
|
||||
const middlewares = jsonServer.defaults({ noCors: true });
|
||||
|
||||
const defaultData = {
|
||||
users: [],
|
||||
revoked_tokens: [],
|
||||
password_resets: [],
|
||||
};
|
||||
|
||||
const adapter = new JSONFile("db.json");
|
||||
const db = new Low(adapter, defaultData);
|
||||
await db.read();
|
||||
db.data.users ??= [];
|
||||
db.data.revoked_tokens ??= [];
|
||||
db.data.password_resets ??= [];
|
||||
|
||||
// -------------------- CORS (with credentials) --------------------
|
||||
const corsOptions = {
|
||||
origin: CLIENT_ORIGIN, // must be explicit when using credentials
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization"],
|
||||
};
|
||||
app.use(cors(corsOptions));
|
||||
app.use(
|
||||
cors({
|
||||
origin: CLIENT_ORIGIN, // must be the exact origin when using credentials
|
||||
credentials: true,
|
||||
methods: ['GET','POST','PUT','PATCH','DELETE','OPTIONS'],
|
||||
allowedHeaders: ['Content-Type','Authorization'],
|
||||
})
|
||||
);
|
||||
app.options('*', cors({ origin: CLIENT_ORIGIN, credentials: true }));
|
||||
app.use(cookieParser());
|
||||
|
||||
// -------------------- Middleware --------------------
|
||||
app.use(jsonServer.bodyParser);
|
||||
app.use(middlewares);
|
||||
|
||||
// Optional request log to debug routes
|
||||
app.use((req, _res, next) => {
|
||||
console.log(req.method, req.url);
|
||||
next();
|
||||
});
|
||||
|
||||
// -------------------- Helpers --------------------
|
||||
function signAccessToken(user) {
|
||||
return jwt.sign(
|
||||
{ sub: user.id, email: user.email, typ: "access" },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: ACCESS_TTL }
|
||||
);
|
||||
}
|
||||
|
||||
function signRefreshToken(user) {
|
||||
return jwt.sign(
|
||||
{ sub: user.id, email: user.email, typ: "refresh", jti: nanoid() },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: REFRESH_TTL }
|
||||
);
|
||||
}
|
||||
|
||||
function setRefreshCookie(res, token, maxAgeMs) {
|
||||
res.cookie('refresh_token', token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: false, // true in production if using HTTPS
|
||||
maxAge: maxAgeMs,
|
||||
path: '/', // adjust if needed
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeUser(u) {
|
||||
const { password, ...safe } = u;
|
||||
return safe;
|
||||
}
|
||||
|
||||
function authz(req) {
|
||||
const h = req.headers.authorization || "";
|
||||
const token = h.startsWith("Bearer ") ? h.slice(7) : null;
|
||||
if (!token) return null;
|
||||
try {
|
||||
const payload = jwt.verify(token, JWT_SECRET);
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getUserByEmail(email) {
|
||||
await db.read();
|
||||
return db.data.users.find((u) => u.email.toLowerCase() === email.toLowerCase());
|
||||
}
|
||||
|
||||
async function getUserById(id) {
|
||||
await db.read();
|
||||
return db.data.users.find((u) => u.id === id);
|
||||
}
|
||||
|
||||
async function createUser({
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
provider = "email",
|
||||
google_sub = null,
|
||||
}) {
|
||||
await db.read();
|
||||
const nextId = db.data.users.length
|
||||
? Math.max(...db.data.users.map((u) => u.id)) + 1
|
||||
: 1;
|
||||
const now = new Date().toISOString();
|
||||
const newUser = {
|
||||
id: nextId,
|
||||
email,
|
||||
password, // ⚠️ mock only (no hashing)
|
||||
name,
|
||||
provider,
|
||||
google_sub,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
db.data.users.push(newUser);
|
||||
await db.write();
|
||||
return newUser;
|
||||
}
|
||||
|
||||
async function upsertGoogleUser({ email, name, google_sub }) {
|
||||
await db.read();
|
||||
let user =
|
||||
db.data.users.find((u) => u.google_sub === google_sub) ||
|
||||
db.data.users.find((u) => u.email.toLowerCase() === email.toLowerCase());
|
||||
if (user) {
|
||||
user.google_sub = google_sub;
|
||||
user.provider = "google";
|
||||
user.name = user.name || name;
|
||||
user.updated_at = new Date().toISOString();
|
||||
await db.write();
|
||||
return user;
|
||||
}
|
||||
return createUser({
|
||||
email,
|
||||
password: nanoid(),
|
||||
name,
|
||||
provider: "google",
|
||||
google_sub,
|
||||
});
|
||||
}
|
||||
|
||||
async function revokeToken(token) {
|
||||
await db.read();
|
||||
db.data.revoked_tokens.push({
|
||||
token,
|
||||
revoked_at: new Date().toISOString(),
|
||||
});
|
||||
await db.write();
|
||||
}
|
||||
|
||||
async function isRevoked(token) {
|
||||
await db.read();
|
||||
return !!db.data.revoked_tokens.find((t) => t.token === token);
|
||||
}
|
||||
|
||||
// -------------------- AUTH Routes --------------------
|
||||
|
||||
|
||||
|
||||
// POST /api/v1/auth/register
|
||||
app.post(["/api/v1/auth/register", "/register"], async (req, res) => {
|
||||
const { email, password, name } = req.body || {};
|
||||
if (!email || !password)
|
||||
return res
|
||||
.status(422)
|
||||
.json({ message: "email și password sunt obligatorii" });
|
||||
|
||||
const existing = await getUserByEmail(email);
|
||||
if (existing) return res.status(409).json({ message: "Email deja folosit" });
|
||||
|
||||
const user = await createUser({ email, password, name });
|
||||
const access_token = signAccessToken(user);
|
||||
const refresh_token = signRefreshToken(user);
|
||||
setRefreshCookie(res, refresh_token, REFRESH_TTL * 1000);
|
||||
|
||||
res.json({ user: sanitizeUser(user), access_token, refresh_token });
|
||||
});
|
||||
|
||||
// POST /api/v1/auth/login
|
||||
app.post(["/api/v1/auth/login", "/login"], async (req, res) => {
|
||||
const { email, password } = req.body || {};
|
||||
const user = await getUserByEmail(email || "");
|
||||
if (!user || user.password !== password)
|
||||
return res.status(401).json({ message: "Credențiale invalide" });
|
||||
|
||||
const access_token = signAccessToken(user);
|
||||
const refresh_token = signRefreshToken(user);
|
||||
setRefreshCookie(res, refresh_token, REFRESH_TTL * 1000);
|
||||
|
||||
res.json({ user: sanitizeUser(user), access_token, refresh_token });
|
||||
});
|
||||
|
||||
// POST /api/v1/auth/google (MOCK: sets refresh cookie like classic login)
|
||||
app.post(["/api/v1/auth/google", "/google"], async (req, res) => {
|
||||
const { token } = req.body || {};
|
||||
if (!token) return res.status(422).json({ message: "token lipsă" });
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = typeof token === "string" ? JSON.parse(token) : token;
|
||||
} catch {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: "token invalid (mock așteaptă JSON cu {email,name,sub})" });
|
||||
}
|
||||
|
||||
const { email, name, sub } = payload || {};
|
||||
if (!email || !sub) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: "token mock trebuie să conțină email și sub" });
|
||||
}
|
||||
|
||||
// create/update Google user
|
||||
const user = await upsertGoogleUser({
|
||||
email,
|
||||
name: name || email.split("@")[0],
|
||||
google_sub: sub,
|
||||
});
|
||||
|
||||
// issue tokens
|
||||
const access_token = signAccessToken(user);
|
||||
const refresh_token = signRefreshToken(user);
|
||||
|
||||
// ✅ set refresh cookie exactly like the classic login
|
||||
setRefreshCookie(res, refresh_token, REFRESH_TTL * 1000);
|
||||
|
||||
// return same shape as login (keeps parity with Rails response)
|
||||
return res.json({
|
||||
user: sanitizeUser(user),
|
||||
access_token,
|
||||
refresh_token,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
// POST /api/v1/auth/logout — clear refresh cookie & revoke tokens
|
||||
app.post(["/api/v1/auth/logout", "/logout"], async (req, res) => {
|
||||
const h = req.headers.authorization || "";
|
||||
const at = h.startsWith("Bearer ") ? h.slice(7) : null;
|
||||
if (at) await revokeToken(at);
|
||||
|
||||
const rt = req.cookies?.refresh_token;
|
||||
if (rt) await revokeToken(rt);
|
||||
|
||||
res.clearCookie("refresh_token", { httpOnly: true, sameSite: "lax", secure: false, path: "/" });
|
||||
return res.json({ success: true });
|
||||
});
|
||||
|
||||
|
||||
// POST /api/v1/auth/refresh — cookie-based (no body)
|
||||
app.post(["/api/v1/auth/refresh", "/refresh"], async (req, res) => {
|
||||
const refresh_token = req.cookies?.refresh_token;
|
||||
if (!refresh_token) return res.status(401).json({ message: "No refresh token" });
|
||||
|
||||
try {
|
||||
const payload = jwt.verify(refresh_token, JWT_SECRET);
|
||||
if (payload.typ !== "refresh") return res.status(401).json({ message: "tip token greșit" });
|
||||
if (await isRevoked(refresh_token)) return res.status(401).json({ message: "refresh token revocat" });
|
||||
|
||||
const user = await getUserById(payload.sub);
|
||||
if (!user) return res.status(401).json({ message: "user inexistent" });
|
||||
|
||||
await revokeToken(refresh_token); // rotate
|
||||
const new_access = signAccessToken(user);
|
||||
const new_refresh = signRefreshToken(user);
|
||||
setRefreshCookie(res, new_refresh, REFRESH_TTL * 1000);
|
||||
|
||||
return res.json({ access_token: new_access }); // only access in body
|
||||
} catch {
|
||||
return res.status(401).json({ message: "refresh token invalid sau expirat" });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// GET /api/v1/auth/me
|
||||
app.get(["/api/v1/auth/me", "/me"], async (req, res) => {
|
||||
const payload = authz(req);
|
||||
if (!payload || payload.typ !== "access")
|
||||
return res.status(401).json({ message: "Unauthorized" });
|
||||
if (await isRevoked((req.headers.authorization || "").slice(7))) {
|
||||
return res.status(401).json({ message: "Token revocat" });
|
||||
}
|
||||
const user = await getUserById(payload.sub);
|
||||
if (!user) return res.status(404).json({ message: "User not found" });
|
||||
res.json({ user: sanitizeUser(user) });
|
||||
});
|
||||
|
||||
// -------------------- Password Reset (mock) --------------------
|
||||
app.post(
|
||||
["/api/v1/auth/forgot-password", "/forgot-password"],
|
||||
async (req, res) => {
|
||||
const { email } = req.body || {};
|
||||
const user = email ? await getUserByEmail(email) : null;
|
||||
if (!user)
|
||||
return res.json({
|
||||
message: "Dacă emailul există, vei primi un link de resetare",
|
||||
});
|
||||
|
||||
const token = nanoid();
|
||||
await db.read();
|
||||
db.data.password_resets.push({
|
||||
id: nanoid(),
|
||||
user_id: user.id,
|
||||
token,
|
||||
created_at: new Date().toISOString(),
|
||||
consumed_at: null,
|
||||
});
|
||||
await db.write();
|
||||
|
||||
// In mock, return token for testing the UI
|
||||
res.json({ message: "Reset email sent (mock)", token });
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
["/api/v1/auth/reset-password", "/reset-password"],
|
||||
async (req, res) => {
|
||||
const { token, password } = req.body || {};
|
||||
if (!token || !password)
|
||||
return res
|
||||
.status(422)
|
||||
.json({ message: "token și password sunt obligatorii" });
|
||||
|
||||
await db.read();
|
||||
const prIndex = db.data.password_resets.findIndex(
|
||||
(r) => r.token === token && !r.consumed_at
|
||||
);
|
||||
if (prIndex === -1)
|
||||
return res.status(400).json({ message: "token invalid sau folosit" });
|
||||
|
||||
const pr = db.data.password_resets[prIndex];
|
||||
const user = await getUserById(pr.user_id);
|
||||
if (!user) return res.status(404).json({ message: "user inexistent" });
|
||||
|
||||
user.password = password;
|
||||
user.updated_at = new Date().toISOString();
|
||||
db.data.password_resets[prIndex].consumed_at = new Date().toISOString();
|
||||
await db.write();
|
||||
|
||||
res.json({ message: "Parola a fost actualizată" });
|
||||
}
|
||||
);
|
||||
|
||||
// -------------------- Mount json-server router for other CRUD --------------------
|
||||
app.use("/api", router);
|
||||
|
||||
// -------------------- Start --------------------
|
||||
app.listen(Number(PORT), () => {
|
||||
console.log(`Mock AUTH API on http://localhost:${PORT}`);
|
||||
});
|
||||
Reference in New Issue
Block a user