@warlock.js/auth 5.24.0 → 5.26.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +26 -9
  2. package/LICENSE +21 -21
  3. package/esm/contracts/index.d.mts +1 -1
  4. package/esm/contracts/request-locals.d.mts +1 -1
  5. package/esm/contracts/types.d.mts +8 -3
  6. package/esm/contracts/types.d.mts.map +1 -1
  7. package/esm/contracts/types.mjs.map +1 -1
  8. package/esm/index.d.mts +3 -3
  9. package/esm/index.mjs +2 -2
  10. package/esm/middleware/auth.middleware.mjs +1 -1
  11. package/esm/middleware/auth.middleware.mjs.map +1 -1
  12. package/esm/middleware/login-throttle.middleware.mjs.map +1 -1
  13. package/esm/models/access-token/access-token.model.mjs.map +1 -1
  14. package/esm/models/access-token/migration.mjs.map +1 -1
  15. package/esm/models/auth-token-family/auth-token-family.model.d.mts.map +1 -1
  16. package/esm/models/auth-token-family/auth-token-family.model.mjs +5 -0
  17. package/esm/models/auth-token-family/auth-token-family.model.mjs.map +1 -1
  18. package/esm/models/auth.model.mjs.map +1 -1
  19. package/esm/models/refresh-token/migration.mjs.map +1 -1
  20. package/esm/models/refresh-token/refresh-token.model.mjs.map +1 -1
  21. package/esm/services/auth-config.mjs +3 -1
  22. package/esm/services/auth-config.mjs.map +1 -1
  23. package/esm/services/auth-events.mjs.map +1 -1
  24. package/esm/services/auth.service.d.mts +9 -4
  25. package/esm/services/auth.service.d.mts.map +1 -1
  26. package/esm/services/auth.service.mjs +45 -17
  27. package/esm/services/auth.service.mjs.map +1 -1
  28. package/esm/services/index.d.mts +1 -1
  29. package/esm/services/index.mjs +1 -1
  30. package/esm/services/resolve-request-user.d.mts +3 -2
  31. package/esm/services/resolve-request-user.d.mts.map +1 -1
  32. package/esm/services/resolve-request-user.mjs +8 -2
  33. package/esm/services/resolve-request-user.mjs.map +1 -1
  34. package/esm/session/page-session.d.mts +3 -1
  35. package/esm/session/page-session.d.mts.map +1 -1
  36. package/esm/session/page-session.mjs +2 -1
  37. package/esm/session/page-session.mjs.map +1 -1
  38. package/esm/utils/auth-error-codes.mjs.map +1 -1
  39. package/llms-full.txt +1946 -1927
  40. package/package.json +8 -8
  41. package/skills/auth-basics/SKILL.md +1 -1
  42. package/skills/customize-token-storage/SKILL.md +160 -160
  43. package/skills/customize-user-type/SKILL.md +168 -168
  44. package/skills/handle-login-and-logout/SKILL.md +195 -180
  45. package/skills/login-with-providers/SKILL.md +283 -283
  46. package/skills/manage-tokens/SKILL.md +196 -196
  47. package/skills/overview/SKILL.md +74 -74
  48. package/skills/protect-routes/SKILL.md +299 -299
  49. package/skills/register-user/SKILL.md +147 -147
  50. package/skills/run-auth-commands/SKILL.md +159 -159
  51. package/skills/throttle-login-attempts/SKILL.md +117 -117
  52. package/skills/verify-email-and-reset-password/SKILL.md +137 -137
@@ -1,117 +1,117 @@
1
- ---
2
- name: throttle-login-attempts
3
- description: 'Brute-force / credential-stuffing protection via `loginThrottleMiddleware` — a failure-aware route gate that counts only failed logins (resets on success), locks per-account and per-source after a threshold, and rejects pre-controller with 429 so the DB lookup and bcrypt verify are skipped. Cache-backed (shared across replicas), fixed-window, fails open on a cache outage. Triggers: `loginThrottleMiddleware`, `AuthErrorCodes.TooManyAttempts`, `EC004`, "rate limit login", "brute force protection", "lock account after failed logins", "throttle login attempts", "too many login attempts 429"; typical import `import { loginThrottleMiddleware } from "@warlock.js/auth"`. Skip: generic per-route request rate limiting that counts every request (use core `middleware.rateLimit`); gating a route by auth — `@warlock.js/auth/protect-routes/SKILL.md`; issuing tokens — `@warlock.js/auth/handle-login-and-logout/SKILL.md`.'
4
- ---
5
-
6
- # Throttle login attempts with `loginThrottleMiddleware`
7
-
8
- `loginThrottleMiddleware(options?)` returns a Warlock middleware that defends the login (and refresh / password-reset) routes against brute-force and credential-stuffing — without punishing a user who fat-fingers a password then gets it right.
9
-
10
- The trick that makes it different from a plain rate limiter: it's **failure-aware**. It hooks the response *after* your controller runs (`response.onSent`), counts only the attempts that actually failed, and **clears the counter the moment a login succeeds**. A generic request limiter can't do that — it runs before the controller and counts every hit, success or not.
11
-
12
- ## The shortest version
13
-
14
- ```ts
15
- import { loginThrottleMiddleware } from "@warlock.js/auth";
16
-
17
- router.post("/auth/login", loginController, {
18
- middleware: [loginThrottleMiddleware()], // 5 failures / 15m → 15m lockout, per email + ip
19
- });
20
- ```
21
-
22
- That's the whole feature for most apps. Defaults: **5** failed attempts within a **15m** window trips a **15m** lockout, tracked independently per account (the `email` field) **and** per source IP.
23
-
24
- > **Heads-up — account-lockout is a DoS surface.** Because the default tracks per-`email`, an attacker who knows a victim's address can lock them out by spamming failed logins. The per-IP counter mitigates it (the attacker's own IP trips too); for purely anonymous endpoints prefer `by: ["ip"]`, and for high-value accounts consider a CAPTCHA step over a hard lock. See [Gotchas](#gotchas).
25
-
26
- ## How it behaves
27
-
28
- 1. **Before the controller** — if the account or the IP is currently locked, it short-circuits with `429` and never touches the database or the bcrypt verify (this is also what neutralises the CPU-DoS angle of brute-forcing). The body carries `AuthErrorCodes.TooManyAttempts` (`EC004`).
29
- 2. **After the controller** — it inspects the outcome. A non-2xx response is a failed login: it bumps a fixed-window counter for each tracked identifier, and once a counter reaches `max` it writes a lock key. A 2xx response is a success: it **clears** the counter and lock for that identifier.
30
-
31
- Per-account tracking stops a password-spray against one user from many IPs; per-source tracking stops one IP from stuffing many accounts. A lock on **either** identifier rejects the request — defense-in-depth.
32
-
33
- ## Options
34
-
35
- ```ts
36
- loginThrottleMiddleware({
37
- max: 5, // failures allowed in the window before lockout
38
- window: "15m", // counting window — ms-format string or seconds (number)
39
- lockoutDuration: "15m", // how long the lock lasts once tripped
40
- by: ["email", "ip"], // identifiers tracked, each independently
41
- identifierKey: "email", // which credential field is the account key
42
- errorMessage: "Too many attempts. Try again later.",
43
- });
44
- ```
45
-
46
- Two escape hatches for non-standard setups:
47
-
48
- ```ts
49
- loginThrottleMiddleware({
50
- // your controller signals failure with a 200 body instead of a status code
51
- isFailure: (response) => response.parsedBody?.ok === false,
52
-
53
- // derive the account key from somewhere other than email/ip
54
- identify: (request) => [`tenant.${request.input("tenant")}.${request.input("username")}`],
55
- });
56
- ```
57
-
58
- | Option | Default | Purpose |
59
- | --- | --- | --- |
60
- | `max` | `5` | Failures within the window before lockout |
61
- | `window` | `"15m"` | Counting window (`ms`-string or seconds) |
62
- | `lockoutDuration` | `"15m"` | Lock TTL once tripped |
63
- | `by` | `["email", "ip"]` | Identifiers tracked independently |
64
- | `identifierKey` | `"email"` | Credential field used as the account key |
65
- | `errorMessage` | i18n `auth.errors.tooManyAttempts` | 429 message override |
66
- | `isFailure` | `(res) => !res.isOk` | What counts as a failed attempt |
67
- | `identify` | built-in email + ip extraction | Custom identifier list |
68
-
69
- ## The 429 response
70
-
71
- On lockout the middleware sends:
72
-
73
- ```jsonc
74
- { "error": "...", "errorCode": "EC004" } // AuthErrorCodes.TooManyAttempts
75
- ```
76
-
77
- `@warlock.js/auth` ships built-in `en` and `ar` text for `auth.errors.tooManyAttempts` (and every other `auth.errors.*` key it uses); define the key in your own locale files, or pass `errorMessage`, to override it. Map `EC004` in your error transformer the same way you map the other `AuthErrorCodes`.
78
-
79
- ## Beyond login
80
-
81
- The middleware isn't login-specific — drop it on any route where repeated failures should lock something out. For the refresh and reset endpoints there's no email in the body, so track by IP only:
82
-
83
- ```ts
84
- router.post("/auth/refresh-token", refreshController, {
85
- middleware: [loginThrottleMiddleware({ by: ["ip"], max: 10, window: "1m" })],
86
- });
87
-
88
- router.post("/auth/forgot-password", forgotController, {
89
- middleware: [loginThrottleMiddleware({ max: 3, window: "1h", lockoutDuration: "1h" })],
90
- });
91
- ```
92
-
93
- ## When to use this vs core's `rateLimit`
94
-
95
- | | `loginThrottleMiddleware` (auth) | `middleware.rateLimit` (core) |
96
- | --- | --- | --- |
97
- | Counts | only **failed** logins | **every** request |
98
- | Resets on a successful login | yes | no |
99
- | Locks per account / per IP | yes | per key (default IP) |
100
- | Storage | `@warlock.js/cache` (shared across replicas) | in-process map |
101
-
102
- They compose. For a hard ceiling on request volume *and* failure-aware account lockout, stack both: `middleware: [middleware.rateLimit({ max: 30, duration: 60_000 }), loginThrottleMiddleware()]`.
103
-
104
- ## Gotchas
105
-
106
- - **Needs an initialised cache driver.** Storage is `@warlock.js/cache` (a peer dep, transitively present via core). If the driver isn't configured at runtime, the middleware **fails open** — it logs and lets the request through, because a throttle outage must never become an auth outage. That also means: no cache, no protection. Verify your cache config in production.
107
- - **Account lockout can be weaponised.** An attacker who knows a victim's email can lock them out by spamming failures. The per-IP counter mitigates this (the attacker's IP locks too), but for purely anonymous endpoints prefer `by: ["ip"]`. For high-value accounts, consider a CAPTCHA step instead of a hard lock.
108
- - **Counts are atomic and exact across servers (redis/pg).** Each failure does a create-only `cache.set(key, 0, { ttl: window })` (only the first failure opens the window and sets the TTL) followed by an atomic `cache.increment(key)`, so concurrent failures on different servers are never lost. The memory cache driver is per-process — counts and locks are then per-process, so use redis/pg when running more than one instance.
109
- - **Fixed window, not sliding.** The window is anchored at the first failure and does not extend on each subsequent one — predictable lockout timing. The lock key is independent and always lasts `lockoutDuration`.
110
- - **Defense-in-depth, not a WAF.** This is application-layer. It won't stop a volumetric L3/L4 flood — pair it with an edge/CDN rate limit for that.
111
- - **Don't call it per-request.** Like every middleware, call it once at route definition; it returns the function that runs per request.
112
-
113
- ## See also
114
-
115
- - [`@warlock.js/auth/protect-routes/SKILL.md`](@warlock.js/auth/protect-routes/SKILL.md) — gate a route behind a valid token (`authMiddleware`); stack the throttle in front of it on login routes.
116
- - [`@warlock.js/auth/handle-login-and-logout/SKILL.md`](@warlock.js/auth/handle-login-and-logout/SKILL.md) — the login flow whose failures this middleware counts.
117
- - [`@warlock.js/core/use-middleware/SKILL.md`](@warlock.js/core/use-middleware/SKILL.md) — `middleware.rateLimit` and the built-in middleware suite.
1
+ ---
2
+ name: throttle-login-attempts
3
+ description: 'Brute-force / credential-stuffing protection via `loginThrottleMiddleware` — a failure-aware route gate that counts only failed logins (resets on success), locks per-account and per-source after a threshold, and rejects pre-controller with 429 so the DB lookup and bcrypt verify are skipped. Cache-backed (shared across replicas), fixed-window, fails open on a cache outage. Triggers: `loginThrottleMiddleware`, `AuthErrorCodes.TooManyAttempts`, `EC004`, "rate limit login", "brute force protection", "lock account after failed logins", "throttle login attempts", "too many login attempts 429"; typical import `import { loginThrottleMiddleware } from "@warlock.js/auth"`. Skip: generic per-route request rate limiting that counts every request (use core `middleware.rateLimit`); gating a route by auth — `@warlock.js/auth/protect-routes/SKILL.md`; issuing tokens — `@warlock.js/auth/handle-login-and-logout/SKILL.md`.'
4
+ ---
5
+
6
+ # Throttle login attempts with `loginThrottleMiddleware`
7
+
8
+ `loginThrottleMiddleware(options?)` returns a Warlock middleware that defends the login (and refresh / password-reset) routes against brute-force and credential-stuffing — without punishing a user who fat-fingers a password then gets it right.
9
+
10
+ The trick that makes it different from a plain rate limiter: it's **failure-aware**. It hooks the response *after* your controller runs (`response.onSent`), counts only the attempts that actually failed, and **clears the counter the moment a login succeeds**. A generic request limiter can't do that — it runs before the controller and counts every hit, success or not.
11
+
12
+ ## The shortest version
13
+
14
+ ```ts
15
+ import { loginThrottleMiddleware } from "@warlock.js/auth";
16
+
17
+ router.post("/auth/login", loginController, {
18
+ middleware: [loginThrottleMiddleware()], // 5 failures / 15m → 15m lockout, per email + ip
19
+ });
20
+ ```
21
+
22
+ That's the whole feature for most apps. Defaults: **5** failed attempts within a **15m** window trips a **15m** lockout, tracked independently per account (the `email` field) **and** per source IP.
23
+
24
+ > **Heads-up — account-lockout is a DoS surface.** Because the default tracks per-`email`, an attacker who knows a victim's address can lock them out by spamming failed logins. The per-IP counter mitigates it (the attacker's own IP trips too); for purely anonymous endpoints prefer `by: ["ip"]`, and for high-value accounts consider a CAPTCHA step over a hard lock. See [Gotchas](#gotchas).
25
+
26
+ ## How it behaves
27
+
28
+ 1. **Before the controller** — if the account or the IP is currently locked, it short-circuits with `429` and never touches the database or the bcrypt verify (this is also what neutralises the CPU-DoS angle of brute-forcing). The body carries `AuthErrorCodes.TooManyAttempts` (`EC004`).
29
+ 2. **After the controller** — it inspects the outcome. A non-2xx response is a failed login: it bumps a fixed-window counter for each tracked identifier, and once a counter reaches `max` it writes a lock key. A 2xx response is a success: it **clears** the counter and lock for that identifier.
30
+
31
+ Per-account tracking stops a password-spray against one user from many IPs; per-source tracking stops one IP from stuffing many accounts. A lock on **either** identifier rejects the request — defense-in-depth.
32
+
33
+ ## Options
34
+
35
+ ```ts
36
+ loginThrottleMiddleware({
37
+ max: 5, // failures allowed in the window before lockout
38
+ window: "15m", // counting window — ms-format string or seconds (number)
39
+ lockoutDuration: "15m", // how long the lock lasts once tripped
40
+ by: ["email", "ip"], // identifiers tracked, each independently
41
+ identifierKey: "email", // which credential field is the account key
42
+ errorMessage: "Too many attempts. Try again later.",
43
+ });
44
+ ```
45
+
46
+ Two escape hatches for non-standard setups:
47
+
48
+ ```ts
49
+ loginThrottleMiddleware({
50
+ // your controller signals failure with a 200 body instead of a status code
51
+ isFailure: (response) => response.parsedBody?.ok === false,
52
+
53
+ // derive the account key from somewhere other than email/ip
54
+ identify: (request) => [`tenant.${request.input("tenant")}.${request.input("username")}`],
55
+ });
56
+ ```
57
+
58
+ | Option | Default | Purpose |
59
+ | --- | --- | --- |
60
+ | `max` | `5` | Failures within the window before lockout |
61
+ | `window` | `"15m"` | Counting window (`ms`-string or seconds) |
62
+ | `lockoutDuration` | `"15m"` | Lock TTL once tripped |
63
+ | `by` | `["email", "ip"]` | Identifiers tracked independently |
64
+ | `identifierKey` | `"email"` | Credential field used as the account key |
65
+ | `errorMessage` | i18n `auth.errors.tooManyAttempts` | 429 message override |
66
+ | `isFailure` | `(res) => !res.isOk` | What counts as a failed attempt |
67
+ | `identify` | built-in email + ip extraction | Custom identifier list |
68
+
69
+ ## The 429 response
70
+
71
+ On lockout the middleware sends:
72
+
73
+ ```jsonc
74
+ { "error": "...", "errorCode": "EC004" } // AuthErrorCodes.TooManyAttempts
75
+ ```
76
+
77
+ `@warlock.js/auth` ships built-in `en` and `ar` text for `auth.errors.tooManyAttempts` (and every other `auth.errors.*` key it uses); define the key in your own locale files, or pass `errorMessage`, to override it. Map `EC004` in your error transformer the same way you map the other `AuthErrorCodes`.
78
+
79
+ ## Beyond login
80
+
81
+ The middleware isn't login-specific — drop it on any route where repeated failures should lock something out. For the refresh and reset endpoints there's no email in the body, so track by IP only:
82
+
83
+ ```ts
84
+ router.post("/auth/refresh-token", refreshController, {
85
+ middleware: [loginThrottleMiddleware({ by: ["ip"], max: 10, window: "1m" })],
86
+ });
87
+
88
+ router.post("/auth/forgot-password", forgotController, {
89
+ middleware: [loginThrottleMiddleware({ max: 3, window: "1h", lockoutDuration: "1h" })],
90
+ });
91
+ ```
92
+
93
+ ## When to use this vs core's `rateLimit`
94
+
95
+ | | `loginThrottleMiddleware` (auth) | `middleware.rateLimit` (core) |
96
+ | --- | --- | --- |
97
+ | Counts | only **failed** logins | **every** request |
98
+ | Resets on a successful login | yes | no |
99
+ | Locks per account / per IP | yes | per key (default IP) |
100
+ | Storage | `@warlock.js/cache` (shared across replicas) | in-process map |
101
+
102
+ They compose. For a hard ceiling on request volume *and* failure-aware account lockout, stack both: `middleware: [middleware.rateLimit({ max: 30, duration: 60_000 }), loginThrottleMiddleware()]`.
103
+
104
+ ## Gotchas
105
+
106
+ - **Needs an initialised cache driver.** Storage is `@warlock.js/cache` (a peer dep, transitively present via core). If the driver isn't configured at runtime, the middleware **fails open** — it logs and lets the request through, because a throttle outage must never become an auth outage. That also means: no cache, no protection. Verify your cache config in production.
107
+ - **Account lockout can be weaponised.** An attacker who knows a victim's email can lock them out by spamming failures. The per-IP counter mitigates this (the attacker's IP locks too), but for purely anonymous endpoints prefer `by: ["ip"]`. For high-value accounts, consider a CAPTCHA step instead of a hard lock.
108
+ - **Counts are atomic and exact across servers (redis/pg).** Each failure does a create-only `cache.set(key, 0, { ttl: window })` (only the first failure opens the window and sets the TTL) followed by an atomic `cache.increment(key)`, so concurrent failures on different servers are never lost. The memory cache driver is per-process — counts and locks are then per-process, so use redis/pg when running more than one instance.
109
+ - **Fixed window, not sliding.** The window is anchored at the first failure and does not extend on each subsequent one — predictable lockout timing. The lock key is independent and always lasts `lockoutDuration`.
110
+ - **Defense-in-depth, not a WAF.** This is application-layer. It won't stop a volumetric L3/L4 flood — pair it with an edge/CDN rate limit for that.
111
+ - **Don't call it per-request.** Like every middleware, call it once at route definition; it returns the function that runs per request.
112
+
113
+ ## See also
114
+
115
+ - [`@warlock.js/auth/protect-routes/SKILL.md`](@warlock.js/auth/protect-routes/SKILL.md) — gate a route behind a valid token (`authMiddleware`); stack the throttle in front of it on login routes.
116
+ - [`@warlock.js/auth/handle-login-and-logout/SKILL.md`](@warlock.js/auth/handle-login-and-logout/SKILL.md) — the login flow whose failures this middleware counts.
117
+ - [`@warlock.js/core/use-middleware/SKILL.md`](@warlock.js/core/use-middleware/SKILL.md) — `middleware.rateLimit` and the built-in middleware suite.
@@ -1,137 +1,137 @@
1
- ---
2
- name: verify-email-and-reset-password
3
- description: 'Email verification and password reset with hashed, single-use, expiring, purpose-bound tokens delivered through `@warlock.js/notifications` — `sendEmailVerification(user)`, `verifyEmail(token)`, `requestPasswordReset(Model, email)`, `resetPassword(token, newPassword)`, the `requireVerifiedEmail()` guard, and the `tokenIssueThrottleMiddleware` / `tokenConsumeThrottleMiddleware` presets. Reset revokes every access token, refresh token and cookie session; a reset request answers the same for unknown emails. Triggers: `sendEmailVerification`, `verifyEmail`, `requestPasswordReset`, `resetPassword`, `requireVerifiedEmail`, `isEmailVerified`, `OneTimeToken`, `InvalidOneTimeTokenError`, `EmailNotVerifiedError`, `NotificationsUnavailableError`, `EC007`, `EC008`, `auth.verification`, `auth.passwordReset`; "verify email", "confirm email address", "forgot password", "reset password link", "block unverified users", "resend verification email"; typical import `import { requestPasswordReset, resetPassword } from "@warlock.js/auth"`. Skip: sign-up itself — `@warlock.js/auth/register-user/SKILL.md`; login throttling — `@warlock.js/auth/throttle-login-attempts/SKILL.md`; defining notifications — `@warlock.js/notifications/define-notification/SKILL.md`.'
4
- ---
5
-
6
- # Verify email + reset password
7
-
8
- Four service functions, one guard, two throttle presets. Auth ships no controllers — wire the routes yourself (examples below).
9
-
10
- ## Prerequisites
11
-
12
- 1. **`@warlock.js/notifications` installed and configured with a `mail` channel.** It is an optional peer; without it (or without `src/config/notifications.ts`) every send/request call throws `NotificationsUnavailableError` **before** a token is issued — never a silent skip.
13
- 2. **The `one_time_tokens` table.** It ships in `authMigrations` next to the access/refresh tables; run your migrations.
14
- 3. **A verified-date field on the user schema** (default `emailVerifiedAt`):
15
-
16
- ```ts
17
- export const userSchema = v.object({
18
- // ...
19
- emailVerifiedAt: v.date().optional(),
20
- });
21
- ```
22
-
23
- If the schema strips it, `verifyEmail` throws naming the field instead of pretending it worked.
24
-
25
- ## Tokens
26
-
27
- - 32 random bytes, base64url. Only the **SHA-256 hash** is stored; the raw token exists only in the notification.
28
- - **Single use**, consumed with a conditional update — of two concurrent uses, exactly one succeeds.
29
- - **Expiring**: verification `24h`, reset `60m` by default.
30
- - **Purpose-bound**: a verification token is never accepted by `resetPassword`, and vice versa.
31
- - A new reset request **invalidates the user's earlier unused reset tokens**. Verification tokens are not invalidated by a resend.
32
- - Unknown, wrong-purpose, expired and used tokens all throw one `InvalidOneTimeTokenError` (`400`, `EC008`).
33
-
34
- ## Email verification
35
-
36
- ```ts
37
- import { requireVerifiedEmail, sendEmailVerification, verifyEmail } from "@warlock.js/auth";
38
- import { type RequestHandler } from "@warlock.js/core";
39
-
40
- // after User.create(...) in your register controller
41
- await sendEmailVerification(user);
42
-
43
- export const verifyEmailController: RequestHandler = async ({ request, response }) => {
44
- const user = await verifyEmail(request.input("token")); // throws InvalidOneTimeTokenError
45
- return response.success({ user });
46
- };
47
-
48
- router.post("/auth/verify-email", verifyEmailController, {
49
- middleware: [tokenConsumeThrottleMiddleware()],
50
- });
51
-
52
- router.post("/auth/verify-email/resend", resendController, {
53
- middleware: [authMiddleware("user"), tokenIssueThrottleMiddleware({ by: ["ip"] })],
54
- });
55
-
56
- // Opt-in guard, AFTER authMiddleware: 403 EmailNotVerifiedError (EC007)
57
- router.post("/orders", createOrder, {
58
- middleware: [authMiddleware("user"), requireVerifiedEmail()],
59
- });
60
- ```
61
-
62
- `isEmailVerified(user)` answers the same question in code.
63
-
64
- ## Password reset
65
-
66
- ```ts
67
- import { requestPasswordReset, resetPassword } from "@warlock.js/auth";
68
-
69
- export const forgotPasswordController: RequestHandler = async ({ request, response }) => {
70
- await requestPasswordReset(User, request.input("email"));
71
- // Same answer whether or not the account exists.
72
- return response.success({ message: "If that account exists, we sent a reset link." });
73
- };
74
-
75
- export const resetPasswordController: RequestHandler = async ({ request, response }) => {
76
- // Validate strength FIRST — the token is consumed before the password is written.
77
- await resetPassword(request.input("token"), request.input("password"));
78
- return response.success({ message: "Password updated. Please log in again." });
79
- };
80
-
81
- router.post("/auth/forgot-password", forgotPasswordController, {
82
- middleware: [tokenIssueThrottleMiddleware()], // 3 / 1h per email + per IP
83
- });
84
- router.post("/auth/reset-password", resetPasswordController, {
85
- middleware: [tokenConsumeThrottleMiddleware()], // 10 failures / 15m per IP
86
- });
87
- ```
88
-
89
- `resetPassword` writes the password, then calls `authService.revokeAllTokens(user)`: every refresh token is revoked and every access token deleted, which also kills cookie sessions (a cookie holds an access token). It emits `password.reset`; `requestPasswordReset` emits `password.resetRequested`.
90
-
91
- **Password hashing.** By default auth saves `hashPassword(plain)` and checks it with `verifyPassword`, the same check login uses. If your model hashes on save (`useHashedPassword()`), that first write gets hashed twice and fails the check, so auth saves the plaintext and lets your transformer hash it once. If neither write passes the check, it throws. Plaintext is never the first thing written. To write it yourself, set `auth.passwordReset.setPassword`.
92
-
93
- ## Throttling
94
-
95
- Both presets wrap `loginThrottleMiddleware` and accept its options:
96
-
97
- | Preset | Tracks | Counts | Default |
98
- | --- | --- | --- | --- |
99
- | `tokenIssueThrottleMiddleware` | email + IP | **every** request (a reset request's success is the anti-enumeration answer, so failure-aware counting would never trip) | 3 / 1h, 1h lock |
100
- | `tokenConsumeThrottleMiddleware` | IP | failures only, cleared on success | 10 / 15m, 15m lock |
101
-
102
- ## Configuration (`src/config/auth.ts`)
103
-
104
- ```ts
105
- const authConfigurations: AuthConfigurations = {
106
- // ...
107
- verification: {
108
- expiresIn: "24h",
109
- field: "emailVerifiedAt",
110
- url: (token) => `${env("APP_URL")}/verify-email?token=${token}`,
111
- notification: myVerificationNotification, // optional replacement
112
- },
113
- passwordReset: {
114
- expiresIn: "60m",
115
- identifierField: "email",
116
- url: (token) => `${env("APP_URL")}/reset-password?token=${token}`,
117
- notification: myResetNotification,
118
- setPassword: async (user, plain) => { /* optional */ },
119
- },
120
- oneTimeToken: { model: AppOneTimeToken }, // optional model override
121
- };
122
- ```
123
-
124
- The default notifications are `defineNotification` objects (`type: "auth.email-verification"` / `"auth.password-reset"`, `via: ["mail"]`). To replace one, pass anything with a `send(user, { token, expiresAt, url? })` method, for example your own `defineNotification<OneTimeTokenNotificationData>({...})`. `notifications` still has to be installed and configured.
125
-
126
- ## Gotchas
127
-
128
- - **Without `url` config, the email contains the raw token**, not a link. Set `url` for anything user-facing.
129
- - **Timing isn't equalised.** An unknown email skips the database write and the send, so the response can come back measurably faster. The throttle limits probing, but it doesn't hide the timing difference.
130
- - **Channel send failures don't throw.** `@warlock.js/notifications` only rethrows configuration errors (such as a missing `mail` channel). An SMTP failure goes to its `failed` event, so observe it there.
131
- - `auth.cleanup` hard-deletes expired and consumed `one_time_tokens` rows (never trashed, even on MongoDB).
132
-
133
- ## See also
134
-
135
- - [`@warlock.js/auth/register-user/SKILL.md`](@warlock.js/auth/register-user/SKILL.md) — where `sendEmailVerification` is usually called
136
- - [`@warlock.js/auth/throttle-login-attempts/SKILL.md`](@warlock.js/auth/throttle-login-attempts/SKILL.md) — the underlying throttle
137
- - [`@warlock.js/notifications/configure-notifications/SKILL.md`](@warlock.js/notifications/configure-notifications/SKILL.md) — mail channel setup
1
+ ---
2
+ name: verify-email-and-reset-password
3
+ description: 'Email verification and password reset with hashed, single-use, expiring, purpose-bound tokens delivered through `@warlock.js/notifications` — `sendEmailVerification(user)`, `verifyEmail(token)`, `requestPasswordReset(Model, email)`, `resetPassword(token, newPassword)`, the `requireVerifiedEmail()` guard, and the `tokenIssueThrottleMiddleware` / `tokenConsumeThrottleMiddleware` presets. Reset revokes every access token, refresh token and cookie session; a reset request answers the same for unknown emails. Triggers: `sendEmailVerification`, `verifyEmail`, `requestPasswordReset`, `resetPassword`, `requireVerifiedEmail`, `isEmailVerified`, `OneTimeToken`, `InvalidOneTimeTokenError`, `EmailNotVerifiedError`, `NotificationsUnavailableError`, `EC007`, `EC008`, `auth.verification`, `auth.passwordReset`; "verify email", "confirm email address", "forgot password", "reset password link", "block unverified users", "resend verification email"; typical import `import { requestPasswordReset, resetPassword } from "@warlock.js/auth"`. Skip: sign-up itself — `@warlock.js/auth/register-user/SKILL.md`; login throttling — `@warlock.js/auth/throttle-login-attempts/SKILL.md`; defining notifications — `@warlock.js/notifications/define-notification/SKILL.md`.'
4
+ ---
5
+
6
+ # Verify email + reset password
7
+
8
+ Four service functions, one guard, two throttle presets. Auth ships no controllers — wire the routes yourself (examples below).
9
+
10
+ ## Prerequisites
11
+
12
+ 1. **`@warlock.js/notifications` installed and configured with a `mail` channel.** It is an optional peer; without it (or without `src/config/notifications.ts`) every send/request call throws `NotificationsUnavailableError` **before** a token is issued — never a silent skip.
13
+ 2. **The `one_time_tokens` table.** It ships in `authMigrations` next to the access/refresh tables; run your migrations.
14
+ 3. **A verified-date field on the user schema** (default `emailVerifiedAt`):
15
+
16
+ ```ts
17
+ export const userSchema = v.object({
18
+ // ...
19
+ emailVerifiedAt: v.date().optional(),
20
+ });
21
+ ```
22
+
23
+ If the schema strips it, `verifyEmail` throws naming the field instead of pretending it worked.
24
+
25
+ ## Tokens
26
+
27
+ - 32 random bytes, base64url. Only the **SHA-256 hash** is stored; the raw token exists only in the notification.
28
+ - **Single use**, consumed with a conditional update — of two concurrent uses, exactly one succeeds.
29
+ - **Expiring**: verification `24h`, reset `60m` by default.
30
+ - **Purpose-bound**: a verification token is never accepted by `resetPassword`, and vice versa.
31
+ - A new reset request **invalidates the user's earlier unused reset tokens**. Verification tokens are not invalidated by a resend.
32
+ - Unknown, wrong-purpose, expired and used tokens all throw one `InvalidOneTimeTokenError` (`400`, `EC008`).
33
+
34
+ ## Email verification
35
+
36
+ ```ts
37
+ import { requireVerifiedEmail, sendEmailVerification, verifyEmail } from "@warlock.js/auth";
38
+ import { type RequestHandler } from "@warlock.js/core";
39
+
40
+ // after User.create(...) in your register controller
41
+ await sendEmailVerification(user);
42
+
43
+ export const verifyEmailController: RequestHandler = async ({ request, response }) => {
44
+ const user = await verifyEmail(request.input("token")); // throws InvalidOneTimeTokenError
45
+ return response.success({ user });
46
+ };
47
+
48
+ router.post("/auth/verify-email", verifyEmailController, {
49
+ middleware: [tokenConsumeThrottleMiddleware()],
50
+ });
51
+
52
+ router.post("/auth/verify-email/resend", resendController, {
53
+ middleware: [authMiddleware("user"), tokenIssueThrottleMiddleware({ by: ["ip"] })],
54
+ });
55
+
56
+ // Opt-in guard, AFTER authMiddleware: 403 EmailNotVerifiedError (EC007)
57
+ router.post("/orders", createOrder, {
58
+ middleware: [authMiddleware("user"), requireVerifiedEmail()],
59
+ });
60
+ ```
61
+
62
+ `isEmailVerified(user)` answers the same question in code.
63
+
64
+ ## Password reset
65
+
66
+ ```ts
67
+ import { requestPasswordReset, resetPassword } from "@warlock.js/auth";
68
+
69
+ export const forgotPasswordController: RequestHandler = async ({ request, response }) => {
70
+ await requestPasswordReset(User, request.input("email"));
71
+ // Same answer whether or not the account exists.
72
+ return response.success({ message: "If that account exists, we sent a reset link." });
73
+ };
74
+
75
+ export const resetPasswordController: RequestHandler = async ({ request, response }) => {
76
+ // Validate strength FIRST — the token is consumed before the password is written.
77
+ await resetPassword(request.input("token"), request.input("password"));
78
+ return response.success({ message: "Password updated. Please log in again." });
79
+ };
80
+
81
+ router.post("/auth/forgot-password", forgotPasswordController, {
82
+ middleware: [tokenIssueThrottleMiddleware()], // 3 / 1h per email + per IP
83
+ });
84
+ router.post("/auth/reset-password", resetPasswordController, {
85
+ middleware: [tokenConsumeThrottleMiddleware()], // 10 failures / 15m per IP
86
+ });
87
+ ```
88
+
89
+ `resetPassword` writes the password, then calls `authService.revokeAllTokens(user)`: every refresh token is revoked and every access token deleted, which also kills cookie sessions (a cookie holds an access token). It emits `password.reset`; `requestPasswordReset` emits `password.resetRequested`.
90
+
91
+ **Password hashing.** By default auth saves `hashPassword(plain)` and checks it with `verifyPassword`, the same check login uses. If your model hashes on save (`useHashedPassword()`), that first write gets hashed twice and fails the check, so auth saves the plaintext and lets your transformer hash it once. If neither write passes the check, it throws. Plaintext is never the first thing written. To write it yourself, set `auth.passwordReset.setPassword`.
92
+
93
+ ## Throttling
94
+
95
+ Both presets wrap `loginThrottleMiddleware` and accept its options:
96
+
97
+ | Preset | Tracks | Counts | Default |
98
+ | --- | --- | --- | --- |
99
+ | `tokenIssueThrottleMiddleware` | email + IP | **every** request (a reset request's success is the anti-enumeration answer, so failure-aware counting would never trip) | 3 / 1h, 1h lock |
100
+ | `tokenConsumeThrottleMiddleware` | IP | failures only, cleared on success | 10 / 15m, 15m lock |
101
+
102
+ ## Configuration (`src/config/auth.ts`)
103
+
104
+ ```ts
105
+ const authConfigurations: AuthConfigurations = {
106
+ // ...
107
+ verification: {
108
+ expiresIn: "24h",
109
+ field: "emailVerifiedAt",
110
+ url: (token) => `${env("APP_URL")}/verify-email?token=${token}`,
111
+ notification: myVerificationNotification, // optional replacement
112
+ },
113
+ passwordReset: {
114
+ expiresIn: "60m",
115
+ identifierField: "email",
116
+ url: (token) => `${env("APP_URL")}/reset-password?token=${token}`,
117
+ notification: myResetNotification,
118
+ setPassword: async (user, plain) => { /* optional */ },
119
+ },
120
+ oneTimeToken: { model: AppOneTimeToken }, // optional model override
121
+ };
122
+ ```
123
+
124
+ The default notifications are `defineNotification` objects (`type: "auth.email-verification"` / `"auth.password-reset"`, `via: ["mail"]`). To replace one, pass anything with a `send(user, { token, expiresAt, url? })` method, for example your own `defineNotification<OneTimeTokenNotificationData>({...})`. `notifications` still has to be installed and configured.
125
+
126
+ ## Gotchas
127
+
128
+ - **Without `url` config, the email contains the raw token**, not a link. Set `url` for anything user-facing.
129
+ - **Timing isn't equalised.** An unknown email skips the database write and the send, so the response can come back measurably faster. The throttle limits probing, but it doesn't hide the timing difference.
130
+ - **Channel send failures don't throw.** `@warlock.js/notifications` only rethrows configuration errors (such as a missing `mail` channel). An SMTP failure goes to its `failed` event, so observe it there.
131
+ - `auth.cleanup` hard-deletes expired and consumed `one_time_tokens` rows (never trashed, even on MongoDB).
132
+
133
+ ## See also
134
+
135
+ - [`@warlock.js/auth/register-user/SKILL.md`](@warlock.js/auth/register-user/SKILL.md) — where `sendEmailVerification` is usually called
136
+ - [`@warlock.js/auth/throttle-login-attempts/SKILL.md`](@warlock.js/auth/throttle-login-attempts/SKILL.md) — the underlying throttle
137
+ - [`@warlock.js/notifications/configure-notifications/SKILL.md`](@warlock.js/notifications/configure-notifications/SKILL.md) — mail channel setup