@warlock.js/auth 4.1.14 → 4.2.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 (91) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/LICENSE +21 -0
  3. package/README.md +78 -37
  4. package/esm/commands/auth-cleanup-command.d.mts +1 -1
  5. package/esm/commands/auth-cleanup-command.d.mts.map +1 -1
  6. package/esm/commands/auth-cleanup-command.mjs +1 -1
  7. package/esm/commands/auth-cleanup-command.mjs.map +1 -1
  8. package/esm/commands/jwt-secret-generator-command.d.mts +1 -1
  9. package/esm/commands/jwt-secret-generator-command.d.mts.map +1 -1
  10. package/esm/commands/jwt-secret-generator-command.mjs +1 -1
  11. package/esm/commands/jwt-secret-generator-command.mjs.map +1 -1
  12. package/esm/contracts/auth-contract.d.mts +2 -2
  13. package/esm/contracts/auth-contract.d.mts.map +1 -1
  14. package/esm/contracts/index.d.mts +1 -1
  15. package/esm/contracts/types.d.mts +105 -76
  16. package/esm/contracts/types.d.mts.map +1 -1
  17. package/esm/contracts/types.mjs +2 -2
  18. package/esm/contracts/types.mjs.map +1 -1
  19. package/esm/index.d.mts +6 -5
  20. package/esm/index.mjs +4 -3
  21. package/esm/middleware/auth.middleware.d.mts +1 -1
  22. package/esm/middleware/auth.middleware.d.mts.map +1 -1
  23. package/esm/middleware/auth.middleware.mjs +9 -9
  24. package/esm/middleware/auth.middleware.mjs.map +1 -1
  25. package/esm/middleware/index.d.mts +2 -0
  26. package/esm/middleware/index.mjs +1 -0
  27. package/esm/middleware/login-throttle.middleware.d.mts +116 -0
  28. package/esm/middleware/login-throttle.middleware.d.mts.map +1 -0
  29. package/esm/middleware/login-throttle.middleware.mjs +105 -0
  30. package/esm/middleware/login-throttle.middleware.mjs.map +1 -0
  31. package/esm/models/access-token/access-token.model.d.mts +47 -5
  32. package/esm/models/access-token/access-token.model.d.mts.map +1 -1
  33. package/esm/models/access-token/access-token.model.mjs +70 -4
  34. package/esm/models/access-token/access-token.model.mjs.map +1 -1
  35. package/esm/models/access-token/index.d.mts +1 -1
  36. package/esm/models/access-token/index.mjs +1 -1
  37. package/esm/models/access-token/migration.mjs +2 -3
  38. package/esm/models/access-token/migration.mjs.map +1 -1
  39. package/esm/models/auth.model.d.mts +2 -2
  40. package/esm/models/auth.model.d.mts.map +1 -1
  41. package/esm/models/auth.model.mjs +1 -1
  42. package/esm/models/auth.model.mjs.map +1 -1
  43. package/esm/models/index.d.mts +4 -3
  44. package/esm/models/index.d.mts.map +1 -1
  45. package/esm/models/index.mjs +3 -3
  46. package/esm/models/index.mjs.map +1 -1
  47. package/esm/models/refresh-token/index.d.mts +1 -1
  48. package/esm/models/refresh-token/index.mjs +1 -1
  49. package/esm/models/refresh-token/migration.mjs +2 -1
  50. package/esm/models/refresh-token/migration.mjs.map +1 -1
  51. package/esm/models/refresh-token/refresh-token.model.d.mts +94 -16
  52. package/esm/models/refresh-token/refresh-token.model.d.mts.map +1 -1
  53. package/esm/models/refresh-token/refresh-token.model.mjs +152 -13
  54. package/esm/models/refresh-token/refresh-token.model.mjs.map +1 -1
  55. package/esm/services/auth-config.mjs +63 -0
  56. package/esm/services/auth-config.mjs.map +1 -0
  57. package/esm/services/auth-events.d.mts +2 -2
  58. package/esm/services/auth-events.d.mts.map +1 -1
  59. package/esm/services/auth-events.mjs +7 -2
  60. package/esm/services/auth-events.mjs.map +1 -1
  61. package/esm/services/auth.service.d.mts +48 -30
  62. package/esm/services/auth.service.d.mts.map +1 -1
  63. package/esm/services/auth.service.mjs +88 -102
  64. package/esm/services/auth.service.mjs.map +1 -1
  65. package/esm/services/generate-jwt-secret.d.mts +1 -1
  66. package/esm/services/generate-jwt-secret.d.mts.map +1 -1
  67. package/esm/services/generate-jwt-secret.mjs +4 -4
  68. package/esm/services/generate-jwt-secret.mjs.map +1 -1
  69. package/esm/services/index.d.mts +1 -1
  70. package/esm/services/jwt.d.mts +13 -4
  71. package/esm/services/jwt.d.mts.map +1 -1
  72. package/esm/services/jwt.mjs +30 -11
  73. package/esm/services/jwt.mjs.map +1 -1
  74. package/esm/utils/auth-error-codes.d.mts +9 -2
  75. package/esm/utils/auth-error-codes.d.mts.map +1 -1
  76. package/esm/utils/auth-error-codes.mjs +7 -1
  77. package/esm/utils/auth-error-codes.mjs.map +1 -1
  78. package/llms-full.txt +269 -20
  79. package/llms.txt +2 -0
  80. package/package.json +9 -11
  81. package/skills/auth-basics/SKILL.md +8 -7
  82. package/skills/customize-token-storage/SKILL.md +112 -0
  83. package/skills/customize-user-type/SKILL.md +7 -2
  84. package/skills/handle-login-and-logout/SKILL.md +1 -1
  85. package/skills/manage-tokens/SKILL.md +9 -9
  86. package/skills/overview/SKILL.md +7 -1
  87. package/skills/protect-routes/SKILL.md +1 -0
  88. package/skills/run-auth-commands/SKILL.md +1 -1
  89. package/skills/throttle-login-attempts/SKILL.md +116 -0
  90. package/cjs/index.cjs +0 -807
  91. package/cjs/index.cjs.map +0 -1
package/package.json CHANGED
@@ -22,15 +22,17 @@
22
22
  "peerDependencies": {
23
23
  "@mongez/copper": "^2.1.2",
24
24
  "@mongez/events": "^2.2.6",
25
- "@warlock.js/fs": "*",
25
+ "@warlock.js/fs": "4.2.0",
26
26
  "@mongez/reinforcements": "^3.2.0",
27
- "@warlock.js/cascade": "*",
28
- "@warlock.js/core": "*",
29
- "@warlock.js/logger": "*",
30
- "@warlock.js/seal": "*"
27
+ "@warlock.js/cache": "4.2.0",
28
+ "@warlock.js/cascade": "4.2.0",
29
+ "@warlock.js/core": "4.2.0",
30
+ "@warlock.js/logger": "4.2.0",
31
+ "@warlock.js/seal": "4.2.0"
31
32
  },
32
- "version": "4.1.14",
33
- "main": "./cjs/index.cjs",
33
+ "version": "4.2.0",
34
+ "type": "module",
35
+ "main": "./esm/index.mjs",
34
36
  "module": "./esm/index.mjs",
35
37
  "types": "./esm/index.d.mts",
36
38
  "exports": {
@@ -38,10 +40,6 @@
38
40
  "import": {
39
41
  "types": "./esm/index.d.mts",
40
42
  "default": "./esm/index.mjs"
41
- },
42
- "require": {
43
- "types": "./esm/index.d.mts",
44
- "default": "./cjs/index.cjs"
45
43
  }
46
44
  }
47
45
  }
@@ -56,15 +56,16 @@ export default {
56
56
  user: User,
57
57
  // admin: Admin, // for multi-user-type
58
58
  },
59
- jwt: {
59
+ accessToken: {
60
60
  secret: env("JWT_SECRET"),
61
61
  expiresIn: "1h",
62
- refresh: {
63
- enabled: true,
64
- expiresIn: "30d",
65
- rotation: true,
66
- maxPerUser: 5,
67
- },
62
+ },
63
+ refreshToken: {
64
+ secret: env("JWT_REFRESH_SECRET"), // recommended: a separate refresh secret
65
+ enabled: true,
66
+ expiresIn: "30d",
67
+ rotation: true,
68
+ maxPerUser: 5,
68
69
  },
69
70
  };
70
71
  ```
@@ -0,0 +1,112 @@
1
+ ---
2
+ name: customize-token-storage
3
+ description: 'Override the persisted AccessToken / RefreshToken models to add columns (multi-tenant `organization_id`, device metadata), rename, or change storage — without forking the package. Extend the model + `schema.extend(...)`, register it under `config.auth.accessToken.model` / `config.auth.refreshToken.model`, override `issue()` to populate the new column, and add a migration. Triggers: `accessToken.model`, `refreshToken.model`, `AccessToken.issue`, `RefreshToken.issue`, `accessTokenSchema`, `refreshTokenSchema`, "add a column to the token table", "multi-tenant tokens", "organization_id on access token", "override the token model", "custom token storage"; typical import `import { AccessToken, accessTokenSchema } from "@warlock.js/auth"`. Skip: multiple user TYPES (not token storage) — `@warlock.js/auth/customize-user-type/SKILL.md`; the token lifecycle API — `@warlock.js/auth/manage-tokens/SKILL.md`; the config blocks themselves — `@warlock.js/auth/auth-basics/SKILL.md`.'
4
+ ---
5
+
6
+ # Customize token storage
7
+
8
+ The package ships `AccessToken` and `RefreshToken` models and runs the whole token flow through their **named statics** — `issue`, `findByToken`, `activeFor`, `revokeAllFor`, etc. The auth service never references a column name directly; it resolves the *active* model from config. So you change token storage by **registering a subclass**, not by forking.
9
+
10
+ Reach for this when you need an extra column on the token tables — the common case is a tenant key (`organization_id`) so tokens are partitioned per organization, or richer device metadata.
11
+
12
+ ## The three pieces
13
+
14
+ A storage override is always three coordinated steps. Miss any one and it breaks — see the strict-mode note below.
15
+
16
+ ### 1. Extend the model + its schema
17
+
18
+ `accessTokenSchema` / `refreshTokenSchema` are exported so you compose them with `.extend(...)` instead of re-declaring the base shape. Override `issue()` to populate your new column (the user is in hand there).
19
+
20
+ ```ts title="src/app/auth/models/app-access-token.ts"
21
+ import { AccessToken, accessTokenSchema, type Auth } from "@warlock.js/auth";
22
+ import { v } from "@warlock.js/seal";
23
+
24
+ export class AppAccessToken extends AccessToken {
25
+ public static schema = accessTokenSchema.extend({
26
+ organization_id: v.string().exists("Organization", { column: "id" }),
27
+ });
28
+
29
+ // populate the tenant key on issue — read it off the authenticating user
30
+ public static issue(user: Auth, token: string, expiresAt: Date) {
31
+ return this.create({
32
+ token,
33
+ user_id: user.id,
34
+ user_type: user.userType,
35
+ expires_at: expiresAt,
36
+ organization_id: user.get("organization_id"),
37
+ });
38
+ }
39
+ }
40
+ ```
41
+
42
+ Refresh tokens follow the same shape — extend `refreshTokenSchema`, and override `issue(user, token, options)` (`options` is `{ familyId, expiresAt, deviceInfo? }`) the same way, copying the base fields plus your column.
43
+
44
+ ### 2. Register the subclass in config
45
+
46
+ ```ts title="src/config/auth.ts"
47
+ import { AppAccessToken } from "app/auth/models/app-access-token";
48
+ import { AppRefreshToken } from "app/auth/models/app-refresh-token";
49
+
50
+ export default {
51
+ userType: { user: User },
52
+ accessToken: {
53
+ model: AppAccessToken, // ← the override
54
+ secret: env("JWT_SECRET"),
55
+ expiresIn: "1h",
56
+ },
57
+ refreshToken: {
58
+ model: AppRefreshToken, // ← the override
59
+ secret: env("JWT_REFRESH_SECRET"),
60
+ expiresIn: "30d",
61
+ },
62
+ };
63
+ ```
64
+
65
+ From here the service, the middleware, and every `authService` helper transparently use your model — `findByToken`, `revokeAllFor`, `enforceMax`, and the rest all run against your columns.
66
+
67
+ ### 3. Add the column to the migration
68
+
69
+ The new column needs a real database column. Add it to your token-table migration (the FK + index match your `User` model's tenant convention):
70
+
71
+ ```ts
72
+ this.uuid("organization_id").references("organizations").onDelete("cascade").index();
73
+ ```
74
+
75
+ See [`@warlock.js/cascade/write-migration/SKILL.md`](@warlock.js/cascade/write-migration/SKILL.md) for the migration mechanics.
76
+
77
+ ## Why all three — the strict-mode trap
78
+
79
+ This is the failure people hit. Cascade's `strictMode: "strip"` **drops any field your schema doesn't declare** before the INSERT. So if you set `organization_id` in `issue()` but don't add it to the schema (step 1), the value is silently stripped, and a NOT-NULL `organization_id` column then **fails the INSERT**. The chain is: `issue()` sets it → the *schema* must declare it so it survives → the *migration* must create the column. All three, or nothing.
80
+
81
+ ## You do NOT need read-side scoping
82
+
83
+ Token lookups go by the unique `token` string (`findByToken`), which already uniquely identifies a row regardless of tenant. So you don't need a tenant-scoped global query scope on the token model — `organization_id` is there for FK cascade-cleanup when an org is deleted, plus partitioning and analytics, not for lookup safety.
84
+
85
+ ## What you're overriding (the contract)
86
+
87
+ Your subclass inherits and may override these statics — the service calls them, never raw queries:
88
+
89
+ | Static | Role |
90
+ | --- | --- |
91
+ | `issue(user, token, …)` | persist a freshly-signed token (override to add columns) |
92
+ | `findByToken(token)` | look a row up by its token string |
93
+ | `findForUser(user, token)` | a user-scoped lookup (logout) |
94
+ | `activeFor(user)` | active, unexpired sessions, newest-first |
95
+ | `revokeAllFor(user)` / `revokeFamily(id)` | revoke a set, returning the revoked rows |
96
+ | `enforceMax(user, max)` | cap concurrent refresh tokens |
97
+ | `purgeExpired()` | delete expired rows (CLI cleanup) |
98
+
99
+ If you rename a column, override the statics that reference it so they map to your name — the service depends on the method, not the column.
100
+
101
+ ## Things NOT to do
102
+
103
+ - **Don't set a column without declaring it in `schema.extend(...)`.** `strictMode: "strip"` removes it; a NOT-NULL column then fails the INSERT.
104
+ - **Don't add a tenant-scoped global scope to the token model.** Lookups are by unique token; a leaky scope is a cross-tenant exposure risk for no lookup benefit.
105
+ - **Don't fork the package to add a column.** Extend + register — you keep receiving package fixes.
106
+ - **Don't forget the migration.** The schema declares the field; only the migration creates the database column.
107
+
108
+ ## See also
109
+
110
+ - [`@warlock.js/auth/customize-user-type/SKILL.md`](@warlock.js/auth/customize-user-type/SKILL.md) — multiple user *types* (a different axis from token *storage*).
111
+ - [`@warlock.js/auth/manage-tokens/SKILL.md`](@warlock.js/auth/manage-tokens/SKILL.md) — the token lifecycle your statics power.
112
+ - [`@warlock.js/cascade/define-model/SKILL.md`](@warlock.js/cascade/define-model/SKILL.md) — extending models and schemas.
@@ -51,10 +51,15 @@ export default {
51
51
  // staff: Staff,
52
52
  // client: Client,
53
53
  },
54
- jwt: {
54
+ accessToken: {
55
55
  secret: env("JWT_SECRET"),
56
56
  expiresIn: "1h",
57
- refresh: { enabled: true, expiresIn: "30d", rotation: true },
57
+ },
58
+ refreshToken: {
59
+ secret: env("JWT_REFRESH_SECRET"),
60
+ enabled: true,
61
+ expiresIn: "30d",
62
+ rotation: true,
58
63
  },
59
64
  };
60
65
  ```
@@ -96,7 +96,7 @@ async function logoutController(request: Request, response: Response) {
96
96
  The contract:
97
97
  - **Pass the access token** → that specific access-token row is deleted.
98
98
  - **Pass the refresh token** → that specific refresh-token row is revoked.
99
- - **Omit refresh token** → behavior depends on `config.auth.jwt.refresh.logoutWithoutToken`:
99
+ - **Omit refresh token** → behavior depends on `config.auth.refreshToken.logoutWithoutToken`:
100
100
  - `"revoke-all"` (default) — every refresh token for this user is revoked. Fail-safe.
101
101
  - `"error"` — throws. Force the client to send the refresh token.
102
102
 
@@ -15,7 +15,7 @@ type RefreshTokenOutput = { token: string; expiresAt: string };
15
15
 
16
16
  type TokenPair = {
17
17
  accessToken: AccessTokenOutput;
18
- refreshToken?: RefreshTokenOutput; // omitted if config.auth.jwt.refresh.enabled = false
18
+ refreshToken?: RefreshTokenOutput; // omitted if config.auth.refreshToken.enabled = false
19
19
  };
20
20
  ```
21
21
 
@@ -34,7 +34,7 @@ const refresh = await authService.createRefreshToken(user, deviceInfo);
34
34
  const pair = await authService.createTokenPair(user, deviceInfo);
35
35
  ```
36
36
 
37
- `createTokenPair` is the typical issuance path. It respects `config.auth.jwt.refresh.enabled` — if disabled, returns only `accessToken`.
37
+ `createTokenPair` is the typical issuance path. It respects `config.auth.refreshToken.enabled` — if disabled, returns only `accessToken`.
38
38
 
39
39
  ## Refresh with rotation — `refreshTokens`
40
40
 
@@ -48,7 +48,7 @@ What happens internally:
48
48
  1. Verify the JWT signature on the old refresh token.
49
49
  2. Find the row in `RefreshToken` — must exist + not be revoked.
50
50
  3. Look up the user via `config.auth.userType[token.userType]`.
51
- 4. **Rotation** (default — `config.auth.jwt.refresh.rotation = true`): revoke the old refresh token, create a new pair from the same `family_id`.
51
+ 4. **Rotation** (default — `config.auth.refreshToken.rotation = true`): revoke the old refresh token, create a new pair from the same `family_id`.
52
52
  5. **No rotation**: mark the old as "used" but keep it valid.
53
53
 
54
54
  **Replay detection.** If the old refresh token is presented again after rotation (already revoked but still in the DB):
@@ -92,7 +92,7 @@ Use this for "active sessions" UIs. Revoke a specific session by calling `.revok
92
92
  await authService.removeAccessToken(user, accessTokenString);
93
93
 
94
94
  // Specific refresh token (via the RefreshToken instance)
95
- const rt = await RefreshToken.first({ token: refreshString });
95
+ const rt = await RefreshToken.findByToken(refreshString);
96
96
  await rt?.revoke();
97
97
 
98
98
  // All access tokens for a user
@@ -108,7 +108,7 @@ await authService.revokeTokenFamily(familyId);
108
108
  ## Max refresh tokens per user
109
109
 
110
110
  ```ts
111
- // In config.auth.jwt.refresh:
111
+ // In config.auth.refreshToken:
112
112
  {
113
113
  maxPerUser: 5, // default
114
114
  }
@@ -120,7 +120,7 @@ When issuing a new refresh token, the service counts active tokens for the user
120
120
 
121
121
  ```ts
122
122
  const cleaned = await authService.cleanupExpiredTokens();
123
- // Returns: number of expired refresh tokens removed.
123
+ // Returns: number of expired refresh tokens removed (also purges expired access-token rows).
124
124
  // Fires "token.expired" event per token + "cleanup.completed" with the count.
125
125
  ```
126
126
 
@@ -146,19 +146,19 @@ For low-level JWT signing/verification (outside the authService flow):
146
146
  ```ts
147
147
  import { jwt } from "@warlock.js/auth";
148
148
 
149
- const token = await jwt.generate(payload, { expiresIn: 3600 });
149
+ const token = await jwt.generate(payload, { expiresIn: "1h" }); // string or ms; a bare number is milliseconds
150
150
  const decoded = await jwt.verify(token);
151
151
 
152
152
  const refreshToken = await jwt.generateRefreshToken(payload, { expiresIn });
153
153
  const decodedRefresh = await jwt.verifyRefreshToken(refreshToken);
154
154
  ```
155
155
 
156
- The package signs access and refresh tokens with independent secrets — `config.auth.jwt.secret` and `config.auth.jwt.refresh.secret`. Setting a distinct `refresh.secret` is recommended: it prevents an access-token compromise from forging refresh tokens (and vice versa). The refresh secret is **optional** — when `config.auth.jwt.refresh.secret` is unset, refresh tokens fall back to the main `config.auth.jwt.secret`, so refresh works out of the box without a second secret.
156
+ The package signs access and refresh tokens with independent secrets — `config.auth.accessToken.secret` and `config.auth.refreshToken.secret`. Setting a distinct `refresh.secret` is recommended: it prevents an access-token compromise from forging refresh tokens (and vice versa). The refresh secret is **optional** — when `config.auth.refreshToken.secret` is unset, refresh tokens fall back to the main `config.auth.accessToken.secret`, so refresh works out of the box without a second secret.
157
157
 
158
158
  ## Things NOT to do
159
159
 
160
160
  - Don't use raw JWT libraries directly. The package handles signing, verification, secret loading, and the access/refresh split.
161
- - Don't disable rotation (`config.auth.jwt.refresh.rotation = false`) unless you genuinely understand the tradeoff — you lose replay detection.
161
+ - Don't disable rotation (`config.auth.refreshToken.rotation = false`) unless you genuinely understand the tradeoff — you lose replay detection.
162
162
  - Don't increase `maxPerUser` to a huge number "to be safe." Each active refresh token is a revocation surface; fewer simultaneous tokens means less attack surface.
163
163
  - Don't manually delete `AccessToken` rows in a service. The user might be hitting a request mid-revoke and get an inconsistent state. Use the `authService` helpers.
164
164
 
@@ -23,7 +23,7 @@ Your user model extends the `Auth` base model and declares its `userType`. A log
23
23
 
24
24
  ## Skills index
25
25
 
26
- Seven task skills. Most apps need `auth-basics` + `protect-routes` + `handle-login-and-logout`.
26
+ Nine task skills. Most apps need `auth-basics` + `protect-routes` + `handle-login-and-logout`.
27
27
 
28
28
  ### Foundations
29
29
 
@@ -49,6 +49,12 @@ The token lifecycle — `generateAccessToken`, `createRefreshToken`, `createToke
49
49
  #### [`customize-user-type`](@warlock.js/auth/customize-user-type/SKILL.md)
50
50
  Support multiple user types in one system — each `Auth` subclass overrides `userType`, `config.auth.userType.<slug>` maps the slug to a model class, `authMiddleware("admin")` / `authMiddleware(["admin", "staff"])` gates per type.
51
51
 
52
+ #### [`customize-token-storage`](@warlock.js/auth/customize-token-storage/SKILL.md)
53
+ Override the persisted `AccessToken` / `RefreshToken` models to add columns (multi-tenant `organization_id`), rename, or change storage — extend the model + schema and register it under `config.auth.accessToken.model` / `config.auth.refreshToken.model`.
54
+
55
+ #### [`throttle-login-attempts`](@warlock.js/auth/throttle-login-attempts/SKILL.md)
56
+ Brute-force / credential-stuffing protection — `loginThrottleMiddleware()` counts only failed logins, resets on success, locks per-account + per-IP after a threshold, and rejects pre-controller with 429. Cache-backed, fails open on a cache outage.
57
+
52
58
  #### [`run-auth-commands`](@warlock.js/auth/run-auth-commands/SKILL.md)
53
59
  Two CLI commands — `warlock jwt.generate` (strong JWT secret → `.env`) and `warlock auth.cleanup` (remove expired refresh tokens). Register via `registerJWTSecretGeneratorCommand()` and `registerAuthCleanupCommand()`.
54
60
 
@@ -103,3 +103,4 @@ The middleware uses the framework's `response.unauthorized({...})` shape. To ove
103
103
 
104
104
  - [`@warlock.js/auth/customize-user-type/SKILL.md`](@warlock.js/auth/customize-user-type/SKILL.md) — config and multi-user-type semantics
105
105
  - [`@warlock.js/auth/handle-login-and-logout/SKILL.md`](@warlock.js/auth/handle-login-and-logout/SKILL.md) — where the access token gets issued in the first place
106
+ - [`@warlock.js/auth/throttle-login-attempts/SKILL.md`](@warlock.js/auth/throttle-login-attempts/SKILL.md) — brute-force throttle to stack in front of the login route
@@ -44,7 +44,7 @@ Run it once when setting up a new project. Each developer typically runs it loca
44
44
  yarn warlock auth.cleanup
45
45
  ```
46
46
 
47
- Runs `authService.cleanupExpiredTokens()` — deletes every refresh token whose `expires_at` has passed. Fires `token.expired` per token and `cleanup.completed` once.
47
+ Runs `authService.cleanupExpiredTokens()` — deletes every refresh token whose `expires_at` has passed, then sweeps expired access-token rows too. Fires `token.expired` per refresh token and `cleanup.completed` once.
48
48
 
49
49
  Schedule it periodically. Two common shapes:
50
50
 
@@ -0,0 +1,116 @@
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
+ Define the `auth.errors.tooManyAttempts` translation key (or pass `errorMessage`) so clients see a real message instead of the raw key. 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
+ - **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`.
109
+ - **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.
110
+ - **Don't call it per-request.** Like every middleware, call it once at route definition; it returns the function that runs per request.
111
+
112
+ ## See also
113
+
114
+ - [`@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.
115
+ - [`@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.
116
+ - [`@warlock.js/core/use-middleware/SKILL.md`](@warlock.js/core/use-middleware/SKILL.md) — `middleware.rateLimit` and the built-in middleware suite.