@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 +90 -3
- package/README.md +49 -2
- package/dist/module.d.mts +1 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +1 -1
- package/dist/runtime/composables/auth/use-lt-auth.js +15 -3
- package/dist/runtime/composables/use-lt-error-translation.js +4 -0
- package/dist/runtime/lib/auth-client.d.ts +157 -574
- package/dist/runtime/lib/auth-client.js +76 -23
- package/dist/runtime/plugins/auth-interceptor.client.js +0 -1
- package/dist/runtime/types/auth.d.ts +93 -6
- package/dist/runtime/types/error.d.ts +26 -1
- package/package.json +4 -4
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.
|
|
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
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.
|
|
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) {
|