AndreaNotes1 commited on
Commit
5ef11cb
Β·
verified Β·
1 Parent(s): 853250b

Upload 34 files

Browse files
package.json CHANGED
@@ -17,7 +17,6 @@
17
  "bcryptjs": "^2.4.3",
18
  "cloudinary": "^2.5.1",
19
  "compression": "^1.7.4",
20
- "cookie-parser": "^1.4.6",
21
  "cors": "^2.8.5",
22
  "dotenv": "^16.4.5",
23
  "express": "^4.19.2",
 
17
  "bcryptjs": "^2.4.3",
18
  "cloudinary": "^2.5.1",
19
  "compression": "^1.7.4",
 
20
  "cors": "^2.8.5",
21
  "dotenv": "^16.4.5",
22
  "express": "^4.19.2",
src/app.js CHANGED
@@ -1,7 +1,6 @@
1
  const express = require("express");
2
  const helmet = require("helmet");
3
  const cors = require("cors");
4
- const cookieParser = require("cookie-parser");
5
  const compression = require("compression");
6
  const morgan = require("morgan");
7
  const mongoSanitize = require("express-mongo-sanitize");
@@ -41,8 +40,18 @@ app.use(
41
  })
42
  );
43
 
44
- // --- CORS: only the configured frontend origins may call the API, and
45
- // credentials (refresh-token cookie) are only sent to those origins. ---
 
 
 
 
 
 
 
 
 
 
46
  app.use(
47
  cors({
48
  origin(origin, callback) {
@@ -54,14 +63,13 @@ app.use(
54
  logger.warn(`CORS rejected origin: "${origin}" (allow-list: ${JSON.stringify(env.clientOrigins)})`);
55
  return callback(new Error("Not allowed by CORS"));
56
  },
57
- credentials: true,
58
  })
59
  );
60
 
61
  app.use(compression());
62
  app.use(express.json({ limit: "100kb" })); // small limit: this API never needs large bodies
63
  app.use(express.urlencoded({ extended: true, limit: "100kb" }));
64
- app.use(cookieParser(env.cookieSecret));
65
 
66
  // Strip any keys starting with "$" or containing "." from user input to
67
  // block MongoDB operator-injection attacks.
 
1
  const express = require("express");
2
  const helmet = require("helmet");
3
  const cors = require("cors");
 
4
  const compression = require("compression");
5
  const morgan = require("morgan");
6
  const mongoSanitize = require("express-mongo-sanitize");
 
40
  })
41
  );
42
 
43
+ // --- CORS: only the configured frontend origins may call the API.
44
+ // `credentials: false` is deliberate β€” this API is designed to run
45
+ // cross-site from its frontend (e.g. a Hugging Face Space backend +
46
+ // a separate Vercel-hosted frontend), and some hosting proxies in that
47
+ // kind of setup intercept CORS preflight requests before they reach
48
+ // this app and strip the Access-Control-Allow-Credentials header,
49
+ // which silently breaks anything relying on `credentials: include`.
50
+ // Sessions are carried via a bearer access token (Authorization
51
+ // header) plus a refresh token returned in the JSON response body
52
+ // instead of a cookie, so no cross-site credentialed request is ever
53
+ // needed β€” see controllers/auth.controller.js for the reasoning and
54
+ // trade-offs of that approach in more detail. ---
55
  app.use(
56
  cors({
57
  origin(origin, callback) {
 
63
  logger.warn(`CORS rejected origin: "${origin}" (allow-list: ${JSON.stringify(env.clientOrigins)})`);
64
  return callback(new Error("Not allowed by CORS"));
65
  },
66
+ credentials: false,
67
  })
68
  );
69
 
70
  app.use(compression());
71
  app.use(express.json({ limit: "100kb" })); // small limit: this API never needs large bodies
72
  app.use(express.urlencoded({ extended: true, limit: "100kb" }));
 
73
 
74
  // Strip any keys starting with "$" or containing "." from user input to
75
  // block MongoDB operator-injection attacks.
src/config/env.js CHANGED
@@ -8,7 +8,6 @@ const REQUIRED_IN_PRODUCTION = [
8
  "JWT_ACCESS_SECRET",
9
  "JWT_REFRESH_SECRET",
10
  "FIELD_ENCRYPTION_KEY",
11
- "COOKIE_SECRET",
12
  "SMTP_HOST",
13
  "SMTP_USER",
14
  "SMTP_PASS",
@@ -33,8 +32,6 @@ const env = {
33
  jwtRefreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || "7d",
34
 
35
  fieldEncryptionKey: process.env.FIELD_ENCRYPTION_KEY,
36
- cookieSecret: process.env.COOKIE_SECRET,
37
- cookieSecure: process.env.COOKIE_SECURE === "true",
38
 
39
  smtp: {
40
  host: process.env.SMTP_HOST,
 
8
  "JWT_ACCESS_SECRET",
9
  "JWT_REFRESH_SECRET",
10
  "FIELD_ENCRYPTION_KEY",
 
11
  "SMTP_HOST",
12
  "SMTP_USER",
13
  "SMTP_PASS",
 
32
  jwtRefreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || "7d",
33
 
34
  fieldEncryptionKey: process.env.FIELD_ENCRYPTION_KEY,
 
 
35
 
36
  smtp: {
37
  host: process.env.SMTP_HOST,
src/controllers/auth.controller.js CHANGED
@@ -1,22 +1,34 @@
1
  const User = require("../models/User");
2
  const AuditLog = require("../models/AuditLog");
3
  const { signAccessToken, signRefreshToken, verifyRefreshToken } = require("../utils/jwt");
4
- const { env } = require("../config/env");
5
  const logger = require("../utils/logger");
6
 
7
  const MAX_FAILED_ATTEMPTS = 5;
8
  const LOCK_DURATION_MS = 15 * 60 * 1000; // 15 minutes
9
- const REFRESH_COOKIE_NAME = "aynwerd_rt";
10
-
11
- function refreshCookieOptions() {
12
- return {
13
- httpOnly: true,
14
- secure: env.cookieSecure,
15
- sameSite: "lax",
16
- path: "/api/auth",
17
- maxAge: 7 * 24 * 60 * 60 * 1000,
18
- };
19
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
  async function audit(action, req, extra = {}) {
22
  try {
@@ -33,6 +45,20 @@ async function audit(action, req, extra = {}) {
33
  }
34
  }
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  async function signup(req, res, next) {
37
  try {
38
  const { name, email, password } = req.body;
@@ -50,19 +76,10 @@ async function signup(req, res, next) {
50
 
51
  await audit("user.signup", req, { email: user.email });
52
 
53
- const accessToken = signAccessToken(user);
54
- const { token: refreshToken, jti } = signRefreshToken(user);
55
- user.activeRefreshTokens = user.activeRefreshTokens || [];
56
- user.activeRefreshTokens.push({
57
- jti,
58
- expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
59
- userAgent: req.headers["user-agent"],
60
- ip: req.ip,
61
- });
62
  await user.save();
63
 
64
- res.cookie(REFRESH_COOKIE_NAME, refreshToken, refreshCookieOptions());
65
- res.status(201).json({ user: user.toSafeJSON(), accessToken });
66
  } catch (err) {
67
  next(err);
68
  }
@@ -102,22 +119,12 @@ async function login(req, res, next) {
102
  user.failedLoginAttempts = 0;
103
  user.lockUntil = null;
104
 
105
- const accessToken = signAccessToken(user);
106
- const { token: refreshToken, jti } = signRefreshToken(user);
107
- user.activeRefreshTokens = (user.activeRefreshTokens || [])
108
- .filter((rt) => rt.expiresAt > new Date()) // prune expired sessions
109
- .concat({
110
- jti,
111
- expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
112
- userAgent: req.headers["user-agent"],
113
- ip: req.ip,
114
- });
115
  await user.save();
116
 
117
  await audit("user.login", req, { email: user.email });
118
 
119
- res.cookie(REFRESH_COOKIE_NAME, refreshToken, refreshCookieOptions());
120
- res.json({ user: user.toSafeJSON(), accessToken });
121
  } catch (err) {
122
  next(err);
123
  }
@@ -125,14 +132,13 @@ async function login(req, res, next) {
125
 
126
  async function refresh(req, res, next) {
127
  try {
128
- const token = req.cookies[REFRESH_COOKIE_NAME];
129
  if (!token) return res.status(401).json({ error: "No refresh token provided." });
130
 
131
  let payload;
132
  try {
133
  payload = verifyRefreshToken(token);
134
  } catch {
135
- res.clearCookie(REFRESH_COOKIE_NAME, refreshCookieOptions());
136
  return res.status(401).json({ error: "Invalid or expired session, please log in again." });
137
  }
138
 
@@ -143,25 +149,21 @@ async function refresh(req, res, next) {
143
 
144
  const stillActive = (user.activeRefreshTokens || []).some((rt) => rt.jti === payload.jti);
145
  if (!stillActive) {
146
- // Possible token reuse after logout/rotation β€” deny and clear cookie.
147
- res.clearCookie(REFRESH_COOKIE_NAME, refreshCookieOptions());
 
 
 
 
148
  return res.status(401).json({ error: "Session no longer valid, please log in again." });
149
  }
150
 
151
  // Rotate: remove old jti, issue a new refresh token.
152
  user.activeRefreshTokens = user.activeRefreshTokens.filter((rt) => rt.jti !== payload.jti);
153
- const accessToken = signAccessToken(user);
154
- const { token: newRefreshToken, jti: newJti } = signRefreshToken(user);
155
- user.activeRefreshTokens.push({
156
- jti: newJti,
157
- expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
158
- userAgent: req.headers["user-agent"],
159
- ip: req.ip,
160
- });
161
  await user.save();
162
 
163
- res.cookie(REFRESH_COOKIE_NAME, newRefreshToken, refreshCookieOptions());
164
- res.json({ accessToken, user: user.toSafeJSON() });
165
  } catch (err) {
166
  next(err);
167
  }
@@ -169,7 +171,7 @@ async function refresh(req, res, next) {
169
 
170
  async function logout(req, res, next) {
171
  try {
172
- const token = req.cookies[REFRESH_COOKIE_NAME];
173
  if (token) {
174
  try {
175
  const payload = verifyRefreshToken(token);
@@ -178,7 +180,6 @@ async function logout(req, res, next) {
178
  // token already invalid β€” nothing to revoke
179
  }
180
  }
181
- res.clearCookie(REFRESH_COOKIE_NAME, refreshCookieOptions());
182
  res.json({ success: true });
183
  } catch (err) {
184
  next(err);
 
1
  const User = require("../models/User");
2
  const AuditLog = require("../models/AuditLog");
3
  const { signAccessToken, signRefreshToken, verifyRefreshToken } = require("../utils/jwt");
 
4
  const logger = require("../utils/logger");
5
 
6
  const MAX_FAILED_ATTEMPTS = 5;
7
  const LOCK_DURATION_MS = 15 * 60 * 1000; // 15 minutes
8
+ const REFRESH_TOKEN_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
9
+
10
+ // --------------------------------------------------------------------
11
+ // Refresh tokens are returned in the JSON response body (and stored by
12
+ // the client in localStorage) rather than as an httpOnly cookie.
13
+ //
14
+ // Why: this API and its frontend are deployed on different domains
15
+ // (e.g. a Hugging Face Space + a separate Vercel domain), and some
16
+ // hosting proxies in that kind of cross-site setup intercept CORS
17
+ // preflight requests before they reach the app and strip the
18
+ // Access-Control-Allow-Credentials header β€” which silently breaks any
19
+ // flow that depends on browsers sending a cross-site cookie. Avoiding
20
+ // `credentials: include` entirely sidesteps that class of problem.
21
+ //
22
+ // The trade-off: an httpOnly cookie can never be read by JavaScript,
23
+ // even from an XSS payload; a token sitting in localStorage can be.
24
+ // We narrow that risk with what's still in our control β€” access
25
+ // tokens stay short-lived (15 min) and refresh tokens rotate on every
26
+ // use with reuse detection (see `refresh` below) β€” but this is
27
+ // genuinely a lower security bar than the cookie-based approach, and
28
+ // worth knowing if you migrate this API to a same-site deployment
29
+ // later (at which point switching back to an httpOnly cookie is
30
+ // recommended).
31
+ // --------------------------------------------------------------------
32
 
33
  async function audit(action, req, extra = {}) {
34
  try {
 
45
  }
46
  }
47
 
48
+ function issueSession(user, req) {
49
+ const accessToken = signAccessToken(user);
50
+ const { token: refreshToken, jti } = signRefreshToken(user);
51
+ user.activeRefreshTokens = (user.activeRefreshTokens || [])
52
+ .filter((rt) => rt.expiresAt > new Date()) // prune expired sessions as we go
53
+ .concat({
54
+ jti,
55
+ expiresAt: new Date(Date.now() + REFRESH_TOKEN_LIFETIME_MS),
56
+ userAgent: req.headers["user-agent"],
57
+ ip: req.ip,
58
+ });
59
+ return { accessToken, refreshToken };
60
+ }
61
+
62
  async function signup(req, res, next) {
63
  try {
64
  const { name, email, password } = req.body;
 
76
 
77
  await audit("user.signup", req, { email: user.email });
78
 
79
+ const { accessToken, refreshToken } = issueSession(user, req);
 
 
 
 
 
 
 
 
80
  await user.save();
81
 
82
+ res.status(201).json({ user: user.toSafeJSON(), accessToken, refreshToken });
 
83
  } catch (err) {
84
  next(err);
85
  }
 
119
  user.failedLoginAttempts = 0;
120
  user.lockUntil = null;
121
 
122
+ const { accessToken, refreshToken } = issueSession(user, req);
 
 
 
 
 
 
 
 
 
123
  await user.save();
124
 
125
  await audit("user.login", req, { email: user.email });
126
 
127
+ res.json({ user: user.toSafeJSON(), accessToken, refreshToken });
 
128
  } catch (err) {
129
  next(err);
130
  }
 
132
 
133
  async function refresh(req, res, next) {
134
  try {
135
+ const { refreshToken: token } = req.body;
136
  if (!token) return res.status(401).json({ error: "No refresh token provided." });
137
 
138
  let payload;
139
  try {
140
  payload = verifyRefreshToken(token);
141
  } catch {
 
142
  return res.status(401).json({ error: "Invalid or expired session, please log in again." });
143
  }
144
 
 
149
 
150
  const stillActive = (user.activeRefreshTokens || []).some((rt) => rt.jti === payload.jti);
151
  if (!stillActive) {
152
+ // Possible reuse of an already-rotated (or logged-out) refresh
153
+ // token β€” deny it. As a precaution, also revoke every other
154
+ // active session for this account, since a replayed token is a
155
+ // signal the original may have leaked.
156
+ user.activeRefreshTokens = [];
157
+ await user.save();
158
  return res.status(401).json({ error: "Session no longer valid, please log in again." });
159
  }
160
 
161
  // Rotate: remove old jti, issue a new refresh token.
162
  user.activeRefreshTokens = user.activeRefreshTokens.filter((rt) => rt.jti !== payload.jti);
163
+ const { accessToken, refreshToken: newRefreshToken } = issueSession(user, req);
 
 
 
 
 
 
 
164
  await user.save();
165
 
166
+ res.json({ accessToken, refreshToken: newRefreshToken, user: user.toSafeJSON() });
 
167
  } catch (err) {
168
  next(err);
169
  }
 
171
 
172
  async function logout(req, res, next) {
173
  try {
174
+ const { refreshToken: token } = req.body;
175
  if (token) {
176
  try {
177
  const payload = verifyRefreshToken(token);
 
180
  // token already invalid β€” nothing to revoke
181
  }
182
  }
 
183
  res.json({ success: true });
184
  } catch (err) {
185
  next(err);
src/routes/auth.routes.js CHANGED
@@ -38,8 +38,13 @@ router.post(
38
  ctrl.login
39
  );
40
 
41
- router.post("/refresh", ctrl.refresh);
42
- router.post("/logout", ctrl.logout);
 
 
 
 
 
43
  router.get("/me", requireAuth, ctrl.me);
44
 
45
  module.exports = router;
 
38
  ctrl.login
39
  );
40
 
41
+ router.post(
42
+ "/refresh",
43
+ [body("refreshToken").notEmpty().withMessage("A refresh token is required.")],
44
+ validate,
45
+ ctrl.refresh
46
+ );
47
+ router.post("/logout", [body("refreshToken").optional({ nullable: true }).isString()], validate, ctrl.logout);
48
  router.get("/me", requireAuth, ctrl.me);
49
 
50
  module.exports = router;