@warlock.js/auth 4.10.0 → 4.12.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 (44) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +5 -1
  3. package/esm/commands/auth-purge-never-expiring-command.d.mts +29 -0
  4. package/esm/commands/auth-purge-never-expiring-command.d.mts.map +1 -0
  5. package/esm/commands/auth-purge-never-expiring-command.mjs +70 -0
  6. package/esm/commands/auth-purge-never-expiring-command.mjs.map +1 -0
  7. package/esm/contracts/types.d.mts +8 -2
  8. package/esm/contracts/types.d.mts.map +1 -1
  9. package/esm/contracts/types.mjs.map +1 -1
  10. package/esm/index.d.mts +3 -1
  11. package/esm/index.mjs +4 -1
  12. package/esm/middleware/auth.middleware.d.mts.map +1 -1
  13. package/esm/middleware/auth.middleware.mjs +25 -0
  14. package/esm/middleware/auth.middleware.mjs.map +1 -1
  15. package/esm/models/access-token/access-token.model.d.mts +41 -0
  16. package/esm/models/access-token/access-token.model.d.mts.map +1 -1
  17. package/esm/models/access-token/access-token.model.mjs +54 -0
  18. package/esm/models/access-token/access-token.model.mjs.map +1 -1
  19. package/esm/models/refresh-token/refresh-token.model.d.mts +32 -1
  20. package/esm/models/refresh-token/refresh-token.model.d.mts.map +1 -1
  21. package/esm/models/refresh-token/refresh-token.model.mjs +43 -3
  22. package/esm/models/refresh-token/refresh-token.model.mjs.map +1 -1
  23. package/esm/services/auth-config.mjs +52 -1
  24. package/esm/services/auth-config.mjs.map +1 -1
  25. package/esm/services/auth.service.d.mts +27 -0
  26. package/esm/services/auth.service.d.mts.map +1 -1
  27. package/esm/services/auth.service.mjs +35 -4
  28. package/esm/services/auth.service.mjs.map +1 -1
  29. package/esm/services/jwt.d.mts +2 -0
  30. package/esm/services/jwt.d.mts.map +1 -1
  31. package/esm/services/jwt.mjs +32 -4
  32. package/esm/services/jwt.mjs.map +1 -1
  33. package/esm/utils/index.mjs +4 -0
  34. package/esm/utils/token-expiry.d.mts +35 -0
  35. package/esm/utils/token-expiry.d.mts.map +1 -0
  36. package/esm/utils/token-expiry.mjs +59 -0
  37. package/esm/utils/token-expiry.mjs.map +1 -0
  38. package/llms-full.txt +63 -5
  39. package/package.json +7 -7
  40. package/skills/auth-basics/SKILL.md +15 -0
  41. package/skills/customize-token-storage/SKILL.md +11 -1
  42. package/skills/manage-tokens/SKILL.md +9 -1
  43. package/skills/protect-routes/SKILL.md +7 -1
  44. package/skills/run-auth-commands/SKILL.md +21 -2
@@ -0,0 +1,59 @@
1
+ import { createDecoder } from "fast-jwt";
2
+
3
+ //#region ../auth/src/utils/token-expiry.ts
4
+ /**
5
+ * Payload decoder. Deliberately signature-*unaware*: these helpers answer
6
+ * "does this token carry a deadline at all", which is a property of the claims,
7
+ * not of the signature. Every caller that acts on the answer (the middleware,
8
+ * the purge command) has already verified the signature or is about to delete
9
+ * the row anyway.
10
+ */
11
+ const decodePayload = createDecoder();
12
+ /**
13
+ * Whether a persisted `expires_at` can serve as a deadline at all.
14
+ *
15
+ * Answers `false` for absent, empty, and — the case that matters — `Invalid
16
+ * Date`, which is what a pre-4.12.0 unparseable `expiresIn` wrote to the token
17
+ * row. An `Invalid Date` compares `false` against *every* other date, so a row
18
+ * holding one satisfies neither `expires_at < now` (cleanup never purges it)
19
+ * nor `expires_at > now` (it never shows as an active session): it is outside
20
+ * the reach of every date predicate rather than merely wrong.
21
+ */
22
+ function isUsableExpiry(value) {
23
+ if (value === void 0 || value === null || value === "") return false;
24
+ return !Number.isNaN(new Date(value).getTime());
25
+ }
26
+ /**
27
+ * Whether the JWT carries a finite numeric `exp` claim.
28
+ *
29
+ * This is the store-independent signal for a never-expiring credential: a
30
+ * token with no `exp` has nothing for a verifier to check, so it verifies
31
+ * indefinitely no matter what the row beside it says. An undecodable string
32
+ * answers `false` — it cannot be shown to expire, and a row whose token cannot
33
+ * even be parsed is unusable regardless.
34
+ */
35
+ function tokenHasExpClaim(token) {
36
+ if (typeof token !== "string" || token === "") return false;
37
+ try {
38
+ const payload = decodePayload(token);
39
+ return typeof payload?.exp === "number" && Number.isFinite(payload.exp);
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+ /**
45
+ * Whether a token row can ever stop being accepted on its own.
46
+ *
47
+ * Two independent ways to answer "no", either of which is enough:
48
+ * - the persisted expiry is unusable, so no date check can ever retire the row;
49
+ * - the token itself carries no `exp`, so signature verification never retires it.
50
+ *
51
+ * This is the predicate the `auth.purge-never-expiring` remediation selects on.
52
+ */
53
+ function isNeverExpiring(token, expiresAt) {
54
+ return !isUsableExpiry(expiresAt) || !tokenHasExpClaim(token);
55
+ }
56
+
57
+ //#endregion
58
+ export { isNeverExpiring, isUsableExpiry, tokenHasExpClaim };
59
+ //# sourceMappingURL=token-expiry.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"token-expiry.mjs","names":[],"sources":["../../../../../../../auth/src/utils/token-expiry.ts"],"sourcesContent":["import { createDecoder } from \"fast-jwt\";\n\n/**\n * Payload decoder. Deliberately signature-*unaware*: these helpers answer\n * \"does this token carry a deadline at all\", which is a property of the claims,\n * not of the signature. Every caller that acts on the answer (the middleware,\n * the purge command) has already verified the signature or is about to delete\n * the row anyway.\n */\nconst decodePayload = createDecoder();\n\n/**\n * Whether a persisted `expires_at` can serve as a deadline at all.\n *\n * Answers `false` for absent, empty, and — the case that matters — `Invalid\n * Date`, which is what a pre-4.12.0 unparseable `expiresIn` wrote to the token\n * row. An `Invalid Date` compares `false` against *every* other date, so a row\n * holding one satisfies neither `expires_at < now` (cleanup never purges it)\n * nor `expires_at > now` (it never shows as an active session): it is outside\n * the reach of every date predicate rather than merely wrong.\n */\nexport function isUsableExpiry(value: unknown): boolean {\n if (value === undefined || value === null || value === \"\") return false;\n\n return !Number.isNaN(new Date(value as string | number | Date).getTime());\n}\n\n/**\n * Whether the JWT carries a finite numeric `exp` claim.\n *\n * This is the store-independent signal for a never-expiring credential: a\n * token with no `exp` has nothing for a verifier to check, so it verifies\n * indefinitely no matter what the row beside it says. An undecodable string\n * answers `false` — it cannot be shown to expire, and a row whose token cannot\n * even be parsed is unusable regardless.\n */\nexport function tokenHasExpClaim(token: unknown): boolean {\n if (typeof token !== \"string\" || token === \"\") return false;\n\n try {\n const payload = decodePayload(token) as { exp?: unknown } | null;\n\n return typeof payload?.exp === \"number\" && Number.isFinite(payload.exp);\n } catch {\n return false;\n }\n}\n\n/**\n * Whether a token row can ever stop being accepted on its own.\n *\n * Two independent ways to answer \"no\", either of which is enough:\n * - the persisted expiry is unusable, so no date check can ever retire the row;\n * - the token itself carries no `exp`, so signature verification never retires it.\n *\n * This is the predicate the `auth.purge-never-expiring` remediation selects on.\n */\nexport function isNeverExpiring(token: unknown, expiresAt: unknown): boolean {\n return !isUsableExpiry(expiresAt) || !tokenHasExpClaim(token);\n}\n"],"mappings":";;;;;;;;;;AASA,MAAM,gBAAgB,cAAc;;;;;;;;;;;AAYpC,SAAgB,eAAe,OAAyB;CACtD,IAAI,UAAU,UAAa,UAAU,QAAQ,UAAU,IAAI,OAAO;CAElE,OAAO,CAAC,OAAO,MAAM,IAAI,KAAK,KAA+B,CAAC,CAAC,QAAQ,CAAC;AAC1E;;;;;;;;;;AAWA,SAAgB,iBAAiB,OAAyB;CACxD,IAAI,OAAO,UAAU,YAAY,UAAU,IAAI,OAAO;CAEtD,IAAI;EACF,MAAM,UAAU,cAAc,KAAK;EAEnC,OAAO,OAAO,SAAS,QAAQ,YAAY,OAAO,SAAS,QAAQ,GAAG;CACxE,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;;;;AAWA,SAAgB,gBAAgB,OAAgB,WAA6B;CAC3E,OAAO,CAAC,eAAe,SAAS,KAAK,CAAC,iBAAiB,KAAK;AAC9D"}
package/llms-full.txt CHANGED
@@ -78,6 +78,21 @@ export default {
78
78
  };
79
79
  ```
80
80
 
81
+ ### Token lifetimes are validated — a bad one throws
82
+
83
+ `expiresIn` (both blocks) must be a duration string the [`ms`](https://github.com/vercel/ms) package parses to a **positive** number: `"1h"`, `"30m"`, `"7d"`, `"30 days"`, or `NO_EXPIRATION` (`"100y"`). Anything else throws on the first token issue, naming the key:
84
+
85
+ ```
86
+ auth.accessToken.expiresIn: "30dayz" is not a valid ms duration — use a positive duration string such as "1h", "7d", or NO_EXPIRATION.
87
+ ```
88
+
89
+ That is deliberate. `ms` answers `undefined` for a string it can't parse (`"30dayz"`, `"thirty days"`) and `0` for `"0d"`, and a signer given either emits a JWT with **no `exp` claim** — a credential that never expires. A typo must not be able to produce that quietly, so it fails loudly instead of falling back to a default nobody asked for. Defaults still apply when the key is **absent**: `1h` for access, `7d` for refresh.
90
+
91
+ Two values that look right and are not:
92
+
93
+ - `expiresIn: 2592000` — a bare number. `ms` *formats* numbers instead of parsing them, so this is rejected; write `"30d"`.
94
+ - `expiresIn: env("JWT_TTL")` where the variable is unset — resolves to `""` and is rejected. Give the env read a default: `env("JWT_TTL") || "1h"`.
95
+
81
96
  ## Pick a skill
82
97
 
83
98
  | If the task is about… | Load |
@@ -196,8 +211,18 @@ Your subclass inherits and may override these statics — the service calls them
196
211
  | `revokeAllFor(user)` / `revokeFamily(id)` | revoke a set, returning the revoked rows |
197
212
  | `enforceMax(user, max)` | cap concurrent refresh tokens |
198
213
  | `purgeExpired()` | delete expired rows (CLI cleanup) |
214
+ | `findNeverExpiring()` / `purgeNeverExpiring()` | find/delete rows that can never expire (CLI remediation) |
215
+
216
+ Plus two instance getters, both of which the route gate consults on every request:
217
+
218
+ | Getter | Role |
219
+ | --- | --- |
220
+ | `isExpired` | whether the row's persisted expiry has passed — **the middleware rejects the request when this is `true`** |
221
+ | `neverExpires` | whether nothing can ever retire the row (unusable `expires_at`, or a token with no `exp` claim) |
199
222
 
200
- 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.
223
+ 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.
224
+
225
+ ⚠️ **Do not drop `isExpired`.** The middleware treats a row that cannot answer as **expired** and rejects the request, so removing the getter locks every user out rather than letting them through. That direction is deliberate: the fail-open reading is what let expired sessions stay usable before 4.12.0.
201
226
 
202
227
  ## Things NOT to do
203
228
 
@@ -673,13 +698,21 @@ For low-level JWT signing/verification (outside the authService flow):
673
698
  ```ts
674
699
  import { jwt } from "@warlock.js/auth";
675
700
 
676
- const token = await jwt.generate(payload, { expiresIn: "1h" }); // string or ms; a bare number is milliseconds
701
+ const token = await jwt.generate(payload, { expiresIn: 3_600_000 }); // milliseconds
677
702
  const decoded = await jwt.verify(token);
678
703
 
679
704
  const refreshToken = await jwt.generateRefreshToken(payload, { expiresIn });
680
705
  const decodedRefresh = await jwt.verifyRefreshToken(refreshToken);
681
706
  ```
682
707
 
708
+ ⚠️ **`jwt.generate` / `jwt.generateRefreshToken` are the low-level escape hatch — `expiresIn` goes straight to `fast-jwt`.** It takes a number of milliseconds or an `ms`-style string, and rejects an unparseable string. What it does **not** reject is `undefined` or `0`: both skip the check and emit a token with **no `exp` claim**. Pass a positive value, and never pass the result of parsing a config string without checking it first.
709
+
710
+ **`jwt.verify` / `jwt.verifyRefreshToken` require an `exp` claim** (since 4.12.0) and reject a token without one. There is no deadline to check on such a token, so verification would otherwise succeed indefinitely. `requiredClaims` is additive — `jwt.verify(token, { requiredClaims: ["iat"] })` requires `iat` **and** `exp`; you cannot opt out of `exp`. If you want a token that effectively never expires, use `expiresIn: NO_EXPIRATION` (`"100y"`), which stamps a real `exp` about a century out.
711
+
712
+ If tokens minted before 4.12.0 may lack an `exp`, see `warlock auth.purge-never-expiring` in [`@warlock.js/auth/run-auth-commands/SKILL.md`](@warlock.js/auth/run-auth-commands/SKILL.md) — they are still in your token tables and no date-based cleanup can reach them.
713
+
714
+ `authService.generateAccessToken` / `createRefreshToken` read `config.auth.accessToken.expiresIn` / `config.auth.refreshToken.expiresIn`, validate them, and throw naming the key if the value is not a positive `ms` duration — before anything is signed, persisted, or capped.
715
+
683
716
  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.
684
717
 
685
718
  ## Things NOT to do
@@ -820,12 +853,18 @@ request.decodedAccessToken = <decoded JWT payload>;
820
853
 
821
854
  The user is loaded via `Model.find(decodedToken.id)` against the `config.auth.userType[userType]` class. If the user no longer exists (deleted), the access token row is destroyed and the request gets 401.
822
855
 
856
+ A token passes three separate checks, in order — a token can fail any one of them while satisfying the other two:
857
+
858
+ 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.
859
+ 2. **The access-token row still exists** — deleting it (logout) invalidates the token immediately, before its JWT expiry.
860
+ 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.
861
+
823
862
  On failure, the middleware returns one of these 401 responses:
824
863
 
825
864
  | Error code | When |
826
865
  | --- | --- |
827
866
  | `MissingAccessToken` | No `Authorization` header |
828
- | `InvalidAccessToken` | Token doesn't verify (signature, expired, doesn't match DB) |
867
+ | `InvalidAccessToken` | Token doesn't verify (signature, missing/passed `exp`, wrong token type), has no DB row, or the row's `expires_at` has passed |
829
868
  | `Unauthorized` | Token valid but user-type isn't in the allowed list |
830
869
 
831
870
  ## Reading the user in a controller
@@ -1062,18 +1101,19 @@ Cleaner alternative: emit your own `user.registered` event from the controller a
1062
1101
 
1063
1102
  ---
1064
1103
  name: run-auth-commands
1065
- description: 'Two bundled CLI commands — warlock jwt.generate (creates strong JWT secret + writes to .env) and warlock auth.cleanup (removes expired refresh tokens). Register via registerJWTSecretGeneratorCommand() and registerAuthCleanupCommand(). Triggers: `registerJWTSecretGeneratorCommand`, `registerAuthCleanupCommand`, `warlock jwt.generate`, `warlock auth.cleanup`, `cleanupExpiredTokens`, `command`; "generate JWT secret", "bootstrap .env JWT_SECRET", "cron job for expired tokens", "schedule auth cleanup"; typical import `import { registerJWTSecretGeneratorCommand, registerAuthCleanupCommand } from "@warlock.js/auth"`. Skip: programmatic cleanup — `@warlock.js/auth/manage-tokens/SKILL.md`; in-process scheduling — `@warlock.js/scheduler/scheduler-basics/SKILL.md`; competing tools `dotenv-cli`, `node-cron`.'
1104
+ description: 'Three bundled CLI commands — warlock jwt.generate (creates strong JWT secret + writes to .env), warlock auth.cleanup (removes expired refresh tokens), and warlock auth.purge-never-expiring (finds and revokes token rows that can never expire). Register via registerJWTSecretGeneratorCommand(), registerAuthCleanupCommand() and registerAuthPurgeNeverExpiringCommand(). Triggers: `registerJWTSecretGeneratorCommand`, `registerAuthCleanupCommand`, `registerAuthPurgeNeverExpiringCommand`, `warlock jwt.generate`, `warlock auth.cleanup`, `warlock auth.purge-never-expiring`, `cleanupExpiredTokens`, `purgeNeverExpiringTokens`, `command`; "token never expires", "no exp claim", "Invalid Date expires_at"; "generate JWT secret", "bootstrap .env JWT_SECRET", "cron job for expired tokens", "schedule auth cleanup"; typical import `import { registerJWTSecretGeneratorCommand, registerAuthCleanupCommand, registerAuthPurgeNeverExpiringCommand } from "@warlock.js/auth"`. Skip: programmatic cleanup — `@warlock.js/auth/manage-tokens/SKILL.md`; in-process scheduling — `@warlock.js/scheduler/scheduler-basics/SKILL.md`; competing tools `dotenv-cli`, `node-cron`.'
1066
1105
  ---
1067
1106
 
1068
1107
  # Run auth commands
1069
1108
 
1070
- The package ships two CLI commands. Register them in `warlock.config.ts`; the framework picks them up.
1109
+ The package ships three CLI commands. Register them in `warlock.config.ts`; the framework picks them up.
1071
1110
 
1072
1111
  ## Register
1073
1112
 
1074
1113
  ```ts title="warlock.config.ts"
1075
1114
  import {
1076
1115
  registerAuthCleanupCommand,
1116
+ registerAuthPurgeNeverExpiringCommand,
1077
1117
  registerJWTSecretGeneratorCommand,
1078
1118
  } from "@warlock.js/auth";
1079
1119
  import { defineConfig } from "@warlock.js/core";
@@ -1083,6 +1123,7 @@ export default defineConfig({
1083
1123
  commands: [
1084
1124
  registerJWTSecretGeneratorCommand(),
1085
1125
  registerAuthCleanupCommand(),
1126
+ registerAuthPurgeNeverExpiringCommand(),
1086
1127
  ],
1087
1128
  },
1088
1129
  });
@@ -1142,6 +1183,23 @@ Once a day is usually enough. The check is cheap (single indexed DELETE on `expi
1142
1183
 
1143
1184
  If you have very-short-lived refresh tokens (1h expiry) and a million-user scale where the table grows fast, cleanup more often (hourly).
1144
1185
 
1186
+ ## `warlock auth.purge-never-expiring` — one-off remediation
1187
+
1188
+ ```bash
1189
+ yarn warlock auth.purge-never-expiring --dry-run # report only
1190
+ yarn warlock auth.purge-never-expiring # report, then revoke
1191
+ ```
1192
+
1193
+ Register with `registerAuthPurgeNeverExpiringCommand()`.
1194
+
1195
+ 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.
1196
+
1197
+ **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.
1198
+
1199
+ 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.
1200
+
1201
+ Unlike `auth.cleanup`, do **not** schedule it: it is a full table scan (it must read rows to judge them, since neither signal is expressible as a `where`), and once the poisoned rows are gone, 4.12.0 cannot create more.
1202
+
1145
1203
  ## Custom commands
1146
1204
 
1147
1205
  If `auth.cleanup` doesn't cover everything your app needs (e.g. you also want to revoke tokens for inactive users), write your own command and combine the auth service helpers:
package/package.json CHANGED
@@ -23,14 +23,14 @@
23
23
  "ms": "^2.1.3"
24
24
  },
25
25
  "peerDependencies": {
26
- "@warlock.js/fs": "4.10.0",
27
- "@warlock.js/cache": "4.10.0",
28
- "@warlock.js/cascade": "4.10.0",
29
- "@warlock.js/core": "4.10.0",
30
- "@warlock.js/logger": "4.10.0",
31
- "@warlock.js/seal": "4.10.0"
26
+ "@warlock.js/fs": "4.12.0",
27
+ "@warlock.js/cache": "4.12.0",
28
+ "@warlock.js/cascade": "4.12.0",
29
+ "@warlock.js/core": "4.12.0",
30
+ "@warlock.js/logger": "4.12.0",
31
+ "@warlock.js/seal": "4.12.0"
32
32
  },
33
- "version": "4.10.0",
33
+ "version": "4.12.0",
34
34
  "type": "module",
35
35
  "main": "./esm/index.mjs",
36
36
  "module": "./esm/index.mjs",
@@ -70,6 +70,21 @@ export default {
70
70
  };
71
71
  ```
72
72
 
73
+ ### Token lifetimes are validated — a bad one throws
74
+
75
+ `expiresIn` (both blocks) must be a duration string the [`ms`](https://github.com/vercel/ms) package parses to a **positive** number: `"1h"`, `"30m"`, `"7d"`, `"30 days"`, or `NO_EXPIRATION` (`"100y"`). Anything else throws on the first token issue, naming the key:
76
+
77
+ ```
78
+ auth.accessToken.expiresIn: "30dayz" is not a valid ms duration — use a positive duration string such as "1h", "7d", or NO_EXPIRATION.
79
+ ```
80
+
81
+ That is deliberate. `ms` answers `undefined` for a string it can't parse (`"30dayz"`, `"thirty days"`) and `0` for `"0d"`, and a signer given either emits a JWT with **no `exp` claim** — a credential that never expires. A typo must not be able to produce that quietly, so it fails loudly instead of falling back to a default nobody asked for. Defaults still apply when the key is **absent**: `1h` for access, `7d` for refresh.
82
+
83
+ Two values that look right and are not:
84
+
85
+ - `expiresIn: 2592000` — a bare number. `ms` *formats* numbers instead of parsing them, so this is rejected; write `"30d"`.
86
+ - `expiresIn: env("JWT_TTL")` where the variable is unset — resolves to `""` and is rejected. Give the env read a default: `env("JWT_TTL") || "1h"`.
87
+
73
88
  ## Pick a skill
74
89
 
75
90
  | If the task is about… | Load |
@@ -95,8 +95,18 @@ Your subclass inherits and may override these statics — the service calls them
95
95
  | `revokeAllFor(user)` / `revokeFamily(id)` | revoke a set, returning the revoked rows |
96
96
  | `enforceMax(user, max)` | cap concurrent refresh tokens |
97
97
  | `purgeExpired()` | delete expired rows (CLI cleanup) |
98
+ | `findNeverExpiring()` / `purgeNeverExpiring()` | find/delete rows that can never expire (CLI remediation) |
98
99
 
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
+ Plus two instance getters, both of which the route gate consults on every request:
101
+
102
+ | Getter | Role |
103
+ | --- | --- |
104
+ | `isExpired` | whether the row's persisted expiry has passed — **the middleware rejects the request when this is `true`** |
105
+ | `neverExpires` | whether nothing can ever retire the row (unusable `expires_at`, or a token with no `exp` claim) |
106
+
107
+ 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.
108
+
109
+ ⚠️ **Do not drop `isExpired`.** The middleware treats a row that cannot answer as **expired** and rejects the request, so removing the getter locks every user out rather than letting them through. That direction is deliberate: the fail-open reading is what let expired sessions stay usable before 4.12.0.
100
110
 
101
111
  ## Things NOT to do
102
112
 
@@ -146,13 +146,21 @@ 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: "1h" }); // string or ms; a bare number is milliseconds
149
+ const token = await jwt.generate(payload, { expiresIn: 3_600_000 }); // 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
+ ⚠️ **`jwt.generate` / `jwt.generateRefreshToken` are the low-level escape hatch — `expiresIn` goes straight to `fast-jwt`.** It takes a number of milliseconds or an `ms`-style string, and rejects an unparseable string. What it does **not** reject is `undefined` or `0`: both skip the check and emit a token with **no `exp` claim**. Pass a positive value, and never pass the result of parsing a config string without checking it first.
157
+
158
+ **`jwt.verify` / `jwt.verifyRefreshToken` require an `exp` claim** (since 4.12.0) and reject a token without one. There is no deadline to check on such a token, so verification would otherwise succeed indefinitely. `requiredClaims` is additive — `jwt.verify(token, { requiredClaims: ["iat"] })` requires `iat` **and** `exp`; you cannot opt out of `exp`. If you want a token that effectively never expires, use `expiresIn: NO_EXPIRATION` (`"100y"`), which stamps a real `exp` about a century out.
159
+
160
+ If tokens minted before 4.12.0 may lack an `exp`, see `warlock auth.purge-never-expiring` in [`@warlock.js/auth/run-auth-commands/SKILL.md`](@warlock.js/auth/run-auth-commands/SKILL.md) — they are still in your token tables and no date-based cleanup can reach them.
161
+
162
+ `authService.generateAccessToken` / `createRefreshToken` read `config.auth.accessToken.expiresIn` / `config.auth.refreshToken.expiresIn`, validate them, and throw naming the key if the value is not a positive `ms` duration — before anything is signed, persisted, or capped.
163
+
156
164
  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
165
 
158
166
  ## Things NOT to do
@@ -44,12 +44,18 @@ request.decodedAccessToken = <decoded JWT payload>;
44
44
 
45
45
  The user is loaded via `Model.find(decodedToken.id)` against the `config.auth.userType[userType]` class. If the user no longer exists (deleted), the access token row is destroyed and the request gets 401.
46
46
 
47
+ A token passes three separate checks, in order — a token can fail any one of them while satisfying the other two:
48
+
49
+ 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.
50
+ 2. **The access-token row still exists** — deleting it (logout) invalidates the token immediately, before its JWT expiry.
51
+ 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.
52
+
47
53
  On failure, the middleware returns one of these 401 responses:
48
54
 
49
55
  | Error code | When |
50
56
  | --- | --- |
51
57
  | `MissingAccessToken` | No `Authorization` header |
52
- | `InvalidAccessToken` | Token doesn't verify (signature, expired, doesn't match DB) |
58
+ | `InvalidAccessToken` | Token doesn't verify (signature, missing/passed `exp`, wrong token type), has no DB row, or the row's `expires_at` has passed |
53
59
  | `Unauthorized` | Token valid but user-type isn't in the allowed list |
54
60
 
55
61
  ## Reading the user in a controller
@@ -1,17 +1,18 @@
1
1
  ---
2
2
  name: run-auth-commands
3
- description: 'Two bundled CLI commands — warlock jwt.generate (creates strong JWT secret + writes to .env) and warlock auth.cleanup (removes expired refresh tokens). Register via registerJWTSecretGeneratorCommand() and registerAuthCleanupCommand(). Triggers: `registerJWTSecretGeneratorCommand`, `registerAuthCleanupCommand`, `warlock jwt.generate`, `warlock auth.cleanup`, `cleanupExpiredTokens`, `command`; "generate JWT secret", "bootstrap .env JWT_SECRET", "cron job for expired tokens", "schedule auth cleanup"; typical import `import { registerJWTSecretGeneratorCommand, registerAuthCleanupCommand } from "@warlock.js/auth"`. Skip: programmatic cleanup — `@warlock.js/auth/manage-tokens/SKILL.md`; in-process scheduling — `@warlock.js/scheduler/scheduler-basics/SKILL.md`; competing tools `dotenv-cli`, `node-cron`.'
3
+ description: 'Three bundled CLI commands — warlock jwt.generate (creates strong JWT secret + writes to .env), warlock auth.cleanup (removes expired refresh tokens), and warlock auth.purge-never-expiring (finds and revokes token rows that can never expire). Register via registerJWTSecretGeneratorCommand(), registerAuthCleanupCommand() and registerAuthPurgeNeverExpiringCommand(). Triggers: `registerJWTSecretGeneratorCommand`, `registerAuthCleanupCommand`, `registerAuthPurgeNeverExpiringCommand`, `warlock jwt.generate`, `warlock auth.cleanup`, `warlock auth.purge-never-expiring`, `cleanupExpiredTokens`, `purgeNeverExpiringTokens`, `command`; "token never expires", "no exp claim", "Invalid Date expires_at"; "generate JWT secret", "bootstrap .env JWT_SECRET", "cron job for expired tokens", "schedule auth cleanup"; typical import `import { registerJWTSecretGeneratorCommand, registerAuthCleanupCommand, registerAuthPurgeNeverExpiringCommand } from "@warlock.js/auth"`. Skip: programmatic cleanup — `@warlock.js/auth/manage-tokens/SKILL.md`; in-process scheduling — `@warlock.js/scheduler/scheduler-basics/SKILL.md`; competing tools `dotenv-cli`, `node-cron`.'
4
4
  ---
5
5
 
6
6
  # Run auth commands
7
7
 
8
- The package ships two CLI commands. Register them in `warlock.config.ts`; the framework picks them up.
8
+ The package ships three CLI commands. Register them in `warlock.config.ts`; the framework picks them up.
9
9
 
10
10
  ## Register
11
11
 
12
12
  ```ts title="warlock.config.ts"
13
13
  import {
14
14
  registerAuthCleanupCommand,
15
+ registerAuthPurgeNeverExpiringCommand,
15
16
  registerJWTSecretGeneratorCommand,
16
17
  } from "@warlock.js/auth";
17
18
  import { defineConfig } from "@warlock.js/core";
@@ -21,6 +22,7 @@ export default defineConfig({
21
22
  commands: [
22
23
  registerJWTSecretGeneratorCommand(),
23
24
  registerAuthCleanupCommand(),
25
+ registerAuthPurgeNeverExpiringCommand(),
24
26
  ],
25
27
  },
26
28
  });
@@ -80,6 +82,23 @@ Once a day is usually enough. The check is cheap (single indexed DELETE on `expi
80
82
 
81
83
  If you have very-short-lived refresh tokens (1h expiry) and a million-user scale where the table grows fast, cleanup more often (hourly).
82
84
 
85
+ ## `warlock auth.purge-never-expiring` — one-off remediation
86
+
87
+ ```bash
88
+ yarn warlock auth.purge-never-expiring --dry-run # report only
89
+ yarn warlock auth.purge-never-expiring # report, then revoke
90
+ ```
91
+
92
+ Register with `registerAuthPurgeNeverExpiringCommand()`.
93
+
94
+ 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.
95
+
96
+ **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.
97
+
98
+ 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.
99
+
100
+ Unlike `auth.cleanup`, do **not** schedule it: it is a full table scan (it must read rows to judge them, since neither signal is expressible as a `where`), and once the poisoned rows are gone, 4.12.0 cannot create more.
101
+
83
102
  ## Custom commands
84
103
 
85
104
  If `auth.cleanup` doesn't cover everything your app needs (e.g. you also want to revoke tokens for inactive users), write your own command and combine the auth service helpers: