@warlock.js/auth 5.30.0 → 5.31.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 (35) hide show
  1. package/CHANGELOG.md +12 -1
  2. package/esm/commands/auth-purge-never-expiring-command.mjs.map +1 -1
  3. package/esm/commands/jwt-secret-generator-command.mjs.map +1 -1
  4. package/esm/middleware/auth.middleware.mjs +2 -2
  5. package/esm/middleware/auth.middleware.mjs.map +1 -1
  6. package/esm/middleware/login-throttle.middleware.mjs +1 -1
  7. package/esm/middleware/login-throttle.middleware.mjs.map +1 -1
  8. package/esm/models/access-token/access-token.model.d.mts +0 -24
  9. package/esm/models/access-token/access-token.model.d.mts.map +1 -1
  10. package/esm/models/auth-token-family/auth-token-family.model.d.mts +0 -24
  11. package/esm/models/auth-token-family/auth-token-family.model.d.mts.map +1 -1
  12. package/esm/models/one-time-token/one-time-token.model.d.mts +0 -32
  13. package/esm/models/one-time-token/one-time-token.model.d.mts.map +1 -1
  14. package/esm/models/passkey-credential/passkey-credential.model.d.mts +0 -28
  15. package/esm/models/passkey-credential/passkey-credential.model.d.mts.map +1 -1
  16. package/esm/models/provider-account/provider-account.model.d.mts +0 -24
  17. package/esm/models/provider-account/provider-account.model.d.mts.map +1 -1
  18. package/esm/models/refresh-token/refresh-token.model.d.mts +4 -48
  19. package/esm/models/refresh-token/refresh-token.model.d.mts.map +1 -1
  20. package/esm/services/resolve-request-user.d.mts.map +1 -1
  21. package/esm/services/resolve-request-user.mjs.map +1 -1
  22. package/llms-full.txt +189 -111
  23. package/package.json +8 -8
  24. package/skills/auth-basics/SKILL.md +10 -13
  25. package/skills/customize-token-storage/SKILL.md +24 -19
  26. package/skills/customize-user-type/SKILL.md +3 -1
  27. package/skills/index.md +4 -1
  28. package/skills/login-with-providers/SKILL.md +54 -29
  29. package/skills/manage-tokens/SKILL.md +2 -2
  30. package/skills/overview/SKILL.md +10 -1
  31. package/skills/protect-routes/SKILL.md +23 -14
  32. package/skills/register-user/SKILL.md +4 -4
  33. package/skills/run-auth-commands/SKILL.md +1 -1
  34. package/skills/throttle-login-attempts/SKILL.md +51 -22
  35. package/skills/verify-email-and-reset-password/SKILL.md +7 -5
@@ -7,7 +7,7 @@ description: 'Brute-force / credential-stuffing protection via `loginThrottleMid
7
7
 
8
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
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.
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
11
 
12
12
  ## The shortest version
13
13
 
@@ -23,6 +23,35 @@ That's the whole feature for most apps. Defaults: **5** failed attempts within a
23
23
 
24
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
25
 
26
+ ## What new apps get by default
27
+
28
+ The auth package registers no routes, so throttles apply where you attach them.
29
+ A 5.31 `create-warlock` app already attaches `loginThrottleMiddleware()` to
30
+ `/auth/login`, `tokenIssueThrottleMiddleware()` to `/auth/forgot-password`,
31
+ `tokenConsumeThrottleMiddleware()` to `/auth/reset-password` and a 30/minute
32
+ route `rateLimit` to `/auth/refresh-token`, all marked `public: true`. Every
33
+ route also sits under core's global limit (60 requests/minute per IP). Older
34
+ apps add the middleware to their own `src/app/auth/routes.ts`:
35
+
36
+ ```ts
37
+ import { tokenConsumeThrottleMiddleware, tokenIssueThrottleMiddleware } from "@warlock.js/auth";
38
+ import { router, type RequestHandler } from "@warlock.js/core";
39
+
40
+ const forgotPasswordController: RequestHandler = async ({ response }) =>
41
+ response.success({ sent: true });
42
+ const resetPasswordController: RequestHandler = async ({ response }) =>
43
+ response.success({ reset: true });
44
+
45
+ router.post("/auth/forgot-password", forgotPasswordController, {
46
+ public: true,
47
+ middleware: [tokenIssueThrottleMiddleware()],
48
+ });
49
+ router.post("/auth/reset-password", resetPasswordController, {
50
+ public: true,
51
+ middleware: [tokenConsumeThrottleMiddleware()],
52
+ });
53
+ ```
54
+
26
55
  ## How it behaves
27
56
 
28
57
  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`).
@@ -34,10 +63,10 @@ Per-account tracking stops a password-spray against one user from many IPs; per-
34
63
 
35
64
  ```ts
36
65
  loginThrottleMiddleware({
37
- max: 5, // failures allowed in the window before lockout
38
- window: "15m", // counting window — ms-format string or seconds (number)
66
+ max: 5, // failures allowed in the window before lockout
67
+ window: "15m", // counting window — ms-format string or seconds (number)
39
68
  lockoutDuration: "15m", // how long the lock lasts once tripped
40
- by: ["email", "ip"], // identifiers tracked, each independently
69
+ by: ["email", "ip"], // identifiers tracked, each independently
41
70
  identifierKey: "email", // which credential field is the account key
42
71
  errorMessage: "Too many attempts. Try again later.",
43
72
  });
@@ -55,16 +84,16 @@ loginThrottleMiddleware({
55
84
  });
56
85
  ```
57
86
 
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 |
87
+ | Option | Default | Purpose |
88
+ | ----------------- | ---------------------------------- | ----------------------------------------- |
89
+ | `max` | `5` | Failures within the window before lockout |
90
+ | `window` | `"15m"` | Counting window (`ms`-string or seconds) |
91
+ | `lockoutDuration` | `"15m"` | Lock TTL once tripped |
92
+ | `by` | `["email", "ip"]` | Identifiers tracked independently |
93
+ | `identifierKey` | `"email"` | Credential field used as the account key |
94
+ | `errorMessage` | i18n `auth.errors.tooManyAttempts` | 429 message override |
95
+ | `isFailure` | `(res) => !res.isOk` | What counts as a failed attempt |
96
+ | `identify` | built-in email + ip extraction | Custom identifier list |
68
97
 
69
98
  ## The 429 response
70
99
 
@@ -92,20 +121,20 @@ router.post("/auth/forgot-password", forgotController, {
92
121
 
93
122
  ## When to use this vs core's `rateLimit`
94
123
 
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 |
124
+ | | `loginThrottleMiddleware` (auth) | `middleware.rateLimit` (core) |
125
+ | ---------------------------- | -------------------------------------------- | ----------------------------- |
126
+ | Counts | only **failed** logins | **every** request |
127
+ | Resets on a successful login | yes | no |
128
+ | Locks per account / per IP | yes | per key (default IP) |
129
+ | Storage | `@warlock.js/cache` (shared across replicas) | in-process map |
101
130
 
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()]`.
131
+ 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
132
 
104
133
  ## Gotchas
105
134
 
106
135
  - **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
136
  - **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.
137
+ - **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, 1, { ttl: window })`, so concurrent failures on different servers are never lost. Passing the TTL on the increment also expires a counter recreated after its seed expires, without extending an existing window. 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
138
  - **Account and IP keys are hashed.** The built-in extractor keys both by an HMAC of the trimmed, lower-cased value (`email.<hmac>`, `ip.<hmac>`), keyed from `auth.accessToken.secret`, so cache logs never show the address or IP and a list of known emails cannot reverse the key. Without a configured secret it falls back to SHA-256. A custom `identify` is used as is — hash personal values yourself (`throttleIdentifierHash(kind, value)` is exported).
110
139
  - **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`: the lock stores its own deadline, so even a sliding-expiry driver (`memoryExtended`) cannot extend it.
111
140
  - **Needs a real cache driver.** The `null` driver stores nothing, so the throttle never locks; it warns once when that happens. On Redis, any version from 6 works.
@@ -94,10 +94,10 @@ router.post("/auth/reset-password", resetPasswordController, {
94
94
 
95
95
  Both presets wrap `loginThrottleMiddleware` and accept its options:
96
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 |
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
101
 
102
102
  ## Configuration (`src/config/auth.ts`)
103
103
 
@@ -115,7 +115,9 @@ const authConfigurations: AuthConfigurations = {
115
115
  identifierField: "email",
116
116
  url: (token) => `${env("APP_URL")}/reset-password?token=${token}`,
117
117
  notification: myResetNotification,
118
- setPassword: async (user, plain) => { /* optional */ },
118
+ setPassword: async (user, plain) => {
119
+ /* optional */
120
+ },
119
121
  },
120
122
  oneTimeToken: { model: AppOneTimeToken }, // optional model override
121
123
  };