@lenne.tech/nuxt-extensions 1.15.1 → 1.17.0

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.
package/CLAUDE.md CHANGED
@@ -25,8 +25,8 @@ src/
25
25
 
26
26
  | Composable | Purpose |
27
27
  |-----------|---------|
28
- | `useLtAuth()` | Authentication state, login, logout, session management |
29
- | `useLtAuthClient()` | Raw Better Auth client access |
28
+ | `useLtAuth()` | Authentication state, login, logout, session management, password change + reset |
29
+ | `useLtAuthClient()` | Raw Better Auth client access (`twoFactor.*`, `admin.*`, `passkey`) |
30
30
  | `useSystemSetup()` | First-admin setup flow |
31
31
  | `useLtTusUpload()` | TUS resumable file uploads |
32
32
  | `useLtFile()` | File utilities (size formatting, URLs) |
@@ -179,6 +179,90 @@ export default defineNuxtRouteMiddleware((to) => {
179
179
  });
180
180
  ```
181
181
 
182
+ ## Password Handling: Reset, Change, and Client-Side Hashing
183
+
184
+ **Every password leaves the browser SHA256-hashed.** `auth-client.ts` wraps each Better-Auth
185
+ method that carries a credential and substitutes that one field with its digest;
186
+ `@lenne.tech/nest-server` hashes the received value again on its side. The two ends agree only
187
+ because both do this — which is why a hand-rolled call is not a style choice but a way to lock
188
+ a user out of their own account.
189
+
190
+ ### Use the composable. Never hand-roll the flow.
191
+
192
+ `useLtAuth()` exposes the whole set — `changePassword`, `requestPasswordReset`, `resetPassword`
193
+ (since 1.16.0) — alongside `signIn` / `signUp`. Reach for `useLtAuthClient()` only for surfaces
194
+ the composable does not carry (`twoFactor.*`, `admin.*`, `passkey`); that client hashes too.
195
+
196
+ What went wrong when a project did otherwise: `useLtAuth()` offered `changePassword` but neither
197
+ reset method, so a reset page was built directly against `$fetch`, the client-side hashing was
198
+ forgotten, and the backend stored `scrypt(plaintext)` while sign-in verified
199
+ `scrypt(sha256(plaintext))`. The password the user had just chosen did not work, and nothing in
200
+ either log said why. The gap in this composable is what created that workaround.
201
+
202
+ ```typescript
203
+ const { requestPasswordReset, resetPassword } = useLtAuth();
204
+
205
+ // Step 1 — send the mail. `redirectTo` MUST be absolute (see below).
206
+ await requestPasswordReset({
207
+ email,
208
+ redirectTo: new URL('/auth/reset-password', config.public.siteUrl).toString(),
209
+ });
210
+
211
+ // Step 2 — on the reset page, with the token from the query string.
212
+ await resetPassword({ newPassword, token });
213
+ ```
214
+
215
+ ### `redirectTo` must be absolute, and must never come from user input
216
+
217
+ Better Auth resolves it against the **API** origin (`new URL(callbackURL, ctx.baseURL)`). A
218
+ relative value therefore lands on the API host, where the route does not exist: 403
219
+ `INVALID_REDIRECT_URL`, no mail sent, nothing visible in the browser. It fails silently.
220
+
221
+ **It also carries a live single-use reset token.** An attacker-chosen value is account takeover,
222
+ not a phishing redirect — so never build it from `route.query`, a referrer, or a form field.
223
+ The only thing holding it back is the server's `trustedOrigins` allowlist; a project that
224
+ widens that (or sets a wildcard) removes the last barrier. In lt starter projects `appUrl()`
225
+ (from the starter's `app/utils/app-origin.ts`, not from this package) builds one and throws
226
+ rather than returning something relative; elsewhere use `new URL(path, siteUrl).toString()`.
227
+
228
+ ### Password length is enforced by YOUR form — the server cannot do it
229
+
230
+ better-auth checks `minPasswordLength` against the value it **receives**
231
+ (`api/routes/password.mjs`). Because this library hashes first, that value is always a 64-character
232
+ hex string, so the check passes trivially on every path. nest-server 11.38.0 adds a raw-value
233
+ length check for plaintext clients, and it deliberately skips anything matching `^[a-f0-9]{64}$`
234
+ — i.e. skips us.
235
+
236
+ This is a consequence of client-side hashing, not a bug in either backend, and there is nothing to
237
+ "fix" server-side. **A minimum length must live in your form schema** (the starters use
238
+ `v.minLength(8)` on register, setup and reset). If someone later removes that rule reasoning that
239
+ "the server validates it", nothing will catch it.
240
+
241
+ ### `admin.*` is passed through UNHASHED — an armed trap, not a live one
242
+
243
+ `admin.createUser` (`password`) and `admin.setUserPassword` (`newPassword`) are the only
244
+ credential-carrying methods this library does **not** hash.
245
+
246
+ Today that is harmless: `@lenne.tech/nest-server` does not register better-auth's `admin()`
247
+ plugin and has no option to enable it, so those routes 404. But `auth.enableAdmin` defaults to
248
+ `true`, meaning the admin *client* plugin is registered in every consuming project — the trap is
249
+ armed and waiting for a server that answers.
250
+
251
+ **Enabling `admin()` server-side is therefore a lock-step change across both repos in one
252
+ release:** the two routes join nest-server's password-normalisation table, and these two methods
253
+ get `ltSha256` wrappers here. Doing only the server half creates accounts whose password nobody
254
+ can ever log in with, with no error anywhere. Hashing here pre-emptively is equally wrong — it
255
+ would build a client expectation the server does not answer.
256
+
257
+ ### If you add a wrapper that hashes something
258
+
259
+ Add it to both guards. `test/auth-client-hashing.test.ts` asserts the actual request payload;
260
+ `test/auth-client-param-forwarding.test.ts` keeps the source-level half. A hashing wrapper
261
+ replaces ONE field and forwards the rest — `{ ...params, password: hashed }`. Never
262
+ `{ password: hashed }` (drops `revokeOtherSessions`, `redirectTo`, `method`, `issuer` — all
263
+ shipped defects), and never `{ password: hashed, ...params }`, which looks almost identical and
264
+ puts the **plaintext** back on the wire.
265
+
182
266
  ## Form Label Repair
183
267
 
184
268
  **The defect:** Nuxt UI's `FormField` derives the label's `for` and the control's `id` from one
@@ -257,7 +341,10 @@ fields at `app:beforeMount` and restores them at `app:mounted`, dispatching a sy
257
341
  ## Development Rules
258
342
 
259
343
  1. **ALWAYS read source code** in `dist/runtime/` to understand available composables and components
260
- 2. **Use `useLtAuth()`** for authentication — never implement auth manually
344
+ 2. **Use `useLtAuth()`** for authentication — never implement auth manually. This includes the
345
+ password-reset flow: passwords are SHA256-hashed client-side, and a hand-rolled call that skips
346
+ that step desynchronises the two credential stores and locks the user out (see "Password
347
+ Handling" below).
261
348
  3. **Check existing composables** before creating new ones — this module may already provide what you need
262
349
  4. **Peer dependencies** (`better-auth`, `@better-auth/passkey`, `tus-js-client`) must be installed in the consuming project.
263
350
  `better-auth` and `@better-auth/passkey` are pinned to **`>=1.7.1 <1.8.0`** — deliberately not a caret.
package/README.md CHANGED
@@ -363,6 +363,9 @@ const {
363
363
  authenticateWithPasskey,
364
364
  registerPasskey,
365
365
  twoFactor,
366
+ changePassword,
367
+ requestPasswordReset,
368
+ resetPassword,
366
369
  } = useLtAuth();
367
370
 
368
371
  // Login with email/password
@@ -426,6 +429,50 @@ const { hasRole, hasAnyRole } = useLtAuth();
426
429
  </template>
427
430
  ```
428
431
 
432
+ ### Password Change and Reset
433
+
434
+ All three methods hash the password with SHA256 before it leaves the browser, because
435
+ `@lenne.tech/nest-server` verifies that shape. **Use them rather than calling the endpoints
436
+ yourself** — a hand-rolled reset that skips the hashing stores a credential the login can never
437
+ match, and the user is locked out of the account they just set a password for. That is not
438
+ hypothetical; it is why these two methods were added in 1.16.0.
439
+
440
+ ```vue
441
+ <script setup lang="ts">
442
+ const { changePassword, requestPasswordReset, resetPassword } = useLtAuth();
443
+ const config = useRuntimeConfig();
444
+
445
+ // Signed-in user changes their own password.
446
+ // `revokeOtherSessions` ends every OTHER session — set it after a suspected compromise.
447
+ await changePassword({ currentPassword, newPassword, revokeOtherSessions: true });
448
+
449
+ // Step 1: send the reset mail.
450
+ await requestPasswordReset({
451
+ email,
452
+ redirectTo: new URL('/auth/reset-password', config.public.siteUrl).toString(),
453
+ });
454
+
455
+ // Step 2: on the reset page, with the token from the query string.
456
+ await resetPassword({ newPassword, token: route.query.token as string });
457
+ </script>
458
+ ```
459
+
460
+ **`redirectTo` must be an absolute URL.** Better Auth resolves it against the **API** origin, so
461
+ a relative value lands on the API host where the route does not exist — a 403
462
+ `INVALID_REDIRECT_URL`, no mail sent, and nothing visible in the browser. In an lt starter
463
+ project the auto-imported `appUrl()` helper builds one and throws rather than returning something
464
+ relative; that helper lives in the starter (`app/utils/app-origin.ts`), not in this package, so
465
+ outside a starter use `new URL(path, siteUrl).toString()` as above.
466
+
467
+ **Never build `redirectTo` from user input** (`route.query`, a referrer, a form field). The link
468
+ carries a live single-use reset token, so an attacker-chosen destination is account takeover
469
+ rather than a phishing redirect. The server's `trustedOrigins` allowlist is what stops it.
470
+
471
+ **Enforce a minimum password length in your own form.** better-auth checks `minPasswordLength`
472
+ against the value it receives, and because this library hashes first, that value is always a
473
+ 64-character hex string — the check passes on every path. The form is the only place the rule can
474
+ live.
475
+
429
476
  ### Custom Better Auth Plugins
430
477
 
431
478
  You can extend the auth client with additional [Better Auth plugins](https://www.better-auth.com/docs/plugins):
@@ -655,8 +702,8 @@ The package works **with or without** `@nuxtjs/i18n`:
655
702
 
656
703
  | Composable | Description |
657
704
  |------------|-------------|
658
- | `useLtAuth()` | Better-Auth integration with session, passkey, 2FA |
659
- | `useLtAuthClient()` | Direct access to the Better-Auth client singleton |
705
+ | `useLtAuth()` | Better-Auth integration with session, passkey, 2FA, password change + reset |
706
+ | `useLtAuthClient()` | Direct access to the Better-Auth client singleton (`twoFactor.*`, `admin.*`, `passkey`) |
660
707
  | `useLtErrorTranslation()` | Translate backend error codes to user-friendly messages |
661
708
  | `useLtTusUpload()` | TUS protocol file uploads with pause/resume |
662
709
  | `useLtFile()` | File utilities (size formatting, URLs) |
package/dist/module.d.mts CHANGED
@@ -3,7 +3,7 @@ import { LtExtensionsModuleOptions } from '../dist/runtime/types/index.js';
3
3
  export * from '../dist/runtime/types/index.js';
4
4
 
5
5
  declare const name = "@lenne.tech/nuxt-extensions";
6
- declare const version = "1.15.1";
6
+ declare const version = "1.17.0";
7
7
  declare const configKey = "ltExtensions";
8
8
  declare const DEFAULT_LT_AUTH_STATE_COOKIE = "lt-auth-state";
9
9
  declare const DEFAULT_LT_JWT_TOKEN_COOKIE = "lt-jwt-token";
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "configKey": "ltExtensions",
3
3
  "name": "@lenne.tech/nuxt-extensions",
4
- "version": "1.15.1",
4
+ "version": "1.17.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.3",
7
7
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -2,7 +2,7 @@ import { defineNuxtModule, createResolver, tryResolveModule, addImports, addComp
2
2
  import { pathToFileURL } from 'node:url';
3
3
 
4
4
  const name = "@lenne.tech/nuxt-extensions";
5
- const version = "1.15.1";
5
+ const version = "1.17.0";
6
6
  const configKey = "ltExtensions";
7
7
  const DEFAULT_LT_AUTH_STATE_COOKIE = "lt-auth-state";
8
8
  const DEFAULT_LT_JWT_TOKEN_COOKIE = "lt-jwt-token";
@@ -201,7 +201,6 @@ export function useLtAuth() {
201
201
  }
202
202
  const signIn = {
203
203
  ...authClient.signIn,
204
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
205
204
  email: async (params, options) => {
206
205
  isLoading.value = true;
207
206
  try {
@@ -227,7 +226,6 @@ export function useLtAuth() {
227
226
  };
228
227
  const signUp = {
229
228
  ...authClient.signUp,
230
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
231
229
  email: async (params, options) => {
232
230
  isLoading.value = true;
233
231
  try {
@@ -382,7 +380,6 @@ export function useLtAuth() {
382
380
  timeout: options.timeout,
383
381
  attestation: options.attestation,
384
382
  authenticatorSelection: options.authenticatorSelection,
385
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
386
383
  excludeCredentials: (options.excludeCredentials || []).map((cred) => ({
387
384
  ...cred,
388
385
  id: ltBase64UrlToUint8Array(cred.id).buffer
@@ -479,6 +476,21 @@ export function useLtAuth() {
479
476
  changePassword: authClient.changePassword,
480
477
  clearUser,
481
478
  registerPasskey,
479
+ // The reset pair, exposed so no project has to reach for `useLtAuthClient()` or hand-roll
480
+ // the flow — the one that did forgot the client-side hashing and desynchronised the two
481
+ // credential stores. The full contract, including why `redirectTo` must be absolute and
482
+ // must never come from user input, lives on `UseLtAuthReturn` in `runtime/types/auth.ts`:
483
+ // that is the surface consumers read, and one copy cannot drift from itself.
484
+ //
485
+ // `params` is forwarded WHOLE and stays fully type-checked. Only `options` is cast, and
486
+ // only because `UseLtAuthReturn` declares it `unknown` while Better-Auth wants its own
487
+ // `ClientFetchOption`. Casting the METHOD instead — which is what this used to do — would
488
+ // throw away the checking on `params` as collateral, on the one argument that carries a
489
+ // credential. The `Parameters<>` here reads the client's option type INSIDE the
490
+ // implementation; it never reaches the published declarations, so it does not recreate
491
+ // the better-auth coupling that 1.15.1 had to revert.
492
+ requestPasswordReset: (params, options) => authClient.requestPasswordReset(params, options),
493
+ resetPassword: (params, options) => authClient.resetPassword(params, options),
482
494
  setUser,
483
495
  signIn,
484
496
  signOut,
@@ -59,6 +59,10 @@ export function useLtErrorTranslation() {
59
59
  return errorOrMessage;
60
60
  }
61
61
  if (errorOrMessage instanceof Error) {
62
+ const body = errorOrMessage.data;
63
+ if (typeof body?.message === "string" && body.message) {
64
+ return body.message;
65
+ }
62
66
  return errorOrMessage.message;
63
67
  }
64
68
  if (typeof errorOrMessage === "object" && errorOrMessage !== null) {