@lenne.tech/nuxt-extensions 1.15.0 → 1.16.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.0";
6
+ declare const version = "1.16.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.0",
4
+ "version": "1.16.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.0";
5
+ const version = "1.16.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,
@@ -6,6 +6,34 @@
6
6
  *
7
7
  * SECURITY: Passwords are hashed with SHA256 client-side to prevent
8
8
  * plain text password transmission over the network.
9
+ *
10
+ * ---
11
+ *
12
+ * A HASHING WRAPPER REPLACES ONE FIELD — IT IS NEVER A WHITELIST.
13
+ *
14
+ * This is the invariant every wrapper below shares, and it has been broken twice, so it
15
+ * lives here rather than beside one method that a refactor could delete.
16
+ *
17
+ * Each wrapper exists to substitute ONE field with its SHA256 digest. It must forward
18
+ * everything else the caller passed, untouched:
19
+ *
20
+ * { ...params, password: hashedPassword } // correct
21
+ * { password: hashedPassword } // WRONG — drops every other option
22
+ * { password: hashedPassword, ...params } // WRONG — puts the PLAINTEXT back
23
+ *
24
+ * The first mistake is silent data loss: `changePassword` dropped `revokeOtherSessions`,
25
+ * so callers who set it after a suspected compromise got a successful password change and
26
+ * every other session left open. `twoFactor.enable` dropped `method` and `issuer`, so
27
+ * `method: 'otp'` quietly enabled TOTP instead.
28
+ *
29
+ * The second is worse and looks almost identical: `params` still carries the raw value, so
30
+ * a trailing spread overwrites the digest and the plaintext password goes on the wire.
31
+ * Trailing-spread is the more common JS idiom, which is exactly why this warning is here.
32
+ *
33
+ * Both are pinned by tests: `test/auth-client-hashing.test.ts` asserts the real request
34
+ * payload (it catches both mistakes), `test/auth-client-param-forwarding.test.ts` keeps the
35
+ * source-level half so a whitelist rebuild is visible in review. If you add a wrapper that
36
+ * hashes something, add it to both.
9
37
  */
10
38
  import type { LtAuthClientConfig } from '../types/index.js';
11
39
  /**
@@ -184,8 +212,28 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
184
212
  }>;
185
213
  }>;
186
214
  };
187
- passkey: Record<string, unknown> | undefined;
188
- admin: Record<string, unknown> | undefined;
215
+ passkey: any;
216
+ /**
217
+ * Better-Auth admin plugin, passed through UNWRAPPED — and therefore UNHASHED.
218
+ *
219
+ * `admin.createUser` (`password`) and `admin.setUserPassword` (`newPassword`) carry
220
+ * credentials that every other method in this file hashes. These two do not.
221
+ *
222
+ * Why that is deliberate rather than an oversight: `@lenne.tech/nest-server` does not
223
+ * register better-auth's `admin()` plugin and offers no option to — the two routes do
224
+ * not exist server-side, so a call 404s today. Hashing here would build a client
225
+ * expectation the server does not answer, and if the plugin were later enabled without
226
+ * normalising those routes, this wrapper would CREATE the credential-store desync it
227
+ * was meant to prevent.
228
+ *
229
+ * What that costs, stated plainly: `auth.enableAdmin` defaults to `true`, so the admin
230
+ * CLIENT plugin is registered in every consuming project. The trap is armed and waiting
231
+ * for a server that answers. Enabling `admin()` server-side is therefore a LOCKSTEP
232
+ * change — the routes join nest-server's password-normalisation table and these two
233
+ * methods get `ltSha256` wrappers here, in one release. Half of it produces accounts
234
+ * whose password nobody can log in with, and nothing warns.
235
+ */
236
+ admin: any;
189
237
  $Infer: {
190
238
  Session: {
191
239
  user: import("better-auth").StripEmptyObjects<{
@@ -297,12 +345,44 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
297
345
  message?: string | undefined;
298
346
  }, FetchOptions["throw"] extends true ? true : false>>;
299
347
  /**
300
- * Change password for an authenticated user (both passwords are hashed)
348
+ * Change password for an authenticated user (both passwords are hashed).
349
+ *
350
+ * `revokeOtherSessions` is the option that made the whitelist bug matter — see the
351
+ * file header.
301
352
  */
302
- changePassword: (params: {
353
+ changePassword: <T extends {
303
354
  currentPassword: string;
304
355
  newPassword: string;
305
- }, options?: unknown) => Promise<{
356
+ }>(params: T, options?: any) => Promise<(Omit<{
357
+ token: string | null;
358
+ user: {
359
+ id: string;
360
+ createdAt: Date;
361
+ updatedAt: Date;
362
+ email: string;
363
+ emailVerified: boolean;
364
+ name: string;
365
+ image?: string | null | undefined;
366
+ } & Record<string, any> & {
367
+ id: string;
368
+ createdAt: Date;
369
+ updatedAt: Date;
370
+ email: string;
371
+ emailVerified: boolean;
372
+ name: string;
373
+ image?: string | null | undefined;
374
+ };
375
+ }, "user"> & {
376
+ user: import("better-auth").StripEmptyObjects<{
377
+ id: string;
378
+ createdAt: Date;
379
+ updatedAt: Date;
380
+ email: string;
381
+ emailVerified: boolean;
382
+ name: string;
383
+ image?: string | null | undefined;
384
+ }>;
385
+ }) | {
306
386
  data: Omit<{
307
387
  token: string | null;
308
388
  user: {
@@ -344,12 +424,17 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
344
424
  };
345
425
  }>;
346
426
  /**
347
- * Reset password with token (new password is hashed before sending)
427
+ * Reset password with token (new password is hashed before sending).
428
+ *
429
+ * Spread first, then overwrite the one field this wrapper exists for — see the file
430
+ * header for why the order is not a style question.
348
431
  */
349
- resetPassword: (params: {
432
+ resetPassword: <T extends {
350
433
  newPassword: string;
351
434
  token: string;
352
- }, options?: unknown) => Promise<{
435
+ }>(params: T, options?: any) => Promise<{
436
+ status: boolean;
437
+ } | {
353
438
  data: {
354
439
  status: boolean;
355
440
  };
@@ -367,11 +452,34 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
367
452
  /**
368
453
  * Sign in with email and password (password is hashed before sending)
369
454
  */
370
- email: (params: {
455
+ email: <T extends {
371
456
  email: string;
372
457
  password: string;
373
458
  rememberMe?: boolean;
374
- }, options?: unknown) => Promise<{
459
+ }>(params: T, options?: any) => Promise<(Omit<{
460
+ redirect: boolean;
461
+ token: string;
462
+ url?: string | undefined;
463
+ user: {
464
+ id: string;
465
+ createdAt: Date;
466
+ updatedAt: Date;
467
+ email: string;
468
+ emailVerified: boolean;
469
+ name: string;
470
+ image?: string | null | undefined | undefined;
471
+ };
472
+ }, "user"> & {
473
+ user: import("better-auth").StripEmptyObjects<{
474
+ id: string;
475
+ createdAt: Date;
476
+ updatedAt: Date;
477
+ email: string;
478
+ emailVerified: boolean;
479
+ name: string;
480
+ image?: string | null | undefined;
481
+ }>;
482
+ }) | {
375
483
  data: Omit<{
376
484
  redirect: boolean;
377
485
  token: string;
@@ -410,7 +518,7 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
410
518
  * Sign in with passkey (pass through to base client - provided by passkeyClient plugin)
411
519
  * @see https://www.better-auth.com/docs/plugins/passkey
412
520
  */
413
- passkey: ((...args: unknown[]) => Promise<unknown>) | undefined;
521
+ passkey: any;
414
522
  social: <FetchOptions extends import("better-auth").ClientFetchOption<Partial<{
415
523
  provider: (string & {}) | "linear" | "huggingface" | "github" | "apple" | "atlassian" | "cognito" | "discord" | "facebook" | "figma" | "microsoft" | "google" | "slack" | "spotify" | "twitch" | "twitter" | "dropbox" | "kick" | "linkedin" | "gitlab" | "tiktok" | "reddit" | "roblox" | "salesforce" | "vk" | "zoom" | "notion" | "kakao" | "naver" | "line" | "paybin" | "paypal" | "polar" | "railway" | "vercel" | "wechat";
416
524
  callbackURL?: string | undefined;
@@ -519,11 +627,53 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
519
627
  /**
520
628
  * Sign up with email and password (password is hashed before sending)
521
629
  */
522
- email: (params: {
630
+ email: <T extends {
523
631
  email: string;
524
632
  name: string;
525
633
  password: string;
526
- } & Record<string, unknown>, options?: unknown) => Promise<{
634
+ }>(params: T, options?: any) => Promise<(Omit<{
635
+ token: null;
636
+ user: {
637
+ id: string;
638
+ createdAt: Date;
639
+ updatedAt: Date;
640
+ email: string;
641
+ emailVerified: boolean;
642
+ name: string;
643
+ image?: string | null | undefined | undefined;
644
+ };
645
+ }, "user"> & {
646
+ user: import("better-auth").StripEmptyObjects<{
647
+ id: string;
648
+ createdAt: Date;
649
+ updatedAt: Date;
650
+ email: string;
651
+ emailVerified: boolean;
652
+ name: string;
653
+ image?: string | null | undefined;
654
+ }>;
655
+ }) | (Omit<{
656
+ token: string;
657
+ user: {
658
+ id: string;
659
+ createdAt: Date;
660
+ updatedAt: Date;
661
+ email: string;
662
+ emailVerified: boolean;
663
+ name: string;
664
+ image?: string | null | undefined | undefined;
665
+ };
666
+ }, "user"> & {
667
+ user: import("better-auth").StripEmptyObjects<{
668
+ id: string;
669
+ createdAt: Date;
670
+ updatedAt: Date;
671
+ email: string;
672
+ emailVerified: boolean;
673
+ name: string;
674
+ image?: string | null | undefined;
675
+ }>;
676
+ }) | {
527
677
  data: (Omit<{
528
678
  token: null;
529
679
  user: {
@@ -578,34 +728,7 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
578
728
  };
579
729
  }>;
580
730
  };
581
- twoFactor: {
582
- /**
583
- * Disable 2FA (password is hashed before sending)
584
- */
585
- disable: (params: {
586
- password: string;
587
- }, options?: unknown) => Promise<unknown>;
588
- /**
589
- * Enable 2FA (password is hashed before sending)
590
- */
591
- enable: (params: {
592
- password: string;
593
- }, options?: unknown) => Promise<unknown>;
594
- /**
595
- * Generate backup codes (password is hashed before sending)
596
- */
597
- generateBackupCodes: (params: {
598
- password: string;
599
- }, options?: unknown) => Promise<unknown>;
600
- /**
601
- * Verify TOTP code (pass through to base client)
602
- */
603
- verifyTotp: ((...args: unknown[]) => Promise<unknown>) | undefined;
604
- /**
605
- * Verify backup code (pass through to base client)
606
- */
607
- verifyBackupCode: ((...args: unknown[]) => Promise<unknown>) | undefined;
608
- };
731
+ twoFactor: any;
609
732
  verifyEmail: <FetchOptions extends import("better-auth").ClientFetchOption<never, Partial<{
610
733
  token: string;
611
734
  callbackURL?: string | undefined;
@@ -78,32 +78,65 @@ export function createLtAuthClient(config = {}) {
78
78
  },
79
79
  plugins
80
80
  });
81
- const withPlugins = baseClient;
82
- const signInWithPlugins = baseClient.signIn;
83
81
  return {
84
82
  // Spread all base client properties and methods
85
83
  ...baseClient,
86
84
  // Explicitly pass through methods not captured by spread operator
87
85
  useSession: baseClient.useSession,
88
- passkey: withPlugins.passkey,
89
- admin: withPlugins.admin,
86
+ passkey: baseClient.passkey,
87
+ /**
88
+ * Better-Auth admin plugin, passed through UNWRAPPED — and therefore UNHASHED.
89
+ *
90
+ * `admin.createUser` (`password`) and `admin.setUserPassword` (`newPassword`) carry
91
+ * credentials that every other method in this file hashes. These two do not.
92
+ *
93
+ * Why that is deliberate rather than an oversight: `@lenne.tech/nest-server` does not
94
+ * register better-auth's `admin()` plugin and offers no option to — the two routes do
95
+ * not exist server-side, so a call 404s today. Hashing here would build a client
96
+ * expectation the server does not answer, and if the plugin were later enabled without
97
+ * normalising those routes, this wrapper would CREATE the credential-store desync it
98
+ * was meant to prevent.
99
+ *
100
+ * What that costs, stated plainly: `auth.enableAdmin` defaults to `true`, so the admin
101
+ * CLIENT plugin is registered in every consuming project. The trap is armed and waiting
102
+ * for a server that answers. Enabling `admin()` server-side is therefore a LOCKSTEP
103
+ * change — the routes join nest-server's password-normalisation table and these two
104
+ * methods get `ltSha256` wrappers here, in one release. Half of it produces accounts
105
+ * whose password nobody can log in with, and nothing warns.
106
+ */
107
+ admin: baseClient.admin,
90
108
  $Infer: baseClient.$Infer,
91
109
  $fetch: baseClient.$fetch,
92
110
  $store: baseClient.$store,
111
+ // Deliberately a bare passthrough: it takes an email address, never a password, so
112
+ // there is nothing to hash — and passing it through untouched is what keeps
113
+ // `redirectTo` intact (see the note above `resetPassword`).
93
114
  requestPasswordReset: baseClient.requestPasswordReset,
94
115
  /**
95
- * Change password for an authenticated user (both passwords are hashed)
116
+ * Change password for an authenticated user (both passwords are hashed).
117
+ *
118
+ * `revokeOtherSessions` is the option that made the whitelist bug matter — see the
119
+ * file header.
96
120
  */
97
121
  changePassword: async (params, options) => {
98
122
  const [hashedCurrent, hashedNew] = await Promise.all([ltSha256(params.currentPassword), ltSha256(params.newPassword)]);
99
- return baseClient.changePassword?.({ currentPassword: hashedCurrent, newPassword: hashedNew }, options);
123
+ if (!baseClient.changePassword) {
124
+ throw new Error("[lt-auth] changePassword is unavailable on the Better-Auth client \u2014 the password was NOT changed.");
125
+ }
126
+ return baseClient.changePassword({ ...params, currentPassword: hashedCurrent, newPassword: hashedNew }, options);
100
127
  },
101
128
  /**
102
- * Reset password with token (new password is hashed before sending)
129
+ * Reset password with token (new password is hashed before sending).
130
+ *
131
+ * Spread first, then overwrite the one field this wrapper exists for — see the file
132
+ * header for why the order is not a style question.
103
133
  */
104
134
  resetPassword: async (params, options) => {
105
135
  const hashedPassword = await ltSha256(params.newPassword);
106
- return baseClient.resetPassword?.({ newPassword: hashedPassword, token: params.token }, options);
136
+ if (!baseClient.resetPassword) {
137
+ throw new Error("[lt-auth] resetPassword is unavailable on the Better-Auth client \u2014 the password was NOT changed.");
138
+ }
139
+ return baseClient.resetPassword({ ...params, newPassword: hashedPassword }, options);
107
140
  },
108
141
  // Override signIn to hash password (keep passkey method from plugin)
109
142
  signIn: {
@@ -119,7 +152,7 @@ export function createLtAuthClient(config = {}) {
119
152
  * Sign in with passkey (pass through to base client - provided by passkeyClient plugin)
120
153
  * @see https://www.better-auth.com/docs/plugins/passkey
121
154
  */
122
- passkey: signInWithPlugins.passkey
155
+ passkey: baseClient.signIn.passkey
123
156
  },
124
157
  // Explicitly pass through signOut (not captured by spread operator)
125
158
  signOut: baseClient.signOut,
@@ -136,36 +169,36 @@ export function createLtAuthClient(config = {}) {
136
169
  },
137
170
  // Override twoFactor to hash passwords (provided by twoFactorClient plugin)
138
171
  twoFactor: {
139
- ...withPlugins.twoFactor,
172
+ ...baseClient.twoFactor,
140
173
  /**
141
174
  * Disable 2FA (password is hashed before sending)
142
175
  */
143
176
  disable: async (params, options) => {
144
177
  const hashedPassword = await ltSha256(params.password);
145
- return withPlugins.twoFactor?.disable({ password: hashedPassword }, options);
178
+ return baseClient.twoFactor.disable({ ...params, password: hashedPassword }, options);
146
179
  },
147
180
  /**
148
181
  * Enable 2FA (password is hashed before sending)
149
182
  */
150
183
  enable: async (params, options) => {
151
184
  const hashedPassword = await ltSha256(params.password);
152
- return withPlugins.twoFactor?.enable({ password: hashedPassword }, options);
185
+ return baseClient.twoFactor.enable({ ...params, password: hashedPassword }, options);
153
186
  },
154
187
  /**
155
188
  * Generate backup codes (password is hashed before sending)
156
189
  */
157
190
  generateBackupCodes: async (params, options) => {
158
191
  const hashedPassword = await ltSha256(params.password);
159
- return withPlugins.twoFactor?.generateBackupCodes({ password: hashedPassword }, options);
192
+ return baseClient.twoFactor.generateBackupCodes({ ...params, password: hashedPassword }, options);
160
193
  },
161
194
  /**
162
195
  * Verify TOTP code (pass through to base client)
163
196
  */
164
- verifyTotp: withPlugins.twoFactor?.verifyTotp,
197
+ verifyTotp: baseClient.twoFactor.verifyTotp,
165
198
  /**
166
199
  * Verify backup code (pass through to base client)
167
200
  */
168
- verifyBackupCode: withPlugins.twoFactor?.verifyBackupCode
201
+ verifyBackupCode: baseClient.twoFactor.verifyBackupCode
169
202
  }
170
203
  };
171
204
  }
@@ -133,7 +133,6 @@ export default (nuxtApp) => {
133
133
  globalThis.$fetch = ((url, options) => {
134
134
  return originalFetch(url, {
135
135
  ...options,
136
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
137
136
  onResponseError: (context) => {
138
137
  if (options?.onResponseError) {
139
138
  options.onResponseError(context);
@@ -102,6 +102,31 @@ export interface LtPasskeyRegisterResult {
102
102
  /**
103
103
  * Return type for useLtAuth composable
104
104
  */
105
+ /**
106
+ * Accepts any caller-supplied shape while refusing a stray plaintext credential field.
107
+ *
108
+ * WHY A GENERIC AND NOT `& Record<string, unknown>`
109
+ *
110
+ * TypeScript gives a `type` alias an implicit index signature and an `interface` deliberately none
111
+ * (declaration merging could add anything later). So an intersection with `Record<string, unknown>`
112
+ * REJECTS an interface-typed variable — and `app/interfaces/*.interface.ts` plus `Ref<Form>.value`
113
+ * are the dominant shapes in this stack. That is how 1.15.0 broke consuming projects; the same
114
+ * mistake in a different costume is not worth repeating.
115
+ *
116
+ * A generic parameter carries every shape and keeps the excess-property check, which matters:
117
+ * widening these signatures to forward caller options also removed the compiler's objection to
118
+ * `changePassword({ …, password: plaintext })` — a plaintext credential that would land in the
119
+ * request body and from there in proxy logs, APM body capture and error reporters. Naming the
120
+ * foreign credential keys `never` puts that objection back without closing the passthrough.
121
+ *
122
+ * @typeParam T - whatever the caller actually passes
123
+ * @typeParam Own - the credential keys this method legitimately carries
124
+ */
125
+ type NoStrayCredential<T, Own extends string> = Omit<{
126
+ currentPassword?: never;
127
+ newPassword?: never;
128
+ password?: never;
129
+ }, Own> & T;
105
130
  export interface UseLtAuthReturn {
106
131
  authMode: ComputedRef<LtAuthMode>;
107
132
  isAuthenticated: ComputedRef<boolean>;
@@ -157,33 +182,95 @@ export interface UseLtAuthReturn {
157
182
  features: ComputedRef<Record<string, boolean | number | string[]>>;
158
183
  fetchFeatures: () => Promise<Record<string, boolean | number | string[]>>;
159
184
  authenticateWithPasskey: () => Promise<LtPasskeyAuthResult>;
160
- changePassword: (params: {
185
+ /**
186
+ * Change the password of the signed-in user. Both passwords are hashed client-side.
187
+ *
188
+ * Pass `revokeOtherSessions: true` after a suspected compromise — Better Auth reads it on the
189
+ * `update-user` route and ends every other session. Until 1.16.0 the wrapper silently dropped
190
+ * it, so a caller believed foreign sessions were ended when they were not.
191
+ */
192
+ changePassword: <T extends {
161
193
  currentPassword: string;
162
194
  newPassword: string;
163
- }, options?: unknown) => Promise<unknown>;
195
+ }>(params: NoStrayCredential<T, 'currentPassword' | 'newPassword'>, options?: unknown) => Promise<unknown>;
164
196
  clearUser: () => void;
165
197
  fetchWithAuth: (url: string, options?: RequestInit) => Promise<Response>;
166
198
  refreshJwtToken: () => Promise<boolean>;
167
199
  registerPasskey: (name?: string) => Promise<LtPasskeyRegisterResult>;
200
+ /**
201
+ * Ask for a password-reset mail.
202
+ *
203
+ * `redirectTo` MUST be an absolute app URL — Better Auth resolves it against the API
204
+ * origin, so a relative value lands on the API host, the route does not exist, and the
205
+ * answer is a 403 with no mail sent. In an lt starter project `appUrl()` builds one; elsewhere
206
+ * construct it yourself, e.g. `new URL('/auth/reset-password', config.public.siteUrl).toString()`.
207
+ *
208
+ * **SECURITY: never build `redirectTo` from `route.query`, a referrer, or a form field.** The
209
+ * reset redirect carries a live single-use token, so an attacker-controlled value is account
210
+ * takeover rather than phishing. What holds it back is `trustedOrigins` rejecting foreign
211
+ * origins — a project that widens that removes the last barrier.
212
+ *
213
+ * From `@lenne.tech/nest-server` 11.38.0 the reset mail links straight to the app, so
214
+ * `redirectTo` is no longer consulted on the default path. It stays load-bearing where a project
215
+ * sets `betterAuth.emailVerification.passwordResetLink: false` to keep Better Auth's own link.
216
+ *
217
+ * Generic rather than a closed shape: this is a passthrough, and a closed shape would push
218
+ * callers back to the raw client for any option the library has not enumerated yet — which is
219
+ * how hand-rolled reset flows start.
220
+ *
221
+ * @example
222
+ * const { requestPasswordReset } = useLtAuth();
223
+ * await requestPasswordReset({
224
+ * email,
225
+ * redirectTo: new URL('/auth/reset-password', config.public.siteUrl).toString(),
226
+ * });
227
+ * @see {@link UseLtAuthReturn.resetPassword} — step two, with the token from the query string.
228
+ */
229
+ requestPasswordReset: <T extends {
230
+ email: string;
231
+ }>(params: NoStrayCredential<T, never>, options?: unknown) => Promise<unknown>;
232
+ /**
233
+ * Set a new password from a reset token. The password is hashed client-side.
234
+ *
235
+ * **The minimum-length rule has to live in your form.** Better Auth checks `minPasswordLength`
236
+ * against the value it RECEIVES, and this client hashes first — so the server sees 64 characters
237
+ * whatever the user typed, on every version. That is a consequence of client-side hashing, not
238
+ * of any particular server release. The lt starter's `reset-password.vue`, `register.vue` and
239
+ * `setup.vue` declare `v.minLength(8)`; a project using this package without those forms has to
240
+ * add it.
241
+ *
242
+ * Takes `{ newPassword, token }` and nothing else — better-auth's `/reset-password` body schema
243
+ * carries no `redirectTo`, so the absolute-URL trap applies one step earlier, on
244
+ * {@link UseLtAuthReturn.requestPasswordReset}.
245
+ *
246
+ * @example
247
+ * const { resetPassword } = useLtAuth();
248
+ * await resetPassword({ newPassword, token: route.query.token as string });
249
+ */
250
+ resetPassword: <T extends {
251
+ newPassword: string;
252
+ token: string;
253
+ }>(params: NoStrayCredential<T, 'newPassword'>, options?: unknown) => Promise<unknown>;
168
254
  setUser: (userData: LtUser | null, mode?: LtAuthMode) => void;
169
255
  signIn: {
170
- email: (params: {
256
+ email: <T extends {
171
257
  email: string;
172
258
  password: string;
173
259
  rememberMe?: boolean;
174
- }, options?: unknown) => Promise<unknown>;
260
+ }>(params: NoStrayCredential<T, 'password'>, options?: unknown) => Promise<unknown>;
175
261
  passkey?: (options?: unknown) => Promise<unknown>;
176
262
  };
177
263
  signOut: (options?: unknown) => Promise<unknown>;
178
264
  signUp: {
179
- email: (params: {
265
+ email: <T extends {
180
266
  email: string;
181
267
  name: string;
182
268
  password: string;
183
- } & Record<string, unknown>, options?: unknown) => Promise<unknown>;
269
+ }>(params: NoStrayCredential<T, 'password'>, options?: unknown) => Promise<unknown>;
184
270
  };
185
271
  switchToJwtMode: () => Promise<boolean>;
186
272
  validateSession: () => Promise<boolean>;
187
273
  passkey?: unknown;
188
274
  twoFactor?: unknown;
189
275
  }
276
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nuxt-extensions",
3
- "version": "1.15.0",
3
+ "version": "1.16.0",
4
4
  "description": "Reusable Nuxt 4 composables, components, and Better-Auth integration for lenne.tech projects",
5
5
  "repository": {
6
6
  "type": "git",