@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
package/llms-full.txt CHANGED
@@ -59,10 +59,7 @@ import { defineConfig } from "@warlock.js/core";
59
59
 
60
60
  export default defineConfig({
61
61
  cli: {
62
- commands: [
63
- registerJWTSecretGeneratorCommand(),
64
- registerAuthCleanupCommand(),
65
- ],
62
+ commands: [registerJWTSecretGeneratorCommand(), registerAuthCleanupCommand()],
66
63
  },
67
64
  database: {
68
65
  migrations: authMigrations,
@@ -106,19 +103,19 @@ That is deliberate. `ms` answers `undefined` for a string it can't parse (`"30da
106
103
 
107
104
  Two values that look right and are not:
108
105
 
109
- - `expiresIn: 2592000` — a bare number. `ms` *formats* numbers instead of parsing them, so this is rejected; write `"30d"`.
106
+ - `expiresIn: 2592000` — a bare number. `ms` _formats_ numbers instead of parsing them, so this is rejected; write `"30d"`.
110
107
  - `expiresIn: env("JWT_TTL")` where the variable is unset — resolves to `""` and is rejected. Give the env read a default: `env("JWT_TTL") || "1h"`.
111
108
 
112
109
  ## Pick a skill
113
110
 
114
- | If the task is about… | Load |
115
- | --- | --- |
116
- | Gating routes with `authMiddleware(allowedUserType)`, any-authenticated vs typed access | The `protect-routes` topic |
117
- | `authService.login(...)`, `attemptLogin`, full credentials-to-tokens flow + logout | The `handle-login-and-logout` topic |
118
- | Token lifecycle — `generateAccessToken`, `createRefreshToken`, rotation, family revocation, max-per-user | The `manage-tokens` topic |
119
- | Register a new user + issue tokens in one flow | The `register-user` topic |
120
- | Multi-user-type apps (`user`, `admin`, `client`), `config.auth.userType.<name>` mapping | The `customize-user-type` topic |
121
- | `warlock jwt.generate` + `warlock auth.cleanup` CLI commands | The `run-auth-commands` topic |
111
+ | If the task is about… | Load |
112
+ | -------------------------------------------------------------------------------------------------------- | ----------------------------------- |
113
+ | Gating routes with `authMiddleware(allowedUserType)`, any-authenticated vs typed access | The `protect-routes` topic |
114
+ | `authService.login(...)`, `attemptLogin`, full credentials-to-tokens flow + logout | The `handle-login-and-logout` topic |
115
+ | Token lifecycle — `generateAccessToken`, `createRefreshToken`, rotation, family revocation, max-per-user | The `manage-tokens` topic |
116
+ | Register a new user + issue tokens in one flow | The `register-user` topic |
117
+ | Multi-user-type apps (`user`, `admin`, `client`), `config.auth.userType.<name>` mapping | The `customize-user-type` topic |
118
+ | `warlock jwt.generate` + `warlock auth.cleanup` CLI commands | The `run-auth-commands` topic |
122
119
 
123
120
  ## Things NOT to do
124
121
 
@@ -137,7 +134,7 @@ description: 'Override the persisted AccessToken / RefreshToken models to add co
137
134
 
138
135
  # Customize token storage
139
136
 
140
- 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.
137
+ 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.
141
138
 
142
139
  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.
143
140
 
@@ -188,7 +185,12 @@ export class AppAccessToken extends AccessToken {
188
185
  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.
189
186
 
190
187
  ```ts title="src/app/auth/models/app-refresh-token.ts"
191
- import { RefreshToken, refreshTokenSchema, type Auth, type RefreshTokenIssueOptions } from "@warlock.js/auth";
188
+ import {
189
+ RefreshToken,
190
+ refreshTokenSchema,
191
+ type Auth,
192
+ type RefreshTokenIssueOptions,
193
+ } from "@warlock.js/auth";
192
194
  import { v } from "@warlock.js/seal";
193
195
 
194
196
  export class AppRefreshToken extends RefreshToken {
@@ -220,12 +222,12 @@ import { AppRefreshToken } from "app/auth/models/app-refresh-token";
220
222
  export default {
221
223
  userType: { user: User },
222
224
  accessToken: {
223
- model: AppAccessToken, // ← the override
225
+ model: AppAccessToken, // ← the override
224
226
  secret: env("JWT_SECRET"),
225
227
  expiresIn: "1h",
226
228
  },
227
229
  refreshToken: {
228
- model: AppRefreshToken, // ← the override
230
+ model: AppRefreshToken, // ← the override
229
231
  secret: env("JWT_REFRESH_SECRET"),
230
232
  expiresIn: "30d",
231
233
  },
@@ -246,7 +248,7 @@ See the `write-migration` topic of the `warlock-js-cascade` skill for the migrat
246
248
 
247
249
  ## Why all three — the strict-mode trap
248
250
 
249
- 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.
251
+ 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.
250
252
 
251
253
  ## You do NOT need read-side scoping
252
254
 
@@ -256,23 +258,23 @@ Token lookups go by the unique `token` string (`findByToken`), which already uni
256
258
 
257
259
  Your subclass inherits and may override these statics — the service calls them, never raw queries:
258
260
 
259
- | Static | Role |
260
- | --- | --- |
261
- | `issue(user, token, …)` | persist a freshly-signed token (override to add columns) |
262
- | `findByToken(token)` | look a row up by its token string |
263
- | `findForUser(user, token)` | a user-scoped lookup (logout) |
264
- | `activeFor(user)` | active, unexpired sessions, newest-first |
265
- | `revokeAllFor(user)` / `revokeFamily(id)` | revoke a set, returning the revoked rows |
266
- | `enforceMax(user, max)` | cap concurrent refresh tokens |
267
- | `purgeExpired()` | delete expired rows (CLI cleanup) |
261
+ | Static | Role |
262
+ | ---------------------------------------------- | -------------------------------------------------------- |
263
+ | `issue(user, token, …)` | persist a freshly-signed token (override to add columns) |
264
+ | `findByToken(token)` | look a row up by its token string |
265
+ | `findForUser(user, token)` | a user-scoped lookup (logout) |
266
+ | `activeFor(user)` | active, unexpired sessions, newest-first |
267
+ | `revokeAllFor(user)` / `revokeFamily(id)` | revoke a set, returning the revoked rows |
268
+ | `enforceMax(user, max)` | cap concurrent refresh tokens |
269
+ | `purgeExpired()` | delete expired rows (CLI cleanup) |
268
270
  | `findNeverExpiring()` / `purgeNeverExpiring()` | find/delete rows that can never expire (CLI remediation) |
269
271
 
270
272
  Plus two instance getters, both of which the route gate consults on every request:
271
273
 
272
- | Getter | Role |
273
- | --- | --- |
274
- | `isExpired` | whether the row's persisted expiry has passed — **the middleware rejects the request when this is `true`** |
275
- | `neverExpires` | whether nothing can ever retire the row (unusable `expires_at`, or a token with no `exp` claim) |
274
+ | Getter | Role |
275
+ | -------------- | ---------------------------------------------------------------------------------------------------------- |
276
+ | `isExpired` | whether the row's persisted expiry has passed — **the middleware rejects the request when this is `true`** |
277
+ | `neverExpires` | whether nothing can ever retire the row (unusable `expires_at`, or a token with no `exp` claim) |
276
278
 
277
279
  If you rename a column, override the statics **and these getters** so they map to your name — the service depends on the method, not the column.
278
280
 
@@ -287,7 +289,7 @@ If you rename a column, override the statics **and these getters** so they map t
287
289
 
288
290
  ## See also
289
291
 
290
- - The `customize-user-type` topic — multiple user *types* (a different axis from token *storage*).
292
+ - The `customize-user-type` topic — multiple user _types_ (a different axis from token _storage_).
291
293
  - The `manage-tokens` topic — the token lifecycle your statics power.
292
294
  - The `define-model` topic of the `warlock-js-cascade` skill — extending models and schemas.
293
295
 
@@ -395,7 +397,9 @@ const dashboardController: RequestHandler = async ({ response }) => response.suc
395
397
 
396
398
  router.get("/account", userAccountController, { middleware: [authMiddleware("user")] });
397
399
  router.get("/admin/users", listUsersController, { middleware: [authMiddleware("admin")] });
398
- router.get("/back-office", backOfficeController, { middleware: [authMiddleware(["admin", "staff"])] });
400
+ router.get("/back-office", backOfficeController, {
401
+ middleware: [authMiddleware(["admin", "staff"])],
402
+ });
399
403
  router.get("/dashboard", dashboardController, { middleware: [authMiddleware([])] }); // any logged-in
400
404
  ```
401
405
 
@@ -686,13 +690,13 @@ authService.setAuthCookie(response, tokens.accessToken); // cookie session, or r
686
690
 
687
691
  ## Install
688
692
 
689
- | Method | Command | Installs |
690
- | --- | --- | --- |
691
- | Google | `warlock add auth-google` | `jose` |
692
- | Apple, LinkedIn | `warlock add auth-google` (or `npm install jose`) — there is no separate `auth-apple` / `auth-linkedin` feature yet | `jose` |
693
- | GitHub, Discord, Facebook, X | nothing — plain OAuth 2 over `fetch` | nothing |
694
- | Passkeys | `warlock add auth-passkeys` | `@simplewebauthn/server` (add `@simplewebauthn/browser` to your client bundle) |
695
- | Phone code | `warlock add notifications` + your own `sms`/`whatsapp` channel | nothing else. Auth ships no SMS/WhatsApp driver |
693
+ | Method | Command | Installs |
694
+ | ---------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
695
+ | Google | `warlock add auth-google` | `jose` |
696
+ | Apple, LinkedIn | `warlock add auth-google` (or `npm install jose`) — there is no separate `auth-apple` / `auth-linkedin` feature yet | `jose` |
697
+ | GitHub, Discord, Facebook, X | nothing — plain OAuth 2 over `fetch` | nothing |
698
+ | Passkeys | `warlock add auth-passkeys` | `@simplewebauthn/server` (add `@simplewebauthn/browser` to your client bundle) |
699
+ | Phone code | `warlock add notifications` + your own `sms`/`whatsapp` channel | nothing else. Auth ships no SMS/WhatsApp driver |
696
700
 
697
701
  The SDKs are optional peers, loaded with `import()` only when a method runs. If one is missing, the call throws `AuthProviderSdkMissingError` (500), and the message names the `warlock add` command. Run the migrations: `authMigrations` now includes `provider_accounts` and `passkey_credentials`, and `one_time_tokens` gained an `attempts` column.
698
702
 
@@ -870,17 +874,25 @@ passkeys: { rpID: "example.com", rpName: "Example", origin: "https://example.com
870
874
 
871
875
  ```ts
872
876
  // Register (logged in)
873
- router.post("/auth/passkeys/register/options", async ({ request, response }) =>
874
- response.success(await generatePasskeyRegistrationOptions(request.locals.user)),
875
- { middleware: [authMiddleware("user")] });
876
- router.post("/auth/passkeys/register", async ({ request, response }) => {
877
- await verifyPasskeyRegistration(request, request.locals.user, request.input("credential"));
878
- return response.success({ registered: true });
879
- }, { middleware: [authMiddleware("user")] });
877
+ router.post(
878
+ "/auth/passkeys/register/options",
879
+ async ({ request, response }) =>
880
+ response.success(await generatePasskeyRegistrationOptions(request.locals.user)),
881
+ { middleware: [authMiddleware("user")] },
882
+ );
883
+ router.post(
884
+ "/auth/passkeys/register",
885
+ async ({ request, response }) => {
886
+ await verifyPasskeyRegistration(request, request.locals.user, request.input("credential"));
887
+ return response.success({ registered: true });
888
+ },
889
+ { middleware: [authMiddleware("user")] },
890
+ );
880
891
 
881
892
  // Log in
882
893
  router.post("/auth/passkeys/login/options", async ({ response }) =>
883
- response.success(await generatePasskeyAuthenticationOptions()));
894
+ response.success(await generatePasskeyAuthenticationOptions()),
895
+ );
884
896
  router.post("/auth/passkeys/login", async ({ request, response }) => {
885
897
  const { tokens } = await verifyPasskeyAuthentication(request, request.input("credential"));
886
898
  authService.setAuthCookie(response, tokens.accessToken);
@@ -894,16 +906,23 @@ Browser:
894
906
  import { startAuthentication, startRegistration } from "@simplewebauthn/browser";
895
907
 
896
908
  const post = (url: string, body?: unknown) =>
897
- fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) })
898
- .then((r) => r.json());
909
+ fetch(url, {
910
+ method: "POST",
911
+ headers: { "content-type": "application/json" },
912
+ body: JSON.stringify(body),
913
+ }).then((r) => r.json());
899
914
 
900
915
  // register
901
916
  const regOptions = await post("/auth/passkeys/register/options");
902
- await post("/auth/passkeys/register", { credential: await startRegistration({ optionsJSON: regOptions }) });
917
+ await post("/auth/passkeys/register", {
918
+ credential: await startRegistration({ optionsJSON: regOptions }),
919
+ });
903
920
 
904
921
  // log in
905
922
  const authOptions = await post("/auth/passkeys/login/options");
906
- await post("/auth/passkeys/login", { credential: await startAuthentication({ optionsJSON: authOptions }) });
923
+ await post("/auth/passkeys/login", {
924
+ credential: await startAuthentication({ optionsJSON: authOptions }),
925
+ });
907
926
  ```
908
927
 
909
928
  (Unwrap `response.success`'s envelope if your app wraps payloads.)
@@ -920,16 +939,26 @@ otp: { channel: "sms" /* or "whatsapp" */, phoneField: "phone", expiresIn: "5m",
920
939
  ```
921
940
 
922
941
  ```ts
923
- router.post("/auth/otp/request", async ({ request, response }) => {
924
- await requestOtp(User, request.input("phone"), { channel: request.input("via") === "whatsapp" ? "whatsapp" : "sms" });
925
- return response.success({ message: "If that number is registered, a code is on its way." });
926
- }, { middleware: [otpRequestThrottleMiddleware()] }); // 3 / 1h per phone + IP
927
-
928
- router.post("/auth/otp/verify", async ({ request, response }) => {
929
- const { tokens } = await verifyOtp(User, request.input("phone"), request.input("code"));
930
- authService.setAuthCookie(response, tokens.accessToken);
931
- return response.success({ ok: true });
932
- }, { middleware: [otpVerifyThrottleMiddleware()] }); // 5 failures / 15m per phone + IP
942
+ router.post(
943
+ "/auth/otp/request",
944
+ async ({ request, response }) => {
945
+ await requestOtp(User, request.input("phone"), {
946
+ channel: request.input("via") === "whatsapp" ? "whatsapp" : "sms",
947
+ });
948
+ return response.success({ message: "If that number is registered, a code is on its way." });
949
+ },
950
+ { middleware: [otpRequestThrottleMiddleware()] },
951
+ ); // 3 / 1h per phone + IP
952
+
953
+ router.post(
954
+ "/auth/otp/verify",
955
+ async ({ request, response }) => {
956
+ const { tokens } = await verifyOtp(User, request.input("phone"), request.input("code"));
957
+ authService.setAuthCookie(response, tokens.accessToken);
958
+ return response.success({ ok: true });
959
+ },
960
+ { middleware: [otpVerifyThrottleMiddleware()] },
961
+ ); // 5 failures / 15m per phone + IP
933
962
  ```
934
963
 
935
964
  - Codes have 6 digits and are stored in `one_time_tokens` (purpose `otp`) as a salted HMAC keyed from your access-token secret, never as plain SHA-256, which is trivially reversible for 6 digits. A new request invalidates the previous code.
@@ -972,7 +1001,7 @@ type RefreshTokenOutput = { token: string; expiresAt: string };
972
1001
 
973
1002
  type TokenPair = {
974
1003
  accessToken: AccessTokenOutput;
975
- refreshToken?: RefreshTokenOutput; // omitted if config.auth.refreshToken.enabled = false
1004
+ refreshToken?: RefreshTokenOutput; // omitted if config.auth.refreshToken.enabled = false
976
1005
  };
977
1006
  ```
978
1007
 
@@ -1047,7 +1076,7 @@ The family ties together "successive rotations of one session." In 5.19 it is al
1047
1076
  const sessions = await authService.getActiveSessions(user);
1048
1077
 
1049
1078
  for (const session of sessions) {
1050
- session.get("device_info"); // { userAgent, ip, deviceId? } if provided at login
1079
+ session.get("device_info"); // { userAgent, ip, deviceId? } if provided at login
1051
1080
  session.get("created_at");
1052
1081
  session.get("expires_at");
1053
1082
  }
@@ -1187,41 +1216,50 @@ Nine task skills. Most apps need `auth-basics` + `protect-routes` + `handle-logi
1187
1216
  ### Foundations
1188
1217
 
1189
1218
  #### `auth-basics`
1219
+
1190
1220
  Start here. The `Auth` base model, `authMiddleware` gate, `authService` (login/logout/refresh), AccessToken + RefreshToken persistence, multi-user-type support.
1191
1221
 
1192
1222
  ### The flows
1193
1223
 
1194
1224
  #### `handle-login-and-logout`
1225
+
1195
1226
  `authService.login(Model, credentials, deviceInfo?)` — verify password, issue the token pair, fire events. `authService.logout(user, accessToken?, refreshToken?)` — revoke tokens. For your `POST /login` and `POST /logout` controllers.
1196
1227
 
1197
1228
  #### `register-user`
1229
+
1198
1230
  Sign up a new user and issue the first token pair — `User.create({ ...password: await hashPassword(plain) })` then `authService.createTokenPair(user)`. For `POST /register`.
1199
1231
 
1200
1232
  #### `protect-routes`
1233
+
1201
1234
  `authMiddleware` object and legacy overloads: typed/default hard gates, optional resolution, credential source, and page-local redirects. Sets `request.locals.user` + `request.decodedAccessToken` when a user resolves.
1202
1235
 
1203
1236
  ### Going deeper
1204
1237
 
1205
1238
  #### `manage-tokens`
1239
+
1206
1240
  The token lifecycle — `generateAccessToken`, `createRefreshToken`, `createTokenPair`, `refreshTokens` (rotation + replay detection), `revokeAllTokens`, `revokeTokenFamily`, `cleanupExpiredTokens`, `getActiveSessions`. For custom login/registration, token revocation, "logout everywhere", and scheduled cleanup.
1207
1241
 
1208
1242
  #### `customize-user-type`
1243
+
1209
1244
  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.
1210
1245
 
1211
1246
  #### `customize-token-storage`
1247
+
1212
1248
  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`.
1213
1249
 
1214
1250
  #### `throttle-login-attempts`
1251
+
1215
1252
  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.
1216
1253
 
1217
1254
  #### `run-auth-commands`
1255
+
1218
1256
  Two CLI commands — `warlock jwt.generate` (strong JWT secret → `.env`) and `warlock auth.cleanup` (remove expired refresh tokens). Register via `registerJWTSecretGeneratorCommand()` and `registerAuthCleanupCommand()`.
1219
1257
 
1220
1258
  ## What this package deliberately doesn't do
1221
1259
 
1222
1260
  - **Session-cookie auth.** It's JWT/token-based. If you need server-side sessions, this isn't it.
1223
1261
  - **OAuth / social login / SSO.** No provider adapters here — wire those at the controller layer and create the user through this package's models.
1224
- - **Authorization / roles / permissions (RBAC).** It authenticates (who you are) and gates by user *type*, not fine-grained permissions. Build RBAC on top.
1262
+ - **Authorization / roles / permissions (RBAC).** It authenticates (who you are) and gates by user _type_, not fine-grained permissions. Build RBAC on top.
1225
1263
  - **Standalone use.** It depends on `@warlock.js/core` for routing, models (Cascade), and config.
1226
1264
 
1227
1265
  ## See also
@@ -1299,21 +1337,21 @@ The user is loaded via `Model.find(decodedToken.id)` against the `config.auth.us
1299
1337
 
1300
1338
  A token passes three separate checks, in order — a token can fail any one of them while satisfying the other two:
1301
1339
 
1302
- 1. **The JWT verifies** — signature, algorithm, `tokenType`, and (since 4.12.0) the presence of an `exp` claim that has not passed. A token with *no* `exp` is rejected: there is no deadline to check, so it would otherwise verify indefinitely.
1340
+ 1. **The JWT verifies** — signature, algorithm, `tokenType`, and (since 4.12.0) the presence of an `exp` claim that has not passed. A token with _no_ `exp` is rejected: there is no deadline to check, so it would otherwise verify indefinitely.
1303
1341
  2. **The access-token row still exists** — deleting it (logout) invalidates the token immediately, before its JWT expiry.
1304
1342
  3. **The row's own `expires_at` has not passed** (since 4.12.0). The database is the authority on the session: a row whose expiry has elapsed is rejected and deleted, even if the JWT itself is still within its lifetime. A row with a missing or unparseable `expires_at` is treated as expired, not as never-expiring.
1305
1343
 
1306
1344
  Missing or invalid credentials return 401; an authenticated disallowed user type returns 403:
1307
1345
 
1308
- | Error code | When |
1309
- | --- | --- |
1310
- | `MissingAccessToken` | No credential at the configured source |
1346
+ | Error code | When |
1347
+ | -------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
1348
+ | `MissingAccessToken` | No credential at the configured source |
1311
1349
  | `InvalidAccessToken` | Token doesn't verify (signature, missing/passed `exp`, wrong token type), has no DB row, or the row's `expires_at` has passed |
1312
- | `Unauthorized` | Token valid but user-type isn't in the allowed list (403) |
1350
+ | `Unauthorized` | Token valid but user-type isn't in the allowed list (403) |
1313
1351
 
1314
1352
  ## Page routes: redirect to login instead of the JSON 401
1315
1353
 
1316
- By default every auth failure — API or page — returns the JSON `401` above. That's right for an API client, but a logged-out human who *navigates* to a guarded page route gets a raw JSON error blob instead of a login screen.
1354
+ By default every auth failure — API or page — returns the JSON `401` above. That's right for an API client, but a logged-out human who _navigates_ to a guarded page route gets a raw JSON error blob instead of a login screen.
1317
1355
 
1318
1356
  Opt into a redirect with `auth.pageAuth` (5.8+). Set `loginPath` and a guarded **page** route (`route.isPage`) that a logged-out browser hits redirects to `loginPath?returnUrl=<original path>` instead of returning the JSON 401:
1319
1357
 
@@ -1323,7 +1361,7 @@ import { User } from "app/users/models/user/user.model";
1323
1361
  export default {
1324
1362
  userType: { user: User },
1325
1363
  pageAuth: {
1326
- loginPath: "/login", // set this to turn the redirect on
1364
+ loginPath: "/login", // set this to turn the redirect on
1327
1365
  returnUrlParam: "returnUrl", // optional, this is the default
1328
1366
  },
1329
1367
  };
@@ -1355,7 +1393,7 @@ declare module "@warlock.js/auth" {
1355
1393
  import { type RequestHandler } from "@warlock.js/core";
1356
1394
 
1357
1395
  export const accountController: RequestHandler = async ({ request, response }) => {
1358
- const user = request.locals.user!; // typed via the RequestUser augmentation above
1396
+ const user = request.locals.user!; // typed via the RequestUser augmentation above
1359
1397
  return response.success({
1360
1398
  id: user.id,
1361
1399
  email: user.get("email"),
@@ -1381,9 +1419,14 @@ Every route inside the group is gated — the group's `middleware` array applies
1381
1419
  Use `optional: true` in the object form when a public route should receive a verified user when one is present. Missing or invalid credentials continue anonymously; a hard gate remains the default.
1382
1420
 
1383
1421
  ```ts
1384
- middleware: [authMiddleware({ source: "header", optional: true })]
1422
+ middleware: [authMiddleware({ source: "header", optional: true })];
1385
1423
  ```
1386
1424
 
1425
+ `authMiddleware()` marks itself as an auth guard for `warlock dev`'s
1426
+ unguarded-route warning (`isAuthGuardMiddleware()` from `@warlock.js/core`);
1427
+ the optional form does not count, so a mutating route behind only optional
1428
+ auth still needs `public: true` to stay quiet.
1429
+
1387
1430
  ## Automatic cookie renewal
1388
1431
 
1389
1432
  Add `refresh: { source: "cookie", key: "refresh_token", overlapMs?: 5000 }`
@@ -1393,6 +1436,7 @@ through `authService.setAuthCookie`; it never replays a handler. `overlapMs` is
1393
1436
  clamped to 0–10,000 ms (default 5,000). Only the exact immediate successor is
1394
1437
  tolerated during that window. The legacy `refreshTokens` API stays strict; a
1395
1438
  later old-token presentation revokes its family.
1439
+
1396
1440
  ## Custom error responses
1397
1441
 
1398
1442
  The middleware uses the framework's `response.unauthorized({...})` shape. To override the response globally, hook the framework's error transformer to remap `AuthErrorCodes.*` codes.
@@ -1417,8 +1461,9 @@ import { User } from "app/users/models/user/user.model";
1417
1461
 
1418
1462
  const articlesController: RequestHandler = async ({ request, response }) => {
1419
1463
  // Who-may-act-on-whom: an ABAC policy on top of the RBAC grant
1420
- definePolicy("users.create", (actor, target, ctx) =>
1421
- ctx.hasRole("superAdmin") || (target as User).userType === "teacher",
1464
+ definePolicy(
1465
+ "users.create",
1466
+ (actor, target, ctx) => ctx.hasRole("superAdmin") || (target as User).userType === "teacher",
1422
1467
  );
1423
1468
 
1424
1469
  if (await can(request.locals.user!, "users.create", { resource: request.body })) {
@@ -1429,10 +1474,12 @@ const articlesController: RequestHandler = async ({ request, response }) => {
1429
1474
  };
1430
1475
 
1431
1476
  // Permission-based route gate, in place of a user-type gate
1432
- router.post("/articles", articlesController, { middleware: [authMiddleware([]), gate("articles.create")] });
1477
+ router.post("/articles", articlesController, {
1478
+ middleware: [authMiddleware([]), gate("articles.create")],
1479
+ });
1433
1480
  ```
1434
1481
 
1435
- Use `authMiddleware` to establish *who the caller is*, and `access` to decide *what they may do*. They compose — `access` reads the user `authMiddleware` put on the request.
1482
+ Use `authMiddleware` to establish _who the caller is_, and `access` to decide _what they may do_. They compose — `access` reads the user `authMiddleware` put on the request.
1436
1483
 
1437
1484
  ## Cookie credentials
1438
1485
 
@@ -1448,7 +1495,7 @@ A route is header-only or cookie-only, never both — `tokenFrom` accepts exactl
1448
1495
 
1449
1496
  ### Writing and clearing the cookie: `authService.setAuthCookie` / `clearAuthCookie`
1450
1497
 
1451
- Reading a cookie is opt-in per route (above); *writing* one is a separate, explicit step your login/logout controller calls — `authService.login`/`logout` never set cookies implicitly, so upgrading never starts emitting `Set-Cookie` for an existing bearer-only app.
1498
+ Reading a cookie is opt-in per route (above); _writing_ one is a separate, explicit step your login/logout controller calls — `authService.login`/`logout` never set cookies implicitly, so upgrading never starts emitting `Set-Cookie` for an existing bearer-only app.
1452
1499
 
1453
1500
  ```ts
1454
1501
  import { type RequestHandler } from "@warlock.js/core";
@@ -1594,7 +1641,7 @@ export const registerController: RequestHandler = async ({ request, response })
1594
1641
 
1595
1642
  // 4. Respond
1596
1643
  return response.successCreate({
1597
- user, // shape via static toJsonColumns / static resource
1644
+ user, // shape via static toJsonColumns / static resource
1598
1645
  tokens,
1599
1646
  });
1600
1647
  };
@@ -1609,7 +1656,7 @@ Always pass `hashPassword(plain)` — never store the plain password. The `hashP
1609
1656
  ```ts
1610
1657
  import { hashPassword, verifyPassword } from "@warlock.js/core";
1611
1658
 
1612
- const hash = await hashPassword("plaintext"); // store this
1659
+ const hash = await hashPassword("plaintext"); // store this
1613
1660
  const ok = await verifyPassword("plaintext", hash); // compare on login
1614
1661
  ```
1615
1662
 
@@ -1623,7 +1670,7 @@ Define the password as `v.string().strongPassword(12)` (or similar) in your User
1623
1670
  const userSchema = v.object({
1624
1671
  email: v.string().email(),
1625
1672
  name: v.string().min(2).max(120),
1626
- password: v.string().strongPassword(12), // 12+ chars, upper/lower/digit/symbol
1673
+ password: v.string().strongPassword(12), // 12+ chars, upper/lower/digit/symbol
1627
1674
  // status, role, etc.
1628
1675
  });
1629
1676
  ```
@@ -1635,7 +1682,7 @@ But **don't return the password in the public output**:
1635
1682
  export class User extends Model<UserSchema> {
1636
1683
  public static table = "users";
1637
1684
  public static schema = userSchema;
1638
- public static toJsonColumns = ["id", "email", "name", "created_at"]; // omit password
1685
+ public static toJsonColumns = ["id", "email", "name", "created_at"]; // omit password
1639
1686
  }
1640
1687
  ```
1641
1688
 
@@ -1786,7 +1833,7 @@ Register with `registerAuthPurgeNeverExpiringCommand()`.
1786
1833
 
1787
1834
  Finds every token row that **can never retire itself**, on two independent signals: an `expires_at` that is missing or unparseable, and a stored token carrying no `exp` claim. It reports `id` / `user_id` / `user_type` / `expires_at` per row — never the token string, which is a live credential until the row is deleted — and then deletes them. Affected users must log in again.
1788
1835
 
1789
- **This is not a substitute for `auth.cleanup`, and `auth.cleanup` is not a substitute for it.** Cleanup selects `expires_at < now`; an `Invalid Date` compares `false` against *every* date, so such a row satisfies neither `< now` nor `> now` and no date predicate can ever reach it. The "no `exp` claim" signal is not in a column at all — it is inside the JWT.
1836
+ **This is not a substitute for `auth.cleanup`, and `auth.cleanup` is not a substitute for it.** Cleanup selects `expires_at < now`; an `Invalid Date` compares `false` against _every_ date, so such a row satisfies neither `< now` nor `> now` and no date predicate can ever reach it. The "no `exp` claim" signal is not in a column at all — it is inside the JWT.
1790
1837
 
1791
1838
  Run it once after upgrading to 4.12.0 **if any deployment ever ran an `expiresIn` that `ms` could not parse** (`"30dayz"`, `"0d"`, `""`, a bare number). Those configurations minted tokens that verify forever. It is safe to run when unaffected — it reports nothing and deletes nothing.
1792
1839
 
@@ -1863,7 +1910,7 @@ description: 'Brute-force / credential-stuffing protection via `loginThrottleMid
1863
1910
 
1864
1911
  `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.
1865
1912
 
1866
- 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.
1913
+ 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.
1867
1914
 
1868
1915
  ## The shortest version
1869
1916
 
@@ -1879,6 +1926,35 @@ That's the whole feature for most apps. Defaults: **5** failed attempts within a
1879
1926
 
1880
1927
  > **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).
1881
1928
 
1929
+ ## What new apps get by default
1930
+
1931
+ The auth package registers no routes, so throttles apply where you attach them.
1932
+ A 5.31 `create-warlock` app already attaches `loginThrottleMiddleware()` to
1933
+ `/auth/login`, `tokenIssueThrottleMiddleware()` to `/auth/forgot-password`,
1934
+ `tokenConsumeThrottleMiddleware()` to `/auth/reset-password` and a 30/minute
1935
+ route `rateLimit` to `/auth/refresh-token`, all marked `public: true`. Every
1936
+ route also sits under core's global limit (60 requests/minute per IP). Older
1937
+ apps add the middleware to their own `src/app/auth/routes.ts`:
1938
+
1939
+ ```ts
1940
+ import { tokenConsumeThrottleMiddleware, tokenIssueThrottleMiddleware } from "@warlock.js/auth";
1941
+ import { router, type RequestHandler } from "@warlock.js/core";
1942
+
1943
+ const forgotPasswordController: RequestHandler = async ({ response }) =>
1944
+ response.success({ sent: true });
1945
+ const resetPasswordController: RequestHandler = async ({ response }) =>
1946
+ response.success({ reset: true });
1947
+
1948
+ router.post("/auth/forgot-password", forgotPasswordController, {
1949
+ public: true,
1950
+ middleware: [tokenIssueThrottleMiddleware()],
1951
+ });
1952
+ router.post("/auth/reset-password", resetPasswordController, {
1953
+ public: true,
1954
+ middleware: [tokenConsumeThrottleMiddleware()],
1955
+ });
1956
+ ```
1957
+
1882
1958
  ## How it behaves
1883
1959
 
1884
1960
  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`).
@@ -1890,10 +1966,10 @@ Per-account tracking stops a password-spray against one user from many IPs; per-
1890
1966
 
1891
1967
  ```ts
1892
1968
  loginThrottleMiddleware({
1893
- max: 5, // failures allowed in the window before lockout
1894
- window: "15m", // counting window — ms-format string or seconds (number)
1969
+ max: 5, // failures allowed in the window before lockout
1970
+ window: "15m", // counting window — ms-format string or seconds (number)
1895
1971
  lockoutDuration: "15m", // how long the lock lasts once tripped
1896
- by: ["email", "ip"], // identifiers tracked, each independently
1972
+ by: ["email", "ip"], // identifiers tracked, each independently
1897
1973
  identifierKey: "email", // which credential field is the account key
1898
1974
  errorMessage: "Too many attempts. Try again later.",
1899
1975
  });
@@ -1911,16 +1987,16 @@ loginThrottleMiddleware({
1911
1987
  });
1912
1988
  ```
1913
1989
 
1914
- | Option | Default | Purpose |
1915
- | --- | --- | --- |
1916
- | `max` | `5` | Failures within the window before lockout |
1917
- | `window` | `"15m"` | Counting window (`ms`-string or seconds) |
1918
- | `lockoutDuration` | `"15m"` | Lock TTL once tripped |
1919
- | `by` | `["email", "ip"]` | Identifiers tracked independently |
1920
- | `identifierKey` | `"email"` | Credential field used as the account key |
1921
- | `errorMessage` | i18n `auth.errors.tooManyAttempts` | 429 message override |
1922
- | `isFailure` | `(res) => !res.isOk` | What counts as a failed attempt |
1923
- | `identify` | built-in email + ip extraction | Custom identifier list |
1990
+ | Option | Default | Purpose |
1991
+ | ----------------- | ---------------------------------- | ----------------------------------------- |
1992
+ | `max` | `5` | Failures within the window before lockout |
1993
+ | `window` | `"15m"` | Counting window (`ms`-string or seconds) |
1994
+ | `lockoutDuration` | `"15m"` | Lock TTL once tripped |
1995
+ | `by` | `["email", "ip"]` | Identifiers tracked independently |
1996
+ | `identifierKey` | `"email"` | Credential field used as the account key |
1997
+ | `errorMessage` | i18n `auth.errors.tooManyAttempts` | 429 message override |
1998
+ | `isFailure` | `(res) => !res.isOk` | What counts as a failed attempt |
1999
+ | `identify` | built-in email + ip extraction | Custom identifier list |
1924
2000
 
1925
2001
  ## The 429 response
1926
2002
 
@@ -1948,20 +2024,20 @@ router.post("/auth/forgot-password", forgotController, {
1948
2024
 
1949
2025
  ## When to use this vs core's `rateLimit`
1950
2026
 
1951
- | | `loginThrottleMiddleware` (auth) | `middleware.rateLimit` (core) |
1952
- | --- | --- | --- |
1953
- | Counts | only **failed** logins | **every** request |
1954
- | Resets on a successful login | yes | no |
1955
- | Locks per account / per IP | yes | per key (default IP) |
1956
- | Storage | `@warlock.js/cache` (shared across replicas) | in-process map |
2027
+ | | `loginThrottleMiddleware` (auth) | `middleware.rateLimit` (core) |
2028
+ | ---------------------------- | -------------------------------------------- | ----------------------------- |
2029
+ | Counts | only **failed** logins | **every** request |
2030
+ | Resets on a successful login | yes | no |
2031
+ | Locks per account / per IP | yes | per key (default IP) |
2032
+ | Storage | `@warlock.js/cache` (shared across replicas) | in-process map |
1957
2033
 
1958
- 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()]`.
2034
+ 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()]`.
1959
2035
 
1960
2036
  ## Gotchas
1961
2037
 
1962
2038
  - **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.
1963
2039
  - **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.
1964
- - **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.
2040
+ - **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.
1965
2041
  - **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).
1966
2042
  - **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.
1967
2043
  - **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.
@@ -2073,10 +2149,10 @@ router.post("/auth/reset-password", resetPasswordController, {
2073
2149
 
2074
2150
  Both presets wrap `loginThrottleMiddleware` and accept its options:
2075
2151
 
2076
- | Preset | Tracks | Counts | Default |
2077
- | --- | --- | --- | --- |
2078
- | `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 |
2079
- | `tokenConsumeThrottleMiddleware` | IP | failures only, cleared on success | 10 / 15m, 15m lock |
2152
+ | Preset | Tracks | Counts | Default |
2153
+ | -------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------ |
2154
+ | `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 |
2155
+ | `tokenConsumeThrottleMiddleware` | IP | failures only, cleared on success | 10 / 15m, 15m lock |
2080
2156
 
2081
2157
  ## Configuration (`src/config/auth.ts`)
2082
2158
 
@@ -2094,7 +2170,9 @@ const authConfigurations: AuthConfigurations = {
2094
2170
  identifierField: "email",
2095
2171
  url: (token) => `${env("APP_URL")}/reset-password?token=${token}`,
2096
2172
  notification: myResetNotification,
2097
- setPassword: async (user, plain) => { /* optional */ },
2173
+ setPassword: async (user, plain) => {
2174
+ /* optional */
2175
+ },
2098
2176
  },
2099
2177
  oneTimeToken: { model: AppOneTimeToken }, // optional model override
2100
2178
  };