nuxt-users 1.49.3 → 1.50.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/cli.mjs +1 -1
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +1 -1
  4. package/dist/runtime/constants.d.ts +1 -0
  5. package/dist/runtime/constants.js +1 -0
  6. package/dist/runtime/server/api/nuxt-users/[id].patch.js +1 -1
  7. package/dist/runtime/server/api/nuxt-users/auth/google/callback.get.js +18 -1
  8. package/dist/runtime/server/api/nuxt-users/auth/google/redirect.get.js +12 -2
  9. package/dist/runtime/server/api/nuxt-users/me.patch.js +3 -7
  10. package/dist/runtime/server/api/nuxt-users/password/forgot.post.js +3 -4
  11. package/dist/runtime/server/api/nuxt-users/password/index.patch.js +2 -1
  12. package/dist/runtime/server/api/nuxt-users/register.post.js +3 -4
  13. package/dist/runtime/server/plugins/validate-mailer-config.js +3 -0
  14. package/dist/runtime/server/services/password.d.ts +0 -4
  15. package/dist/runtime/server/services/password.js +14 -46
  16. package/dist/runtime/server/services/registration.js +11 -48
  17. package/dist/runtime/server/utils/escape-html.d.ts +4 -0
  18. package/dist/runtime/server/utils/escape-html.js +8 -0
  19. package/dist/runtime/server/utils/google-oauth.d.ts +1 -1
  20. package/dist/runtime/server/utils/google-oauth.js +3 -2
  21. package/dist/runtime/server/utils/index.d.ts +1 -0
  22. package/dist/runtime/server/utils/index.js +1 -0
  23. package/dist/runtime/server/utils/site-url.d.ts +10 -0
  24. package/dist/runtime/server/utils/site-url.js +13 -0
  25. package/dist/runtime/server/utils/token-expiry.d.ts +27 -0
  26. package/dist/runtime/server/utils/token-expiry.js +24 -0
  27. package/dist/runtime/server/utils/user.d.ts +2 -1
  28. package/dist/runtime/server/utils/user.js +9 -1
  29. package/dist/runtime/utils/permissions.js +10 -13
  30. package/dist/shared/{nuxt-users.DtwPhils.d.mts → nuxt-users.Bday8snh.d.mts} +7 -0
  31. package/dist/shared/{nuxt-users.DtwPhils.d.ts → nuxt-users.Bday8snh.d.ts} +7 -0
  32. package/dist/shared/{nuxt-users.KrHIaTTL.mjs → nuxt-users.CcNeTWsL.mjs} +3 -0
  33. package/dist/utils.d.mts +2 -2
  34. package/dist/utils.d.ts +2 -2
  35. package/package.json +6 -6
package/dist/cli.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { defineCommand, runMain } from 'citty';
3
3
  import { readFileSync } from 'node:fs';
4
- import { d as defaultOptions } from './shared/nuxt-users.KrHIaTTL.mjs';
4
+ import { d as defaultOptions } from './shared/nuxt-users.CcNeTWsL.mjs';
5
5
  import { defu } from 'defu';
6
6
  import { runMigrations, getAppliedMigrations } from '../dist/runtime/server/utils/migrate.js';
7
7
  import { createUser as createUser$1 } from '../dist/runtime/server/utils/user.js';
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=3.0.0"
6
6
  },
7
- "version": "1.49.3",
7
+ "version": "1.50.1",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.3",
10
10
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { defineNuxtModule, createResolver, addPlugin, addImportsDir, addServerImportsDir, addServerScanDir, addServerHandler, addServerPlugin, addRouteMiddleware, addComponentsDir } from '@nuxt/kit';
2
2
  import { defu } from 'defu';
3
- import { d as defaultOptions } from './shared/nuxt-users.KrHIaTTL.mjs';
3
+ import { d as defaultOptions } from './shared/nuxt-users.CcNeTWsL.mjs';
4
4
  export * from '../dist/runtime/server/internal/index.js';
5
5
 
6
6
  const module$1 = defineNuxtModule({
@@ -1,3 +1,4 @@
1
+ export declare const OAUTH_STATE_COOKIE = "nuxt_users_oauth_state";
1
2
  export declare const PUBLIC_PAGES: string[];
2
3
  export declare const PUBLIC_API_ENDPOINTS: {
3
4
  path: string;
@@ -1,3 +1,4 @@
1
+ export const OAUTH_STATE_COOKIE = "nuxt_users_oauth_state";
1
2
  export const PUBLIC_PAGES = ["/login", "/reset-password"];
2
3
  export const PUBLIC_API_ENDPOINTS = [
3
4
  { path: "/password/forgot", methods: ["POST"] },
@@ -12,7 +12,7 @@ export default defineEventHandler(async (event) => {
12
12
  });
13
13
  }
14
14
  const body = await readBody(event);
15
- const currentUser = event.context.user;
15
+ const currentUser = event.context.nuxtUsers?.user;
16
16
  if (currentUser && currentUser.id === userId && typeof body.active !== "undefined") {
17
17
  throw createError({
18
18
  statusCode: 403,
@@ -1,4 +1,5 @@
1
- import { defineEventHandler, sendRedirect, getQuery, setCookie } from "h3";
1
+ import crypto from "node:crypto";
2
+ import { defineEventHandler, sendRedirect, getQuery, setCookie, getCookie, deleteCookie } from "h3";
2
3
  import { useRuntimeConfig } from "#imports";
3
4
  import {
4
5
  createGoogleOAuth2Client,
@@ -6,6 +7,7 @@ import {
6
7
  findOrCreateGoogleUser,
7
8
  createAuthTokenForUser
8
9
  } from "../../../../utils/index.js";
10
+ import { OAUTH_STATE_COOKIE } from "../../../../../constants.js";
9
11
  export default defineEventHandler(async (event) => {
10
12
  const { nuxtUsers } = useRuntimeConfig();
11
13
  const options = nuxtUsers;
@@ -13,6 +15,13 @@ export default defineEventHandler(async (event) => {
13
15
  if (!options.auth.google) {
14
16
  return sendRedirect(event, "/login?error=oauth_not_configured");
15
17
  }
18
+ const expectedState = getCookie(event, OAUTH_STATE_COOKIE);
19
+ deleteCookie(event, OAUTH_STATE_COOKIE, { path: "/" });
20
+ if (!isValidState(expectedState, query.state)) {
21
+ console.error("[Nuxt Users] Invalid or missing OAuth state in callback");
22
+ const errorRedirect = options.auth.google.errorRedirect || "/login?error=oauth_failed";
23
+ return sendRedirect(event, errorRedirect);
24
+ }
16
25
  if (query.error) {
17
26
  console.error("[Nuxt Users] Google OAuth error:", query.error);
18
27
  const errorRedirect = options.auth.google.errorRedirect || "/login?error=oauth_failed";
@@ -60,6 +69,14 @@ export default defineEventHandler(async (event) => {
60
69
  return sendRedirect(event, errorRedirect);
61
70
  }
62
71
  });
72
+ const isValidState = (expected, received) => {
73
+ if (!expected || typeof received !== "string") {
74
+ return false;
75
+ }
76
+ const expectedBuffer = Buffer.from(expected);
77
+ const receivedBuffer = Buffer.from(received);
78
+ return expectedBuffer.length === receivedBuffer.length && crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
79
+ };
63
80
  const getRequestURL = (event) => {
64
81
  const headers = event.node.req.headers;
65
82
  const host = headers.host || headers[":authority"];
@@ -1,6 +1,8 @@
1
- import { defineEventHandler, sendRedirect, createError } from "h3";
1
+ import crypto from "node:crypto";
2
+ import { defineEventHandler, sendRedirect, createError, setCookie } from "h3";
2
3
  import { useRuntimeConfig } from "#imports";
3
4
  import { createGoogleOAuth2Client, getGoogleAuthUrl } from "../../../../utils/index.js";
5
+ import { OAUTH_STATE_COOKIE } from "../../../../../constants.js";
4
6
  export default defineEventHandler(async (event) => {
5
7
  const { nuxtUsers } = useRuntimeConfig();
6
8
  const options = nuxtUsers;
@@ -21,7 +23,15 @@ export default defineEventHandler(async (event) => {
21
23
  const callbackPath = options.auth.google.callbackUrl || `${options.apiBasePath}/auth/google/callback`;
22
24
  const callbackUrl = `${baseUrl}${callbackPath}`;
23
25
  const oauth2Client = createGoogleOAuth2Client(options.auth.google, callbackUrl);
24
- const authUrl = getGoogleAuthUrl(oauth2Client, options.auth.google);
26
+ const state = crypto.randomBytes(32).toString("hex");
27
+ setCookie(event, OAUTH_STATE_COOKIE, state, {
28
+ httpOnly: true,
29
+ secure: process.env.NODE_ENV === "production",
30
+ sameSite: "lax",
31
+ path: "/",
32
+ maxAge: 10 * 60
33
+ });
34
+ const authUrl = getGoogleAuthUrl(oauth2Client, options.auth.google, state);
25
35
  return sendRedirect(event, authUrl);
26
36
  } catch (error) {
27
37
  console.error("[Nuxt Users] Google OAuth redirect error:", error);
@@ -4,7 +4,7 @@ import { updateUser } from "../../utils/index.js";
4
4
  export default defineEventHandler(async (event) => {
5
5
  const { nuxtUsers } = useRuntimeConfig();
6
6
  const options = nuxtUsers;
7
- const user = event.context.user;
7
+ const user = event.context.nuxtUsers?.user;
8
8
  if (!user) {
9
9
  throw createError({
10
10
  statusCode: 401,
@@ -12,12 +12,8 @@ export default defineEventHandler(async (event) => {
12
12
  });
13
13
  }
14
14
  const body = await readBody(event);
15
- if (body.role) {
16
- delete body.role;
17
- }
18
- if (body.active) {
19
- delete body.active;
20
- }
15
+ delete body.role;
16
+ delete body.active;
21
17
  try {
22
18
  const updatedUser = await updateUser(user.id, body, options);
23
19
  return { user: updatedUser };
@@ -1,5 +1,6 @@
1
- import { defineEventHandler, readBody, createError, getHeader } from "h3";
1
+ import { defineEventHandler, readBody, createError } from "h3";
2
2
  import { sendPasswordResetLink } from "../../../services/password.js";
3
+ import { getSiteUrl } from "../../../utils/site-url.js";
3
4
  import { useRuntimeConfig } from "#imports";
4
5
  export default defineEventHandler(async (event) => {
5
6
  const { nuxtUsers } = useRuntimeConfig();
@@ -13,9 +14,7 @@ export default defineEventHandler(async (event) => {
13
14
  });
14
15
  }
15
16
  try {
16
- const host = getHeader(event, "host");
17
- const protocol = getHeader(event, "x-forwarded-proto") || "http";
18
- const baseUrl = host ? `${protocol}://${host}` : void 0;
17
+ const baseUrl = getSiteUrl(event, options);
19
18
  await sendPasswordResetLink(email, options, baseUrl);
20
19
  return { message: "If a user with that email exists, a password reset link has been sent." };
21
20
  } catch (error) {
@@ -1,7 +1,7 @@
1
1
  import { createError, defineEventHandler, getCookie, readBody } from "h3";
2
2
  import bcrypt from "bcrypt";
3
3
  import { useRuntimeConfig } from "#imports";
4
- import { getCurrentUserFromToken, updateUserPassword } from "../../../utils/index.js";
4
+ import { getCurrentUserFromToken, updateUserPassword, revokeUserTokens } from "../../../utils/index.js";
5
5
  import { validatePassword, getPasswordValidationOptions } from "../../../../../utils";
6
6
  export default defineEventHandler(async (event) => {
7
7
  const { nuxtUsers } = useRuntimeConfig();
@@ -56,5 +56,6 @@ export default defineEventHandler(async (event) => {
56
56
  });
57
57
  }
58
58
  await updateUserPassword(user.email, newPassword, options);
59
+ await revokeUserTokens(user.id, options, token);
59
60
  return { message: "Password updated successfully" };
60
61
  });
@@ -1,6 +1,7 @@
1
- import { createError, defineEventHandler, readBody, getHeader } from "h3";
1
+ import { createError, defineEventHandler, readBody } from "h3";
2
2
  import { useRuntimeConfig } from "#imports";
3
3
  import { registerUser } from "../../services/registration.js";
4
+ import { getSiteUrl } from "../../utils/site-url.js";
4
5
  export default defineEventHandler(async (event) => {
5
6
  try {
6
7
  const { nuxtUsers } = useRuntimeConfig();
@@ -25,9 +26,7 @@ export default defineEventHandler(async (event) => {
25
26
  statusMessage: "Name cannot be empty"
26
27
  });
27
28
  }
28
- const host = getHeader(event, "host") || "localhost:3000";
29
- const protocol = getHeader(event, "x-forwarded-proto") || "http";
30
- const baseUrl = `${protocol}://${host}`;
29
+ const baseUrl = getSiteUrl(event, options);
31
30
  const result = await registerUser(
32
31
  {
33
32
  email: body.email.toLowerCase().trim(),
@@ -29,5 +29,8 @@ const plugin = () => {
29
29
  return;
30
30
  }
31
31
  validateMailerConfig(options);
32
+ if (process.env.NODE_ENV === "production" && !options.siteUrl) {
33
+ console.warn("[Nuxt Users] siteUrl is not configured: password reset and email confirmation requests will fail. Set nuxtUsers.siteUrl (or NUXT_NUXT_USERS_SITE_URL) to your public app URL.");
34
+ }
32
35
  };
33
36
  export default plugin;
@@ -8,7 +8,3 @@ export declare const sendPasswordResetLink: (email: string, options: ModuleOptio
8
8
  */
9
9
  export declare const resetPassword: (token: string, email: string, // Added email to quickly find the token
10
10
  newPassword: string, options: ModuleOptions) => Promise<boolean>;
11
- /**
12
- * Deletes expired password reset tokens from the database.
13
- */
14
- export declare const deleteExpiredPasswordResetTokens: (options: ModuleOptions) => Promise<void>;
@@ -1,7 +1,9 @@
1
1
  import { createTransport } from "nodemailer";
2
2
  import crypto from "node:crypto";
3
3
  import bcrypt from "bcrypt";
4
- import { findUserByEmail, updateUserPassword, useDb } from "../utils/index.js";
4
+ import { findUserByEmail, updateUserPassword, revokeUserTokens, useDb } from "../utils/index.js";
5
+ import { escapeHtml } from "../utils/escape-html.js";
6
+ import { isTokenExpired, findTokenRecordsByEmail } from "../utils/token-expiry.js";
5
7
  const TOKEN_EXPIRATION_HOURS = 1;
6
8
  export const sendPasswordResetLink = async (email, options, baseUrl) => {
7
9
  const user = await findUserByEmail(email, options);
@@ -35,6 +37,7 @@ export const sendPasswordResetLink = async (email, options, baseUrl) => {
35
37
  resetUrl.searchParams.set("token", token);
36
38
  resetUrl.searchParams.set("email", email);
37
39
  const resetLink = resetUrl.toString();
40
+ const safeLink = escapeHtml(resetLink);
38
41
  try {
39
42
  await transporter.sendMail({
40
43
  from: options.mailer.defaults?.from || '"Nuxt Users" <noreply@example.com>',
@@ -51,7 +54,7 @@ If you did not request this password reset, please ignore this email.`,
51
54
  <p>You have requested to reset your password. Please click the button below to set a new password:</p>
52
55
 
53
56
  <div style="text-align: center; margin: 30px 0;">
54
- <a href="${resetLink}"
57
+ <a href="${safeLink}"
55
58
  style="background-color: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;">
56
59
  Reset Password
57
60
  </a>
@@ -59,7 +62,7 @@ If you did not request this password reset, please ignore this email.`,
59
62
 
60
63
  <p>Or copy and paste the following link into your browser:</p>
61
64
  <p style="word-break: break-all; background-color: #f8f9fa; padding: 10px; border-radius: 3px;">
62
- <a href="${resetLink}">${resetLink}</a>
65
+ <a href="${safeLink}">${safeLink}</a>
63
66
  </p>
64
67
 
65
68
  <p style="color: #6c757d; font-size: 14px;">
@@ -80,17 +83,13 @@ If you did not request this password reset, please ignore this email.`,
80
83
  export const resetPassword = async (token, email, newPassword, options) => {
81
84
  const db = await useDb(options);
82
85
  const passwordResetTokensTable = options.tables.passwordResetTokens;
83
- const tokenRecords = await db.sql`
84
- SELECT * FROM {${passwordResetTokensTable}}
85
- WHERE email = ${email}
86
- ORDER BY created_at DESC
87
- `;
88
- if (tokenRecords.rows.length === 0) {
86
+ const tokenRecords = await findTokenRecordsByEmail(db, passwordResetTokensTable, email);
87
+ if (tokenRecords.length === 0) {
89
88
  console.log(`[Nuxt Users] No password reset tokens found for email: ${email}`);
90
89
  return false;
91
90
  }
92
91
  let validTokenRecord = null;
93
- for (const record of tokenRecords.rows) {
92
+ for (const record of tokenRecords) {
94
93
  const tokenMatch = await bcrypt.compare(token, record.token);
95
94
  if (tokenMatch) {
96
95
  validTokenRecord = record;
@@ -101,48 +100,17 @@ export const resetPassword = async (token, email, newPassword, options) => {
101
100
  console.log(`[Nuxt Users] Invalid password reset token provided for email: ${email}`);
102
101
  return false;
103
102
  }
104
- const now = /* @__PURE__ */ new Date();
105
- const currentTimeString = now.toISOString().slice(0, 19).replace("T", " ");
106
- const createdAtString = validTokenRecord.created_at instanceof Date ? validTokenRecord.created_at.toISOString().slice(0, 19).replace("T", " ") : String(validTokenRecord.created_at);
107
- const [datePart, timePart] = createdAtString.split(/[ T]/);
108
- if (!datePart || !timePart) {
109
- console.log(`[Nuxt Users] Invalid timestamp format for token: ${createdAtString}`);
110
- return false;
111
- }
112
- const [year, month, day] = datePart.split("-").map(Number);
113
- const [hour, minute, second] = timePart.split(":").map(Number);
114
- if (!year || !month || !day || hour === void 0 || minute === void 0 || second === void 0) {
115
- console.log("[Nuxt Users] Invalid timestamp components");
116
- return false;
117
- }
118
- let expirationHour = hour + TOKEN_EXPIRATION_HOURS;
119
- let expirationDay = day;
120
- const expirationMonth = month;
121
- const expirationYear = year;
122
- if (expirationHour >= 24) {
123
- expirationHour -= 24;
124
- expirationDay++;
125
- }
126
- const expirationTimeString = `${expirationYear.toString().padStart(4, "0")}-${expirationMonth.toString().padStart(2, "0")}-${expirationDay.toString().padStart(2, "0")} ${expirationHour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}:${second.toString().padStart(2, "0")}`;
127
- if (currentTimeString > expirationTimeString) {
103
+ if (isTokenExpired(validTokenRecord.created_at, validTokenRecord.db_now, TOKEN_EXPIRATION_HOURS)) {
128
104
  console.log(`[Nuxt Users] Expired password reset token for email: ${email}`);
129
105
  await db.sql`DELETE FROM {${passwordResetTokensTable}} WHERE id = ${validTokenRecord.id}`;
130
106
  return false;
131
107
  }
132
108
  await updateUserPassword(email, newPassword, options);
109
+ const user = await findUserByEmail(email, options);
110
+ if (user) {
111
+ await revokeUserTokens(user.id, options);
112
+ }
133
113
  await db.sql`DELETE FROM {${passwordResetTokensTable}} WHERE email = ${email}`;
134
114
  console.log(`[Nuxt Users] Password reset successful for email: ${email}`);
135
115
  return true;
136
116
  };
137
- export const deleteExpiredPasswordResetTokens = async (options) => {
138
- const db = await useDb(options);
139
- const passwordResetTokensTable = options.tables.passwordResetTokens;
140
- const expirationDate = /* @__PURE__ */ new Date();
141
- expirationDate.setHours(expirationDate.getHours() - TOKEN_EXPIRATION_HOURS);
142
- const expirationDateString = expirationDate.toISOString().slice(0, 19).replace("T", " ");
143
- await db.sql`
144
- DELETE FROM {${passwordResetTokensTable}}
145
- WHERE created_at < ${expirationDateString}
146
- `;
147
- console.log(`[Nuxt Users] Expired password reset tokens older than ${expirationDateString} deleted.`);
148
- };
@@ -2,6 +2,8 @@ import { createTransport } from "nodemailer";
2
2
  import crypto from "node:crypto";
3
3
  import bcrypt from "bcrypt";
4
4
  import { findUserByEmail, useDb, USER_EMAIL_ALREADY_EXISTS_MESSAGE } from "../utils/index.js";
5
+ import { escapeHtml } from "../utils/escape-html.js";
6
+ import { isTokenExpired, findTokenRecordsByEmail } from "../utils/token-expiry.js";
5
7
  import { validatePassword, getPasswordValidationOptions } from "nuxt-users/utils";
6
8
  const TOKEN_EXPIRATION_HOURS = 24;
7
9
  export const registerUser = async (userData, options, baseUrl) => {
@@ -76,6 +78,8 @@ export const sendConfirmationEmail = async (email, name, token, options, baseUrl
76
78
  confirmationUrl.searchParams.set("token", token);
77
79
  confirmationUrl.searchParams.set("email", email);
78
80
  const confirmationLink = confirmationUrl.toString();
81
+ const safeName = escapeHtml(name);
82
+ const safeLink = escapeHtml(confirmationLink);
79
83
  try {
80
84
  await transporter.sendMail({
81
85
  from: options.mailer.defaults?.from || '"Nuxt Users" <noreply@example.com>',
@@ -92,11 +96,11 @@ This link will expire in ${TOKEN_EXPIRATION_HOURS} hours.
92
96
  If you did not create an account, please ignore this email.`,
93
97
  html: `
94
98
  <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
95
- <h2>Welcome, ${name}!</h2>
99
+ <h2>Welcome, ${safeName}!</h2>
96
100
  <p>Thank you for registering! Please click the button below to confirm your email address and activate your account:</p>
97
101
 
98
102
  <div style="text-align: center; margin: 30px 0;">
99
- <a href="${confirmationLink}"
103
+ <a href="${safeLink}"
100
104
  style="background-color: #28a745; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;">
101
105
  Confirm Email Address
102
106
  </a>
@@ -104,7 +108,7 @@ If you did not create an account, please ignore this email.`,
104
108
 
105
109
  <p>Or copy and paste the following link into your browser:</p>
106
110
  <p style="word-break: break-all; background-color: #f8f9fa; padding: 10px; border-radius: 3px;">
107
- <a href="${confirmationLink}">${confirmationLink}</a>
111
+ <a href="${safeLink}">${safeLink}</a>
108
112
  </p>
109
113
 
110
114
  <p style="color: #6c757d; font-size: 14px;">
@@ -127,17 +131,13 @@ export const confirmUserEmail = async (token, email, options) => {
127
131
  const db = await useDb(options);
128
132
  const passwordResetTokensTable = options.tables.passwordResetTokens;
129
133
  const usersTable = options.tables.users;
130
- const tokenRecords = await db.sql`
131
- SELECT * FROM {${passwordResetTokensTable}}
132
- WHERE email = ${email}
133
- ORDER BY created_at DESC
134
- `;
135
- if (tokenRecords.rows.length === 0) {
134
+ const tokenRecords = await findTokenRecordsByEmail(db, passwordResetTokensTable, email);
135
+ if (tokenRecords.length === 0) {
136
136
  console.log(`[Nuxt Users] No confirmation tokens found for email: ${email}`);
137
137
  return false;
138
138
  }
139
139
  let validTokenRecord = null;
140
- for (const record of tokenRecords.rows) {
140
+ for (const record of tokenRecords) {
141
141
  const tokenMatch = await bcrypt.compare(token, record.token);
142
142
  if (tokenMatch) {
143
143
  validTokenRecord = record;
@@ -148,44 +148,7 @@ export const confirmUserEmail = async (token, email, options) => {
148
148
  console.log(`[Nuxt Users] Invalid confirmation token provided for email: ${email}`);
149
149
  return false;
150
150
  }
151
- const now = /* @__PURE__ */ new Date();
152
- const currentTimeString = now.toISOString().slice(0, 19).replace("T", " ");
153
- const createdAtString = validTokenRecord.created_at instanceof Date ? validTokenRecord.created_at.toISOString().slice(0, 19).replace("T", " ") : String(validTokenRecord.created_at);
154
- const [datePart, timePart] = createdAtString.split(/[ T]/);
155
- if (!datePart || !timePart) {
156
- console.log(`[Nuxt Users] Invalid timestamp format for token: ${createdAtString}`);
157
- return false;
158
- }
159
- const [year, month, day] = datePart.split("-").map(Number);
160
- const [hour, minute, second] = timePart.split(":").map(Number);
161
- if (!year || !month || !day || hour === void 0 || minute === void 0 || second === void 0) {
162
- console.log("[Nuxt Users] Invalid timestamp components");
163
- return false;
164
- }
165
- let expirationHour = hour + TOKEN_EXPIRATION_HOURS;
166
- let expirationDay = day;
167
- let expirationMonth = month;
168
- let expirationYear = year;
169
- if (expirationHour >= 24) {
170
- expirationHour -= 24;
171
- expirationDay++;
172
- const daysInMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
173
- if (expirationYear % 4 === 0 && (expirationYear % 100 !== 0 || expirationYear % 400 === 0)) {
174
- daysInMonth[1] = 29;
175
- }
176
- const monthIndex = expirationMonth - 1;
177
- const daysInCurrentMonth = daysInMonth[monthIndex];
178
- if (daysInCurrentMonth && expirationDay > daysInCurrentMonth) {
179
- expirationDay = 1;
180
- expirationMonth++;
181
- if (expirationMonth > 12) {
182
- expirationMonth = 1;
183
- expirationYear++;
184
- }
185
- }
186
- }
187
- const expirationTimeString = `${expirationYear.toString().padStart(4, "0")}-${expirationMonth.toString().padStart(2, "0")}-${expirationDay.toString().padStart(2, "0")} ${expirationHour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}:${second.toString().padStart(2, "0")}`;
188
- if (currentTimeString > expirationTimeString) {
151
+ if (isTokenExpired(validTokenRecord.created_at, validTokenRecord.db_now, TOKEN_EXPIRATION_HOURS)) {
189
152
  console.log(`[Nuxt Users] Expired confirmation token for email: ${email}`);
190
153
  await db.sql`DELETE FROM {${passwordResetTokensTable}} WHERE id = ${validTokenRecord.id}`;
191
154
  return false;
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Escapes a value for safe interpolation into HTML (text content and quoted attributes).
3
+ */
4
+ export declare const escapeHtml: (value: string) => string;
@@ -0,0 +1,8 @@
1
+ const HTML_ESCAPES = {
2
+ "&": "&amp;",
3
+ "<": "&lt;",
4
+ ">": "&gt;",
5
+ '"': "&quot;",
6
+ "'": "&#39;"
7
+ };
8
+ export const escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => HTML_ESCAPES[char]);
@@ -14,7 +14,7 @@ export declare function createGoogleOAuth2Client(options: GoogleOAuthOptions, ca
14
14
  /**
15
15
  * Generate authorization URL for Google OAuth
16
16
  */
17
- export declare function getGoogleAuthUrl(oauth2Client: OAuth2Client, options: GoogleOAuthOptions): string;
17
+ export declare function getGoogleAuthUrl(oauth2Client: OAuth2Client, options: GoogleOAuthOptions, state?: string): string;
18
18
  /**
19
19
  * Exchange authorization code for tokens and get user info
20
20
  */
@@ -9,12 +9,13 @@ export function createGoogleOAuth2Client(options, callbackUrl) {
9
9
  callbackUrl
10
10
  );
11
11
  }
12
- export function getGoogleAuthUrl(oauth2Client, options) {
12
+ export function getGoogleAuthUrl(oauth2Client, options, state) {
13
13
  const scopes = options.scopes || ["openid", "profile", "email"];
14
14
  return oauth2Client.generateAuthUrl({
15
15
  access_type: "offline",
16
16
  scope: scopes,
17
- include_granted_scopes: true
17
+ include_granted_scopes: true,
18
+ ...state && { state }
18
19
  });
19
20
  }
20
21
  export async function getGoogleUserFromCode(oauth2Client, code) {
@@ -8,3 +8,4 @@ export { createMigrationsTable } from './create-migrations-table.js';
8
8
  export { runMigrations, getAppliedMigrations } from './migrate.js';
9
9
  export { createGoogleOAuth2Client, getGoogleAuthUrl, getGoogleUserFromCode, findOrCreateGoogleUser, createAuthTokenForUser, generateSecurePassword } from './google-oauth.js';
10
10
  export { handleUnauthenticatedRequest, handleInvalidToken, handleUnauthorizedRequest } from './authorization.js';
11
+ export { getSiteUrl, SITE_URL_REQUIRED_MESSAGE } from './site-url.js';
@@ -8,3 +8,4 @@ export { createMigrationsTable } from "./create-migrations-table.js";
8
8
  export { runMigrations, getAppliedMigrations } from "./migrate.js";
9
9
  export { createGoogleOAuth2Client, getGoogleAuthUrl, getGoogleUserFromCode, findOrCreateGoogleUser, createAuthTokenForUser, generateSecurePassword } from "./google-oauth.js";
10
10
  export { handleUnauthenticatedRequest, handleInvalidToken, handleUnauthorizedRequest } from "./authorization.js";
11
+ export { getSiteUrl, SITE_URL_REQUIRED_MESSAGE } from "./site-url.js";
@@ -0,0 +1,10 @@
1
+ import { type H3Event } from 'h3';
2
+ import type { ModuleOptions } from 'nuxt-users/utils';
3
+ export declare const SITE_URL_REQUIRED_MESSAGE = "[Nuxt Users] siteUrl is not configured. Set nuxtUsers.siteUrl (or NUXT_NUXT_USERS_SITE_URL) to your public app URL, e.g. https://example.com, so email links are not built from the untrusted Host header.";
4
+ /**
5
+ * Resolves the base URL used for links in emails (password reset, email confirmation).
6
+ *
7
+ * The request's Host / X-Forwarded-Proto headers are attacker-controlled, so they are only
8
+ * used as a fallback outside production. In production `siteUrl` must be configured.
9
+ */
10
+ export declare const getSiteUrl: (event: H3Event, options: ModuleOptions) => string;
@@ -0,0 +1,13 @@
1
+ import { getHeader } from "h3";
2
+ export const SITE_URL_REQUIRED_MESSAGE = "[Nuxt Users] siteUrl is not configured. Set nuxtUsers.siteUrl (or NUXT_NUXT_USERS_SITE_URL) to your public app URL, e.g. https://example.com, so email links are not built from the untrusted Host header.";
3
+ export const getSiteUrl = (event, options) => {
4
+ if (options.siteUrl) {
5
+ return options.siteUrl.replace(/\/+$/, "");
6
+ }
7
+ if (process.env.NODE_ENV === "production") {
8
+ throw new Error(SITE_URL_REQUIRED_MESSAGE);
9
+ }
10
+ const host = getHeader(event, "host") || "localhost:3000";
11
+ const protocol = getHeader(event, "x-forwarded-proto") || "http";
12
+ return `${protocol}://${host}`;
13
+ };
@@ -0,0 +1,27 @@
1
+ import type { Database } from 'db0';
2
+ /**
3
+ * A DB timestamp as returned by the driver: a Date (MySQL, PostgreSQL)
4
+ * or a 'YYYY-MM-DD HH:MM:SS' string (SQLite).
5
+ */
6
+ export type DbTimestamp = Date | string;
7
+ /**
8
+ * Decides whether a token is older than `hours`.
9
+ *
10
+ * `dbNow` must be the database's own CURRENT_TIMESTAMP, read in the same query as `createdAt`:
11
+ * both values then share the database's and the driver's time zone handling, so the difference
12
+ * is correct whatever time zones the database and the Node process use.
13
+ */
14
+ export declare const isTokenExpired: (createdAt: DbTimestamp, dbNow: DbTimestamp, hours: number) => boolean;
15
+ /** A row of the password reset / confirmation token table, with the database's own clock alongside */
16
+ export interface TokenRecord {
17
+ id: number;
18
+ email: string;
19
+ token: string;
20
+ created_at: DbTimestamp;
21
+ db_now: DbTimestamp;
22
+ }
23
+ /**
24
+ * Finds the tokens for an email, newest first. `db_now` is read in the same query so that
25
+ * expiry can be judged on the database's own clock with `isTokenExpired` (issue #220).
26
+ */
27
+ export declare const findTokenRecordsByEmail: (db: Database, table: string, email: string) => Promise<TokenRecord[]>;
@@ -0,0 +1,24 @@
1
+ const toMilliseconds = (value) => {
2
+ if (value instanceof Date) {
3
+ return value.getTime();
4
+ }
5
+ if (typeof value !== "string") {
6
+ return Number.NaN;
7
+ }
8
+ return Date.parse(`${value.replace(" ", "T")}Z`);
9
+ };
10
+ export const isTokenExpired = (createdAt, dbNow, hours) => {
11
+ const ageMs = toMilliseconds(dbNow) - toMilliseconds(createdAt);
12
+ if (Number.isNaN(ageMs)) {
13
+ return true;
14
+ }
15
+ return ageMs > hours * 60 * 60 * 1e3;
16
+ };
17
+ export const findTokenRecordsByEmail = async (db, table, email) => {
18
+ const result = await db.sql`
19
+ SELECT *, CURRENT_TIMESTAMP AS db_now FROM {${table}}
20
+ WHERE email = ${email}
21
+ ORDER BY created_at DESC
22
+ `;
23
+ return result.rows;
24
+ };
@@ -59,8 +59,9 @@ export declare const cleanupPersonalAccessTokens: (options: ModuleOptions, inclu
59
59
  }>;
60
60
  /**
61
61
  * Revokes all tokens for a specific user.
62
+ * Pass `exceptToken` to keep one session (e.g. the one that just changed the password).
62
63
  */
63
- export declare const revokeUserTokens: (userId: number, options: ModuleOptions) => Promise<void>;
64
+ export declare const revokeUserTokens: (userId: number, options: ModuleOptions, exceptToken?: string) => Promise<void>;
64
65
  /**
65
66
  * Gets the last login time for a user based on their most recent personal access token creation.
66
67
  * This represents when the user last successfully logged in.
@@ -226,9 +226,17 @@ export const cleanupPersonalAccessTokens = async (options, includeNoExpiration =
226
226
  console.log(`[Nuxt Users] Token cleanup completed: ${expiredCount} expired + ${noExpirationCount} without expiration = ${totalCount} total tokens removed.`);
227
227
  return { expiredCount, noExpirationCount, totalCount };
228
228
  };
229
- export const revokeUserTokens = async (userId, options) => {
229
+ export const revokeUserTokens = async (userId, options, exceptToken) => {
230
230
  const db = await useDb(options);
231
231
  const personalAccessTokensTable = options.tables.personalAccessTokens;
232
+ if (exceptToken) {
233
+ await db.sql`
234
+ DELETE FROM {${personalAccessTokensTable}}
235
+ WHERE tokenable_type = 'user' AND tokenable_id = ${userId} AND token <> ${exceptToken}
236
+ `;
237
+ console.log(`[Nuxt Users] Other tokens revoked for user ID: ${userId}`);
238
+ return;
239
+ }
232
240
  await db.sql`
233
241
  DELETE FROM {${personalAccessTokensTable}}
234
242
  WHERE tokenable_type = 'user' AND tokenable_id = ${userId}
@@ -1,3 +1,8 @@
1
+ const patternToRegexSource = (pattern) => pattern.split(/(\*\*|\*)/).map((part) => {
2
+ if (part === "**") return ".*";
3
+ if (part === "*") return "[^/]*";
4
+ return part.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
5
+ }).join("");
1
6
  export const pathMatchesPattern = (path, pattern) => {
2
7
  if (path === pattern) {
3
8
  return true;
@@ -5,21 +10,13 @@ export const pathMatchesPattern = (path, pattern) => {
5
10
  if (pattern === "*") {
6
11
  return true;
7
12
  }
8
- if ((pattern.match(/\*/g) || []).length > 1) {
9
- const regexPattern = pattern.replace(/\*/g, "[^/]*");
10
- const regex = new RegExp(`^${regexPattern}`);
11
- return regex.test(path);
12
- }
13
- if (pattern.endsWith("/*")) {
14
- const basePattern = pattern.slice(0, -2);
15
- return path === basePattern || path.startsWith(basePattern + "/");
13
+ if (!pattern.includes("*")) {
14
+ return false;
16
15
  }
17
- if (pattern.includes("*")) {
18
- const regexPattern = pattern.replace(/\*/g, "[^/]*");
19
- const regex = new RegExp(`^${regexPattern}`);
20
- return regex.test(path);
16
+ if (pattern.endsWith("/*") && !pattern.endsWith("/**")) {
17
+ return new RegExp(`^${patternToRegexSource(pattern.slice(0, -2))}(?:/.*)?$`).test(path);
21
18
  }
22
- return false;
19
+ return new RegExp(`^${patternToRegexSource(pattern)}$`).test(path);
23
20
  };
24
21
  export const hasPermission = (userRole, path, method, permissions) => {
25
22
  const safeMethods = ["OPTIONS", "HEAD", "TRACE"];
@@ -98,6 +98,12 @@ interface RuntimeModuleOptions {
98
98
  * Uses nodemailer
99
99
  */
100
100
  mailer?: MailerOptions;
101
+ /**
102
+ * Public base URL of the app (e.g. 'https://example.com'), used to build links in emails
103
+ * (password reset, email confirmation). Required in production: the request Host header
104
+ * is attacker-controlled and is only used as a fallback in development.
105
+ */
106
+ siteUrl?: string;
101
107
  /**
102
108
  * URL path for password reset page
103
109
  * @default '/reset-password'
@@ -225,6 +231,7 @@ interface ModuleOptions {
225
231
  passwordResetTokens: string;
226
232
  };
227
233
  mailer?: MailerOptions;
234
+ siteUrl?: string;
228
235
  passwordResetUrl: string;
229
236
  emailConfirmationUrl: string;
230
237
  auth: {
@@ -98,6 +98,12 @@ interface RuntimeModuleOptions {
98
98
  * Uses nodemailer
99
99
  */
100
100
  mailer?: MailerOptions;
101
+ /**
102
+ * Public base URL of the app (e.g. 'https://example.com'), used to build links in emails
103
+ * (password reset, email confirmation). Required in production: the request Host header
104
+ * is attacker-controlled and is only used as a fallback in development.
105
+ */
106
+ siteUrl?: string;
101
107
  /**
102
108
  * URL path for password reset page
103
109
  * @default '/reset-password'
@@ -225,6 +231,7 @@ interface ModuleOptions {
225
231
  passwordResetTokens: string;
226
232
  };
227
233
  mailer?: MailerOptions;
234
+ siteUrl?: string;
228
235
  passwordResetUrl: string;
229
236
  emailConfirmationUrl: string;
230
237
  auth: {
@@ -24,6 +24,9 @@ const defaultOptions = {
24
24
  from: '"Nuxt Users Module" <noreply@example.com>'
25
25
  }
26
26
  },
27
+ // Empty by default so NUXT_NUXT_USERS_SITE_URL can override it (Nuxt only applies env vars to existing keys);
28
+ // empty falls back to the request host in development and is rejected in production
29
+ siteUrl: "",
27
30
  passwordResetUrl: "/reset-password",
28
31
  emailConfirmationUrl: "/email-confirmation",
29
32
  auth: {
package/dist/utils.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { P as Permission } from './shared/nuxt-users.DtwPhils.mjs';
2
- export { D as DatabaseConfig, a as DatabaseType, b as DisplayFieldsProps, G as GoogleOAuthOptions, L as LocaleMessages, c as LocaleOptions, d as LoginFormData, e as LoginFormProps, M as ModuleOptions, f as PasswordResetToken, g as PersonalAccessToken, h as ResetPasswordFormProps, R as RuntimeModuleOptions, U as User, i as UserWithoutPassword, j as defaultDisplayFields, k as defaultFieldLabels } from './shared/nuxt-users.DtwPhils.mjs';
1
+ import { P as Permission } from './shared/nuxt-users.Bday8snh.mjs';
2
+ export { D as DatabaseConfig, a as DatabaseType, b as DisplayFieldsProps, G as GoogleOAuthOptions, L as LocaleMessages, c as LocaleOptions, d as LoginFormData, e as LoginFormProps, M as ModuleOptions, f as PasswordResetToken, g as PersonalAccessToken, h as ResetPasswordFormProps, R as RuntimeModuleOptions, U as User, i as UserWithoutPassword, j as defaultDisplayFields, k as defaultFieldLabels } from './shared/nuxt-users.Bday8snh.mjs';
3
3
  export { defaultLocaleMessages, en, hu, huFormal } from '../dist/runtime/locales/index.js';
4
4
  export { getNestedValue, getTranslation } from '../dist/runtime/utils/locale.js';
5
5
  export { deepMerge } from '../dist/runtime/utils/deep-merge.js';
package/dist/utils.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { P as Permission } from './shared/nuxt-users.DtwPhils.js';
2
- export { D as DatabaseConfig, a as DatabaseType, b as DisplayFieldsProps, G as GoogleOAuthOptions, L as LocaleMessages, c as LocaleOptions, d as LoginFormData, e as LoginFormProps, M as ModuleOptions, f as PasswordResetToken, g as PersonalAccessToken, h as ResetPasswordFormProps, R as RuntimeModuleOptions, U as User, i as UserWithoutPassword, j as defaultDisplayFields, k as defaultFieldLabels } from './shared/nuxt-users.DtwPhils.js';
1
+ import { P as Permission } from './shared/nuxt-users.Bday8snh.js';
2
+ export { D as DatabaseConfig, a as DatabaseType, b as DisplayFieldsProps, G as GoogleOAuthOptions, L as LocaleMessages, c as LocaleOptions, d as LoginFormData, e as LoginFormProps, M as ModuleOptions, f as PasswordResetToken, g as PersonalAccessToken, h as ResetPasswordFormProps, R as RuntimeModuleOptions, U as User, i as UserWithoutPassword, j as defaultDisplayFields, k as defaultFieldLabels } from './shared/nuxt-users.Bday8snh.js';
3
3
  export { defaultLocaleMessages, en, hu, huFormal } from '../dist/runtime/locales/index.js';
4
4
  export { getNestedValue, getTranslation } from '../dist/runtime/utils/locale.js';
5
5
  export { deepMerge } from '../dist/runtime/utils/deep-merge.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nuxt-users",
3
- "version": "1.49.3",
3
+ "version": "1.50.1",
4
4
  "description": "A comprehensive user management module for Nuxt applications with authentication, authorization, database support, and CLI tools",
5
5
  "keywords": [
6
6
  "nuxt",
@@ -55,7 +55,7 @@
55
55
  "scripts": {
56
56
  "prepack": "nuxt-module-build build && node scripts/post-build.js",
57
57
  "postinstall": "node -e \"try { require('fs').chmodSync('./dist/cli.mjs', '755') } catch(e) {}\" && node scripts/check-peer-dependencies.js",
58
- "predev": "node -v | grep -q 'v22' || (echo 'Please use Node.js v22 for development' && exit 1)",
58
+ "predev": "node -v | grep -qE '^v(22|24)\\.' || (echo 'Please use Node.js v22 or v24 for development' && exit 1)",
59
59
  "dev": "yarn dev:prepare && nuxi dev playground",
60
60
  "dev:build": "nuxi build playground",
61
61
  "dev:prepare": "yarn nuxt-module-build build --stub && yarn nuxt-module-build prepare && nuxi prepare playground",
@@ -65,13 +65,13 @@
65
65
  "check:locale-consistency": "tsx scripts/check-locale-consistency.ts",
66
66
  "check:npm_token": "dotenv -- sh -c 'if [ -z \"$NPM_TOKEN\" ]; then echo \"❌ NPM_TOKEN environment variable is not set in .env file. Please set it to your npm token.\"; exit 1; fi'",
67
67
  "check:npm_auth": "dotenv -- sh -c 'npm whoami --registry https://registry.npmjs.org/ --//registry.npmjs.org/:_authToken=$NPM_TOKEN'",
68
- "release:publish:dry": "dotenv -- sh -c 'npm publish --dry-run --ignore-scripts --registry https://registry.npmjs.org/ --//registry.npmjs.org/:_authToken=$NPM_TOKEN'",
68
+ "release:publish:dry": "npm pack --dry-run --ignore-scripts",
69
69
  "release:preflight": "yarn prerelease && yarn check:npm_token && yarn check:npm_auth && sh scripts/release-preflight.sh",
70
- "prerelease": "node -v | grep -q 'v22' || (echo 'Please use Node.js v22 for deployment' && exit 1)",
71
- "release": "yarn release:preflight && yarn lint && yarn check:translations && yarn check:locale-consistency && yarn test && yarn prepack && yarn release:publish:dry && dotenv -- changelogen --release && dotenv -- sh -c 'npm publish --//registry.npmjs.org/:_authToken=$NPM_TOKEN --registry https://registry.npmjs.org/' && git push --follow-tags",
70
+ "prerelease": "node -v | grep -qE '^v(22|24)\\.' || (echo 'Please use Node.js v22 or v24 for deployment' && exit 1)",
71
+ "release": "yarn release:preflight && yarn lint && yarn check:translations && yarn check:locale-consistency && yarn test && yarn prepack && yarn release:publish:dry && dotenv -- changelogen --release --no-github && dotenv -- sh -c 'npm publish --//registry.npmjs.org/:_authToken=$NPM_TOKEN --registry https://registry.npmjs.org/' && git push --follow-tags && dotenv -- changelogen gh release",
72
72
  "lint": "eslint .",
73
73
  "lint:fix": "eslint . --fix",
74
- "pretest": "node -v | grep -q 'v22' || (echo 'Please use Node.js v22 for testing' && exit 1)",
74
+ "pretest": "node -v | grep -qE '^v(22|24)\\.' || (echo 'Please use Node.js v22 or v24 for testing' && exit 1)",
75
75
  "test:quick": "yarn test:unit && yarn test:sqlite",
76
76
  "test:release": "yarn test && yarn build && yarn lint:fix",
77
77
  "test:types": "yarn dev:prepare && vue-tsc --noEmit --project tsconfig.vitest.json && cd playground && vue-tsc --noEmit",