@lenne.tech/nuxt-extensions 1.15.1 → 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 +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/lib/auth-client.d.ts +64 -10
- package/dist/runtime/lib/auth-client.js +42 -20
- package/dist/runtime/plugins/auth-interceptor.client.js +0 -1
- package/dist/runtime/types/auth.d.ts +93 -6
- package/package.json +1 -1
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.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
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.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
|
/**
|
|
@@ -185,6 +213,26 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
|
|
|
185
213
|
}>;
|
|
186
214
|
};
|
|
187
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
|
+
*/
|
|
188
236
|
admin: any;
|
|
189
237
|
$Infer: {
|
|
190
238
|
Session: {
|
|
@@ -297,12 +345,15 @@ 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:
|
|
353
|
+
changePassword: <T extends {
|
|
303
354
|
currentPassword: string;
|
|
304
355
|
newPassword: string;
|
|
305
|
-
}, options?: any) => Promise<(Omit<{
|
|
356
|
+
}>(params: T, options?: any) => Promise<(Omit<{
|
|
306
357
|
token: string | null;
|
|
307
358
|
user: {
|
|
308
359
|
id: string;
|
|
@@ -373,12 +424,15 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
|
|
|
373
424
|
};
|
|
374
425
|
}>;
|
|
375
426
|
/**
|
|
376
|
-
* 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.
|
|
377
431
|
*/
|
|
378
|
-
resetPassword:
|
|
432
|
+
resetPassword: <T extends {
|
|
379
433
|
newPassword: string;
|
|
380
434
|
token: string;
|
|
381
|
-
}, options?: any) => Promise<{
|
|
435
|
+
}>(params: T, options?: any) => Promise<{
|
|
382
436
|
status: boolean;
|
|
383
437
|
} | {
|
|
384
438
|
data: {
|
|
@@ -398,11 +452,11 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
|
|
|
398
452
|
/**
|
|
399
453
|
* Sign in with email and password (password is hashed before sending)
|
|
400
454
|
*/
|
|
401
|
-
email:
|
|
455
|
+
email: <T extends {
|
|
402
456
|
email: string;
|
|
403
457
|
password: string;
|
|
404
458
|
rememberMe?: boolean;
|
|
405
|
-
}, options?: any) => Promise<(Omit<{
|
|
459
|
+
}>(params: T, options?: any) => Promise<(Omit<{
|
|
406
460
|
redirect: boolean;
|
|
407
461
|
token: string;
|
|
408
462
|
url?: string | undefined;
|
|
@@ -573,11 +627,11 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
|
|
|
573
627
|
/**
|
|
574
628
|
* Sign up with email and password (password is hashed before sending)
|
|
575
629
|
*/
|
|
576
|
-
email:
|
|
630
|
+
email: <T extends {
|
|
577
631
|
email: string;
|
|
578
632
|
name: string;
|
|
579
633
|
password: string;
|
|
580
|
-
}
|
|
634
|
+
}>(params: T, options?: any) => Promise<(Omit<{
|
|
581
635
|
token: null;
|
|
582
636
|
user: {
|
|
583
637
|
id: string;
|
|
@@ -83,29 +83,60 @@ export function createLtAuthClient(config = {}) {
|
|
|
83
83
|
...baseClient,
|
|
84
84
|
// Explicitly pass through methods not captured by spread operator
|
|
85
85
|
useSession: baseClient.useSession,
|
|
86
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
87
86
|
passkey: baseClient.passkey,
|
|
88
|
-
|
|
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
|
+
*/
|
|
89
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
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
98
121
|
changePassword: async (params, options) => {
|
|
99
122
|
const [hashedCurrent, hashedNew] = await Promise.all([ltSha256(params.currentPassword), ltSha256(params.newPassword)]);
|
|
100
|
-
|
|
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);
|
|
101
127
|
},
|
|
102
128
|
/**
|
|
103
|
-
* 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.
|
|
104
133
|
*/
|
|
105
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
106
134
|
resetPassword: async (params, options) => {
|
|
107
135
|
const hashedPassword = await ltSha256(params.newPassword);
|
|
108
|
-
|
|
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);
|
|
109
140
|
},
|
|
110
141
|
// Override signIn to hash password (keep passkey method from plugin)
|
|
111
142
|
signIn: {
|
|
@@ -113,7 +144,6 @@ export function createLtAuthClient(config = {}) {
|
|
|
113
144
|
/**
|
|
114
145
|
* Sign in with email and password (password is hashed before sending)
|
|
115
146
|
*/
|
|
116
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
117
147
|
email: async (params, options) => {
|
|
118
148
|
const hashedPassword = await ltSha256(params.password);
|
|
119
149
|
return baseClient.signIn.email({ ...params, password: hashedPassword }, options);
|
|
@@ -122,7 +152,6 @@ export function createLtAuthClient(config = {}) {
|
|
|
122
152
|
* Sign in with passkey (pass through to base client - provided by passkeyClient plugin)
|
|
123
153
|
* @see https://www.better-auth.com/docs/plugins/passkey
|
|
124
154
|
*/
|
|
125
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
126
155
|
passkey: baseClient.signIn.passkey
|
|
127
156
|
},
|
|
128
157
|
// Explicitly pass through signOut (not captured by spread operator)
|
|
@@ -133,7 +162,6 @@ export function createLtAuthClient(config = {}) {
|
|
|
133
162
|
/**
|
|
134
163
|
* Sign up with email and password (password is hashed before sending)
|
|
135
164
|
*/
|
|
136
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
137
165
|
email: async (params, options) => {
|
|
138
166
|
const hashedPassword = await ltSha256(params.password);
|
|
139
167
|
return baseClient.signUp.email({ ...params, password: hashedPassword }, options);
|
|
@@ -141,41 +169,35 @@ export function createLtAuthClient(config = {}) {
|
|
|
141
169
|
},
|
|
142
170
|
// Override twoFactor to hash passwords (provided by twoFactorClient plugin)
|
|
143
171
|
twoFactor: {
|
|
144
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
145
172
|
...baseClient.twoFactor,
|
|
146
173
|
/**
|
|
147
174
|
* Disable 2FA (password is hashed before sending)
|
|
148
175
|
*/
|
|
149
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
150
176
|
disable: async (params, options) => {
|
|
151
177
|
const hashedPassword = await ltSha256(params.password);
|
|
152
|
-
return baseClient.twoFactor.disable({ password: hashedPassword }, options);
|
|
178
|
+
return baseClient.twoFactor.disable({ ...params, password: hashedPassword }, options);
|
|
153
179
|
},
|
|
154
180
|
/**
|
|
155
181
|
* Enable 2FA (password is hashed before sending)
|
|
156
182
|
*/
|
|
157
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
158
183
|
enable: async (params, options) => {
|
|
159
184
|
const hashedPassword = await ltSha256(params.password);
|
|
160
|
-
return baseClient.twoFactor.enable({ password: hashedPassword }, options);
|
|
185
|
+
return baseClient.twoFactor.enable({ ...params, password: hashedPassword }, options);
|
|
161
186
|
},
|
|
162
187
|
/**
|
|
163
188
|
* Generate backup codes (password is hashed before sending)
|
|
164
189
|
*/
|
|
165
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
166
190
|
generateBackupCodes: async (params, options) => {
|
|
167
191
|
const hashedPassword = await ltSha256(params.password);
|
|
168
|
-
return baseClient.twoFactor.generateBackupCodes({ password: hashedPassword }, options);
|
|
192
|
+
return baseClient.twoFactor.generateBackupCodes({ ...params, password: hashedPassword }, options);
|
|
169
193
|
},
|
|
170
194
|
/**
|
|
171
195
|
* Verify TOTP code (pass through to base client)
|
|
172
196
|
*/
|
|
173
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
174
197
|
verifyTotp: baseClient.twoFactor.verifyTotp,
|
|
175
198
|
/**
|
|
176
199
|
* Verify backup code (pass through to base client)
|
|
177
200
|
*/
|
|
178
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
179
201
|
verifyBackupCode: baseClient.twoFactor.verifyBackupCode
|
|
180
202
|
}
|
|
181
203
|
};
|
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
265
|
+
email: <T extends {
|
|
180
266
|
email: string;
|
|
181
267
|
name: string;
|
|
182
268
|
password: string;
|
|
183
|
-
}
|
|
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 {};
|