@warlock.js/auth 4.11.0 → 4.13.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.
- package/CHANGELOG.md +76 -0
- package/README.md +5 -1
- package/esm/commands/auth-purge-never-expiring-command.d.mts +29 -0
- package/esm/commands/auth-purge-never-expiring-command.d.mts.map +1 -0
- package/esm/commands/auth-purge-never-expiring-command.mjs +70 -0
- package/esm/commands/auth-purge-never-expiring-command.mjs.map +1 -0
- package/esm/contracts/types.d.mts +8 -2
- package/esm/contracts/types.d.mts.map +1 -1
- package/esm/contracts/types.mjs.map +1 -1
- package/esm/index.d.mts +3 -1
- package/esm/index.mjs +4 -1
- package/esm/middleware/auth.middleware.d.mts.map +1 -1
- package/esm/middleware/auth.middleware.mjs +25 -0
- package/esm/middleware/auth.middleware.mjs.map +1 -1
- package/esm/models/access-token/access-token.model.d.mts +41 -0
- package/esm/models/access-token/access-token.model.d.mts.map +1 -1
- package/esm/models/access-token/access-token.model.mjs +54 -0
- package/esm/models/access-token/access-token.model.mjs.map +1 -1
- package/esm/models/refresh-token/refresh-token.model.d.mts +32 -1
- package/esm/models/refresh-token/refresh-token.model.d.mts.map +1 -1
- package/esm/models/refresh-token/refresh-token.model.mjs +43 -3
- package/esm/models/refresh-token/refresh-token.model.mjs.map +1 -1
- package/esm/services/auth-config.mjs +52 -1
- package/esm/services/auth-config.mjs.map +1 -1
- package/esm/services/auth.service.d.mts +27 -0
- package/esm/services/auth.service.d.mts.map +1 -1
- package/esm/services/auth.service.mjs +35 -4
- package/esm/services/auth.service.mjs.map +1 -1
- package/esm/services/jwt.d.mts +2 -0
- package/esm/services/jwt.d.mts.map +1 -1
- package/esm/services/jwt.mjs +32 -4
- package/esm/services/jwt.mjs.map +1 -1
- package/esm/utils/index.mjs +4 -0
- package/esm/utils/token-expiry.d.mts +35 -0
- package/esm/utils/token-expiry.d.mts.map +1 -0
- package/esm/utils/token-expiry.mjs +59 -0
- package/esm/utils/token-expiry.mjs.map +1 -0
- package/llms-full.txt +63 -5
- package/package.json +7 -7
- package/skills/auth-basics/SKILL.md +15 -0
- package/skills/customize-token-storage/SKILL.md +11 -1
- package/skills/manage-tokens/SKILL.md +9 -1
- package/skills/protect-routes/SKILL.md +7 -1
- 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
|
|
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:
|
|
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,
|
|
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: '
|
|
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
|
|
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.
|
|
27
|
-
"@warlock.js/cache": "4.
|
|
28
|
-
"@warlock.js/cascade": "4.
|
|
29
|
-
"@warlock.js/core": "4.
|
|
30
|
-
"@warlock.js/logger": "4.
|
|
31
|
-
"@warlock.js/seal": "4.
|
|
26
|
+
"@warlock.js/fs": "4.13.0",
|
|
27
|
+
"@warlock.js/cache": "4.13.0",
|
|
28
|
+
"@warlock.js/cascade": "4.13.0",
|
|
29
|
+
"@warlock.js/core": "4.13.0",
|
|
30
|
+
"@warlock.js/logger": "4.13.0",
|
|
31
|
+
"@warlock.js/seal": "4.13.0"
|
|
32
32
|
},
|
|
33
|
-
"version": "4.
|
|
33
|
+
"version": "4.13.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
|
-
|
|
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:
|
|
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,
|
|
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: '
|
|
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
|
|
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:
|