@warlock.js/auth 4.11.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
package/CHANGELOG.md CHANGED
@@ -4,6 +4,82 @@ All notable changes to `@warlock.js/auth` are documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `@warlock.js/*` packages are released in lockstep — every package shares the same version number, so a version below may list only the changes that affected this package.
6
6
 
7
+ ## 4.12.0
8
+
9
+ ### Security
10
+
11
+ > **If you ran 4.11.0 or earlier with an invalid `accessToken.expiresIn` / `refreshToken.expiresIn`, tokens issued in that window may never expire and are still live.** Upgrading stops new ones being minted and stops old ones being accepted — but the rows are still in your database. Run `warlock auth.purge-never-expiring --dry-run` to see them; see **Remediation** below.
12
+
13
+ - **A token with no `exp` claim is now rejected instead of being accepted forever.** `fast-jwt` has no deadline to check on such a token, so verification simply succeeds — measured against `fast-jwt@6.2.4`, a token with no `exp` verifies unchanged at `clockTimestamp` + 100 years. Both `jwt.verify` and `jwt.verifyRefreshToken` now require an `exp` claim.
14
+
15
+ This is deliberately on the *verify* side rather than the issue side: the tokens that lack an `exp` were minted by a version that no longer runs, so a guard on issue would not reach a single one of them. A caller may add to `requiredClaims`, never subtract — `jwt.verify(token, { requiredClaims: ["iat"] })` still requires `exp` too.
16
+
17
+ - **The persisted `expires_at` is now enforced on every request.** `authMiddleware` previously checked only that the access-token row *existed*; a row whose own expiry had passed still opened the gate, because nothing ever asked. The row is now checked against the clock and deleted on rejection.
18
+
19
+ These are two independent defences. The first catches a token whose *claims* carry no deadline; the second catches a token whose *row* says the deadline has passed (a logged-out or expired session whose JWT is still within its own lifetime). Neither subsumes the other.
20
+
21
+ ### Added
22
+
23
+ - **`warlock auth.purge-never-expiring`** — remediation for rows written by the `expiresIn` defect below. Finds every access- and refresh-token row that can never retire itself, on two independent signals: an `expires_at` that is missing or unparseable, and a persisted token carrying no `exp` claim. Reports `id`, `user_id`, `user_type` and `expires_at` per row (never the token string — it is a live credential until the command removes it), then deletes them. Pass `--dry-run` to report without deleting.
24
+
25
+ `auth.cleanup` cannot find these rows and never could: its predicate is `expires_at < now`, and an `Invalid Date` compares `false` against *every* date, so such a row satisfies neither `< now` nor `> now`. It is outside the reach of every date predicate rather than merely wrong. The "no `exp` claim" signal is not in a column at all.
26
+
27
+ Exposed programmatically as `authService.findNeverExpiringTokens()` (read-only) and `authService.purgeNeverExpiringTokens()`, and per model as `findNeverExpiring()` / `purgeNeverExpiring()`.
28
+
29
+ ### Remediation — what to run if you were affected
30
+
31
+ **You were affected if** any deployment ran 4.11.0 or earlier with an `accessToken.expiresIn` / `refreshToken.expiresIn` that `ms` could not parse to a positive number (`"30dayz"`, `"thirty days"`, `"0d"`, `""`, a bare number such as `2592000`). The valid values — `"1h"`, `"7d"`, `"30 days"`, `NO_EXPIRATION`, and the `1h` / `7d` defaults — were never affected.
32
+
33
+ After upgrading:
34
+
35
+ ```bash
36
+ warlock auth.purge-never-expiring --dry-run # look first
37
+ warlock auth.purge-never-expiring # then revoke
38
+ ```
39
+
40
+ Affected users must log in again. To check **without deploying anything**, the row-level shape is visible directly — note that what the bad value lands as depends on the store, and both were measured:
41
+
42
+ - **MongoDB** — `bson` serialises an `Invalid Date` to **epoch 0**, so the row reads `1970-01-01T00:00:00Z` rather than an invalid value:
43
+
44
+ ```js
45
+ db.access_tokens.find({ expires_at: { $lte: new Date(0) } })
46
+ db.refresh_tokens.find({ expires_at: { $lte: new Date(0) } })
47
+ ```
48
+
49
+ - **PostgreSQL** — `pg` serialises an `Invalid Date` to the literal `0NaN-NaN-NaNTNaN:NaN:NaN.NaN+NaN:NaN`, which a `timestamp` column rejects, so the `INSERT` most likely failed and no row was written (the token was still signed and returned to the client — it is then rejected by the row check, since it has no row). Any rows that did land are visible as:
50
+
51
+ ```sql
52
+ SELECT id, user_id, user_type, expires_at FROM access_tokens WHERE expires_at IS NULL OR expires_at <= 'epoch';
53
+ SELECT id, user_id, user_type, expires_at FROM refresh_tokens WHERE expires_at IS NULL OR expires_at <= 'epoch';
54
+ ```
55
+
56
+ These queries find the row-level shape only. The definitive test — *does the stored token carry an `exp` claim at all* — reads the JWT rather than a column, which is why the command exists and why it is the recommended path.
57
+
58
+ ### Fixed
59
+
60
+ - **An `expiresIn` the `ms` package cannot parse no longer mints a credential that never expires.** `accessToken.expiresIn: "30dayz"` (or `"thirty days"`, or any truthy-but-unparseable value) made `ms()` return `undefined`, which the signer emitted as a JWT with **no `exp` claim**, alongside a token row whose `expires_at` was `Invalid Date`. The old guard tested the raw config string for truthiness, so the `1h` fallback was unreachable in exactly the case it existed for. `refreshToken.expiresIn` had no fallback at all.
61
+
62
+ Both lifetimes are now validated and throw naming the key — before anything is signed, persisted, or capped:
63
+
64
+ ```
65
+ auth.accessToken.expiresIn: "30dayz" is not a valid ms duration — use a positive duration string such as "1h", "7d", or NO_EXPIRATION.
66
+ ```
67
+
68
+ A silent substitution of the default was deliberately not chosen: it trades one lifetime nobody chose for another, just as quietly.
69
+
70
+ - **`expiresIn: "0d"` (and any non-positive duration) is rejected too.** It is truthy and parses cleanly to `0`, so it survived any guard that only rejects `undefined` — and `fast-jwt` skips its own validation for `0`, emitting a token with **no `exp` claim** while the persisted row claims it expired immediately.
71
+
72
+ - A bare number (`expiresIn: 2592000`) is now rejected instead of silently corrupting the expiry. `ms` *formats* numbers rather than parsing them (`2592000` ⇒ `"43m"`), which then poisoned `Date.now() + expiresIn` into `Invalid Date`. Write `"30d"`.
73
+
74
+ ### Changed
75
+
76
+ - **Potentially breaking:** a JWT with no `exp` claim is rejected by `jwt.verify` / `jwt.verifyRefreshToken`. No supported configuration produces one: an app that wants a token that effectively never expires sets `expiresIn: NO_EXPIRATION` (`"100y"`), which mints a **real** `exp` about a century out (`ms("100y")` ⇒ `3155760000000`; `exp - iat` ⇒ `3155760000` seconds). "No deadline" and "a distant deadline" are different things, and only the second was ever asked for. If you sign tokens with your own signer and feed them to this package's verifier, they must carry `exp`.
77
+ - **Potentially breaking:** `RefreshToken.isExpired` now answers `true` for a missing or unparseable `expires_at`; it previously answered `false` ("no expiry recorded ⇒ never expires"). That reading handed an unlimited life to precisely the malformed rows. `expires_at` is `required` in the schema — a row that cannot say when it dies is malformed, not immortal. `AccessToken.isExpired` is new and fails closed the same way.
78
+ - **Potentially breaking:** an invalid `accessToken.expiresIn` / `refreshToken.expiresIn` now throws on token issue instead of producing a token with a wrong or absent expiry. Valid configuration is unaffected — `"1h"`, `"7d"`, `"30 days"`, `NO_EXPIRATION` (`"100y"`), the `1h` access default when the key is absent, and the `7d` refresh default all behave exactly as before. An **empty string** (e.g. `env("JWT_TTL")` with the variable unset) now throws rather than falling back; give the env read an explicit default.
79
+ - `authConfig.accessToken.expiresInMs()` / `authConfig.refreshToken.expiresInMs()` are the validated accessors token issuers must use; the raw `expiresIn()` accessors are unchanged.
80
+ - Removed the `as ms.StringValue` casts on both call sites. They were what let arbitrary config text compile against `ms`'s template-literal type and reach the signer as `undefined`.
81
+ - Declares its own test runner and pins it to an exact version (`vitest@4.1.10`). The package is its own repository, so a runner resolved from a workspace root it may not be cloned with is a runner it cannot rely on. The pin is exact rather than a range because the version moved underneath the suite mid-development on an unrelated install — a suite whose runner can change without anyone choosing it proves less than it appears to
82
+
7
83
  ## 4.10.0
8
84
 
9
85
  ### Changed
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Warlock Auth
2
2
 
3
- JWT authentication for [Warlock.js](https://github.com/warlockjs/core) applications — a base `Auth` model your user types extend, an `authMiddleware` route gate, an `authService` for login / logout / refresh (with refresh-token rotation + replay detection), persisted access + refresh tokens, multi-user-type support, lifecycle events, brute-force throttling, and two CLI commands.
3
+ JWT authentication for [Warlock.js](https://github.com/warlockjs/core) applications — a base `Auth` model your user types extend, an `authMiddleware` route gate, an `authService` for login / logout / refresh (with refresh-token rotation + replay detection), persisted access + refresh tokens, multi-user-type support, lifecycle events, brute-force throttling, and three CLI commands.
4
4
 
5
5
  ## Installation
6
6
 
@@ -35,6 +35,10 @@ export default authConfigurations;
35
35
 
36
36
  The legacy `jwt: { secret, expiresIn, refresh: {…} }` block is still honored (with a deprecation warning), but prefer the `accessToken` / `refreshToken` blocks.
37
37
 
38
+ `expiresIn` must be a duration string [`ms`](https://github.com/vercel/ms) parses to a positive value (`"1h"`, `"30 days"`, `NO_EXPIRATION`); anything else throws naming the key on the first token issue, rather than signing a token with no expiry.
39
+
40
+ A token is only accepted while **both** its own `exp` claim and its stored `expires_at` are in the future — a JWT with no `exp` at all is rejected outright, since a verifier with no deadline to check succeeds forever. If any deployment ran a pre-4.12.0 version with an invalid `expiresIn`, tokens issued then may never expire and are still live: run `warlock auth.purge-never-expiring --dry-run` to find them. See the 4.12.0 entry in [`CHANGELOG.md`](./CHANGELOG.md).
41
+
38
42
  ## Documentation
39
43
 
40
44
  Task-focused guides live under [`skills/`](./skills):
@@ -0,0 +1,29 @@
1
+ //#region ../auth/src/commands/auth-purge-never-expiring-command.d.ts
2
+ /**
3
+ * Register the `auth.purge-never-expiring` CLI command — remediation for the
4
+ * pre-4.12.0 `expiresIn` defect (#25).
5
+ *
6
+ * **Why a command and not a migration.** The rows this removes cannot be
7
+ * selected by a date predicate (an `Invalid Date` compares `false` against
8
+ * every date, so `WHERE expires_at < now()` never matches one) and the
9
+ * definitive signal — a token with no `exp` claim — lives inside the JWT
10
+ * string, not in a column. A migration would also run once, silently, at deploy
11
+ * time; an operator dealing with possibly-leaked credentials needs to *look*
12
+ * first, then act, and to repeat it. Hence `--dry-run`, which is the default
13
+ * posture this command is expected to be run in first.
14
+ *
15
+ * A migration is additionally the wrong shape for a fix that must reach both
16
+ * supported stores: the same row is a different thing in MongoDB and Postgres,
17
+ * and this goes through the models so a registered override
18
+ * (`config.auth.accessToken.model`) is honoured.
19
+ *
20
+ * @example
21
+ * ```bash
22
+ * warlock auth.purge-never-expiring --dry-run # report only
23
+ * warlock auth.purge-never-expiring # report, then delete
24
+ * ```
25
+ */
26
+ declare function registerAuthPurgeNeverExpiringCommand(): any;
27
+ //#endregion
28
+ export { registerAuthPurgeNeverExpiringCommand };
29
+ //# sourceMappingURL=auth-purge-never-expiring-command.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth-purge-never-expiring-command.d.mts","names":[],"sources":["../../../../../../../auth/src/commands/auth-purge-never-expiring-command.ts"],"mappings":";;AA4BA;;;;AAAqD;;;;;;;;;;;;;;;;;;;iBAArC,qCAAA"}
@@ -0,0 +1,70 @@
1
+ import { authService } from "../services/auth.service.mjs";
2
+ import { colors } from "@mongez/copper";
3
+ import { command } from "@warlock.js/core";
4
+
5
+ //#region ../auth/src/commands/auth-purge-never-expiring-command.ts
6
+ /**
7
+ * Register the `auth.purge-never-expiring` CLI command — remediation for the
8
+ * pre-4.12.0 `expiresIn` defect (#25).
9
+ *
10
+ * **Why a command and not a migration.** The rows this removes cannot be
11
+ * selected by a date predicate (an `Invalid Date` compares `false` against
12
+ * every date, so `WHERE expires_at < now()` never matches one) and the
13
+ * definitive signal — a token with no `exp` claim — lives inside the JWT
14
+ * string, not in a column. A migration would also run once, silently, at deploy
15
+ * time; an operator dealing with possibly-leaked credentials needs to *look*
16
+ * first, then act, and to repeat it. Hence `--dry-run`, which is the default
17
+ * posture this command is expected to be run in first.
18
+ *
19
+ * A migration is additionally the wrong shape for a fix that must reach both
20
+ * supported stores: the same row is a different thing in MongoDB and Postgres,
21
+ * and this goes through the models so a registered override
22
+ * (`config.auth.accessToken.model`) is honoured.
23
+ *
24
+ * @example
25
+ * ```bash
26
+ * warlock auth.purge-never-expiring --dry-run # report only
27
+ * warlock auth.purge-never-expiring # report, then delete
28
+ * ```
29
+ */
30
+ function registerAuthPurgeNeverExpiringCommand() {
31
+ return command({
32
+ name: "auth.purge-never-expiring",
33
+ description: "Find (and delete) token rows that can never expire — an unusable expires_at, or a token with no exp claim",
34
+ options: [{
35
+ text: "--dry-run",
36
+ description: "Report the affected rows without deleting anything",
37
+ type: "boolean"
38
+ }],
39
+ preload: {
40
+ env: true,
41
+ config: ["auth", "database"],
42
+ connectors: ["database"]
43
+ },
44
+ action: async (data = {}) => {
45
+ const flags = data.options ?? {};
46
+ const flag = flags["dryRun"] ?? flags["dry-run"];
47
+ const dryRun = flag !== void 0 && flag !== false && flag !== "false";
48
+ console.log(colors.cyan("🔎 Scanning for tokens that can never expire..."));
49
+ const { accessTokens, refreshTokens } = await authService.findNeverExpiringTokens();
50
+ const total = accessTokens.length + refreshTokens.length;
51
+ if (total === 0) {
52
+ console.log(colors.green("✅ No never-expiring tokens found."));
53
+ return;
54
+ }
55
+ console.log(colors.yellow(`⚠️ ${total} never-expiring token(s): ${accessTokens.length} access, ${refreshTokens.length} refresh.`));
56
+ for (const token of accessTokens) console.log(colors.yellow(` access id=${token.id} user_id=${token.get("user_id")} user_type=${token.get("user_type")} expires_at=${String(token.get("expires_at"))}`));
57
+ for (const token of refreshTokens) console.log(colors.yellow(` refresh id=${token.id} user_id=${token.get("user_id")} user_type=${token.get("user_type")} expires_at=${String(token.get("expires_at"))}`));
58
+ if (dryRun) {
59
+ console.log(colors.cyan("Dry run — nothing deleted. Re-run without --dry-run to revoke these tokens."));
60
+ return;
61
+ }
62
+ const purged = await authService.purgeNeverExpiringTokens();
63
+ console.log(colors.green(`✅ Revoked ${purged.accessTokens} access token(s) and ${purged.refreshTokens} refresh token(s). Affected users must log in again.`));
64
+ }
65
+ });
66
+ }
67
+
68
+ //#endregion
69
+ export { registerAuthPurgeNeverExpiringCommand };
70
+ //# sourceMappingURL=auth-purge-never-expiring-command.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth-purge-never-expiring-command.mjs","names":[],"sources":["../../../../../../../auth/src/commands/auth-purge-never-expiring-command.ts"],"sourcesContent":["import { colors } from \"@mongez/copper\";\nimport { command } from \"@warlock.js/core\";\nimport { authService } from \"../services/auth.service\";\n\n/**\n * Register the `auth.purge-never-expiring` CLI command — remediation for the\n * pre-4.12.0 `expiresIn` defect (#25).\n *\n * **Why a command and not a migration.** The rows this removes cannot be\n * selected by a date predicate (an `Invalid Date` compares `false` against\n * every date, so `WHERE expires_at < now()` never matches one) and the\n * definitive signal — a token with no `exp` claim — lives inside the JWT\n * string, not in a column. A migration would also run once, silently, at deploy\n * time; an operator dealing with possibly-leaked credentials needs to *look*\n * first, then act, and to repeat it. Hence `--dry-run`, which is the default\n * posture this command is expected to be run in first.\n *\n * A migration is additionally the wrong shape for a fix that must reach both\n * supported stores: the same row is a different thing in MongoDB and Postgres,\n * and this goes through the models so a registered override\n * (`config.auth.accessToken.model`) is honoured.\n *\n * @example\n * ```bash\n * warlock auth.purge-never-expiring --dry-run # report only\n * warlock auth.purge-never-expiring # report, then delete\n * ```\n */\nexport function registerAuthPurgeNeverExpiringCommand() {\n return command({\n name: \"auth.purge-never-expiring\",\n description:\n \"Find (and delete) token rows that can never expire — an unusable expires_at, or a token with no exp claim\",\n options: [\n {\n text: \"--dry-run\",\n description: \"Report the affected rows without deleting anything\",\n type: \"boolean\",\n },\n ],\n preload: {\n env: true,\n config: [\"auth\", \"database\"],\n connectors: [\"database\"],\n },\n action: async (data: { options?: Record<string, unknown> } = {}) => {\n // Core camelCases flags (`--dry-run` ⇒ `dryRun`); both spellings are read\n // so the command does not depend on that detail staying true.\n //\n // Read fail-safe: the flag counts as set unless it is *explicitly* false\n // (`--dry-run=false`). An operator who asked to look before deleting and\n // got a delete because a flag arrived as `\"true\"` instead of `true` would\n // lose live credentials to a parsing detail.\n const flags = data.options ?? {};\n const flag = flags[\"dryRun\"] ?? flags[\"dry-run\"];\n const dryRun = flag !== undefined && flag !== false && flag !== \"false\";\n\n console.log(colors.cyan(\"🔎 Scanning for tokens that can never expire...\"));\n\n const { accessTokens, refreshTokens } = await authService.findNeverExpiringTokens();\n const total = accessTokens.length + refreshTokens.length;\n\n if (total === 0) {\n console.log(colors.green(\"✅ No never-expiring tokens found.\"));\n\n return;\n }\n\n console.log(\n colors.yellow(\n `⚠️ ${total} never-expiring token(s): ` +\n `${accessTokens.length} access, ${refreshTokens.length} refresh.`,\n ),\n );\n\n // Identify each row so an operator can correlate with their own audit\n // trail before (or after) the rows are gone. The token string itself is\n // never printed — it is a live credential until this command removes it.\n for (const token of accessTokens) {\n console.log(\n colors.yellow(\n ` access id=${token.id} user_id=${token.get(\"user_id\")} ` +\n `user_type=${token.get(\"user_type\")} expires_at=${String(token.get(\"expires_at\"))}`,\n ),\n );\n }\n\n for (const token of refreshTokens) {\n console.log(\n colors.yellow(\n ` refresh id=${token.id} user_id=${token.get(\"user_id\")} ` +\n `user_type=${token.get(\"user_type\")} expires_at=${String(token.get(\"expires_at\"))}`,\n ),\n );\n }\n\n if (dryRun) {\n console.log(\n colors.cyan(\"Dry run — nothing deleted. Re-run without --dry-run to revoke these tokens.\"),\n );\n\n return;\n }\n\n // Re-selects rather than deleting the rows just listed: the delete then\n // acts on the table's current state instead of a snapshot taken before\n // the operator read the report. A second full scan is affordable here —\n // this is a one-off remediation, not a request path.\n const purged = await authService.purgeNeverExpiringTokens();\n\n console.log(\n colors.green(\n `✅ Revoked ${purged.accessTokens} access token(s) and ` +\n `${purged.refreshTokens} refresh token(s). Affected users must log in again.`,\n ),\n );\n },\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BA,SAAgB,wCAAwC;CACtD,OAAO,QAAQ;EACb,MAAM;EACN,aACE;EACF,SAAS,CACP;GACE,MAAM;GACN,aAAa;GACb,MAAM;EACR,CACF;EACA,SAAS;GACP,KAAK;GACL,QAAQ,CAAC,QAAQ,UAAU;GAC3B,YAAY,CAAC,UAAU;EACzB;EACA,QAAQ,OAAO,OAA8C,CAAC,MAAM;GAQlE,MAAM,QAAQ,KAAK,WAAW,CAAC;GAC/B,MAAM,OAAO,MAAM,aAAa,MAAM;GACtC,MAAM,SAAS,SAAS,UAAa,SAAS,SAAS,SAAS;GAEhE,QAAQ,IAAI,OAAO,KAAK,iDAAiD,CAAC;GAE1E,MAAM,EAAE,cAAc,kBAAkB,MAAM,YAAY,wBAAwB;GAClF,MAAM,QAAQ,aAAa,SAAS,cAAc;GAElD,IAAI,UAAU,GAAG;IACf,QAAQ,IAAI,OAAO,MAAM,mCAAmC,CAAC;IAE7D;GACF;GAEA,QAAQ,IACN,OAAO,OACL,OAAO,MAAM,4BACR,aAAa,OAAO,WAAW,cAAc,OAAO,UAC3D,CACF;GAKA,KAAK,MAAM,SAAS,cAClB,QAAQ,IACN,OAAO,OACL,gBAAgB,MAAM,GAAG,WAAW,MAAM,IAAI,SAAS,EAAE,aAC1C,MAAM,IAAI,WAAW,EAAE,cAAc,OAAO,MAAM,IAAI,YAAY,CAAC,GACpF,CACF;GAGF,KAAK,MAAM,SAAS,eAClB,QAAQ,IACN,OAAO,OACL,gBAAgB,MAAM,GAAG,WAAW,MAAM,IAAI,SAAS,EAAE,aAC1C,MAAM,IAAI,WAAW,EAAE,cAAc,OAAO,MAAM,IAAI,YAAY,CAAC,GACpF,CACF;GAGF,IAAI,QAAQ;IACV,QAAQ,IACN,OAAO,KAAK,6EAA6E,CAC3F;IAEA;GACF;GAMA,MAAM,SAAS,MAAM,YAAY,yBAAyB;GAE1D,QAAQ,IACN,OAAO,MACL,aAAa,OAAO,aAAa,uBAC5B,OAAO,cAAc,qDAC5B,CACF;EACF;CACF,CAAC;AACH"}
@@ -54,7 +54,11 @@ type AccessTokenConfig = {
54
54
  */
55
55
  algorithm?: Algorithm;
56
56
  /**
57
- * Access-token lifetime — any value the `ms` package accepts.
57
+ * Access-token lifetime — a duration string `ms` parses to a **positive**
58
+ * number of milliseconds. A value `ms` cannot parse (`"30dayz"`), a
59
+ * non-positive one (`"0d"`), or a bare number throws naming this key on the
60
+ * first token issue: each of them would otherwise sign a token with no `exp`
61
+ * claim at all.
58
62
  * @example "1h" or NO_EXPIRATION
59
63
  * @default "1h"
60
64
  */
@@ -80,7 +84,9 @@ type RefreshTokenConfig = {
80
84
  */
81
85
  secret?: string;
82
86
  /**
83
- * Refresh-token lifetime — any value the `ms` package accepts.
87
+ * Refresh-token lifetime — a duration string `ms` parses to a **positive**
88
+ * number of milliseconds; an unusable value throws naming this key on issue
89
+ * (see {@link AccessTokenConfig.expiresIn}).
84
90
  * @example "7d" or "1w"
85
91
  * @default "7d"
86
92
  */
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.mts","names":[],"sources":["../../../../../../../auth/src/contracts/types.ts"],"mappings":";;;;;;;;;;;AAyBA;;;;AAA0B;AAO1B;;;;AAAsC;AAQtC;;;;;cAfa,aAAA;;;;;;KAOD,0BAAA;AAmCZ;;;;;;AAAA,KA3BY,iBAAA;EA0CV;;;;;EApCA,KAAA,UAAe,WAAA;EAyDgC;AAAA;AASjD;EA9DE,MAAA;;;;;EAKA,SAAA,GAAY,SAAS;EA4DrB;;;;;EAtDA,SAAA;AAAA;;;;KAMU,kBAAA;EA2DA;;;;EAtDV,KAAA,UAAe,YAAA;EAiED;;;;EA5Dd,OAAA;EAsDA;;;;EAjDA,MAAA;EAuDc;;;;;EAjDd,SAAA;EAyDqB;AAGvB;;;EAvDE,QAAA;EA+DS;AAMX;;;EAhEE,UAAA;EAoEA;;;;EA/DA,kBAAA,GAAqB,0BAA0B;AAAA;AAyEjD;;;;;;AAAA,KAhEY,eAAA;EACV,MAAA;EACA,SAAA,GAAY,SAAA;EACZ,SAAA;EACA,OAAA;IACE,MAAA;IACA,OAAA;IACA,SAAA;IACA,QAAA;IACA,UAAA;IACA,kBAAA,GAAqB,0BAA0B;EAAA;AAAA;AAAA,KAIvC,kBAAA;EA0EY;;;;EArEtB,QAAA;IAAA,CACG,QAAA,WAAmB,UAAA,CAAW,IAAA;EAAA;EAsEhB;;;EAjEjB,WAAA,GAAc,iBAAA;;;;EAId,YAAA,GAAe,kBAAA;;;;EAIf,GAAA,GAAM,eAAA;AAAA;AAAA,KAGI,iBAAA;;;;EAIV,KAAA;;;;EAIA,SAAS;AAAA;;;;KAMC,SAAA;;;;EAIV,WAAA,EAAa,iBAAA;;;;EAIb,YAAA,GAAe,iBAAiB;AAAA;;;;KAMtB,UAAA;;;;EAIV,SAAA;;;;EAIA,EAAA;;;;EAIA,QAAA;;;;;EAKA,QAAA;;;;EAIA,OAAA,GAAU,MAAM;AAAA;AAAA,KAGN,WAAA,kBAA6B,IAAA;EACvC,IAAA,EAAM,QAAA;EACN,MAAA,EAAQ,SAAA;AAAA"}
1
+ {"version":3,"file":"types.d.mts","names":[],"sources":["../../../../../../../auth/src/contracts/types.ts"],"mappings":";;;;;;;;;;;AAyBA;;;;AAA0B;AAO1B;;;;AAAsC;AAQtC;;;;;cAfa,aAAA;;;;;;KAOD,0BAAA;AAuCZ;;;;;;AAAA,KA/BY,iBAAA;EA8CV;;;;;EAxCA,KAAA,UAAe,WAAA;EA+DgC;AAAA;AASjD;EApEE,MAAA;;;;;EAKA,SAAA,GAAY,SAAS;EAkErB;;;;;;;;;EAxDA,SAAA;AAAA;AAmEF;;;AAAA,KA7DY,kBAAA;EAmEY;;;;EA9DtB,KAAA,UAAe,YAAA;EA2EM;;;;EAtErB,OAAA;EA8DA;;;;EAzDA,MAAA;EAiEM;;AAAe;AAGvB;;;;EA5DE,SAAA;EA0EU;;;;EArEV,QAAA;EAyEa;;;;EApEb,UAAA;EA8EU;;;;EAzEV,kBAAA,GAAqB,0BAA0B;AAAA;;;;;;AA8F/B;KArFN,eAAA;EACV,MAAA;EACA,SAAA,GAAY,SAAA;EACZ,SAAA;EACA,OAAA;IACE,MAAA;IACA,OAAA;IACA,SAAA;IACA,QAAA;IACA,UAAA;IACA,kBAAA,GAAqB,0BAA0B;EAAA;AAAA;AAAA,KAIvC,kBAAA;EA4EO;AAAA;;;EAvEjB,QAAA;IAAA,CACG,QAAA,WAAmB,UAAA,CAAW,IAAA;EAAA;;;;EAKjC,WAAA,GAAc,iBAAA;;;;EAId,YAAA,GAAe,kBAAA;;;;EAIf,GAAA,GAAM,eAAA;AAAA;AAAA,KAGI,iBAAA;;;;EAIV,KAAA;;;;EAIA,SAAS;AAAA;;;;KAMC,SAAA;;;;EAIV,WAAA,EAAa,iBAAA;;;;EAIb,YAAA,GAAe,iBAAiB;AAAA;;;;KAMtB,UAAA;;;;EAIV,SAAA;;;;EAIA,EAAA;;;;EAIA,QAAA;;;;;EAKA,QAAA;;;;EAIA,OAAA,GAAU,MAAM;AAAA;AAAA,KAGN,WAAA,kBAA6B,IAAA;EACvC,IAAA,EAAM,QAAA;EACN,MAAA,EAAQ,SAAA;AAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"types.mjs","names":[],"sources":["../../../../../../../auth/src/contracts/types.ts"],"sourcesContent":["import { type ChildModel } from \"@warlock.js/cascade\";\r\nimport { type Algorithm } from \"fast-jwt\";\r\nimport type { AccessToken } from \"../models/access-token\";\r\nimport type { Auth } from \"../models/auth.model\";\r\nimport type { RefreshToken } from \"../models/refresh-token\";\r\n\r\n/**\r\n * Symbol to indicate no expiration for tokens\r\n * Use this when you explicitly want tokens to never expire\r\n *\r\n * @example\r\n * ```typescript\r\n * // src/config/auth.ts\r\n * import { NO_EXPIRATION, type AuthConfigurations } from \"@warlock.js/auth\";\r\n *\r\n * const authConfigurations: AuthConfigurations = {\r\n * accessToken: {\r\n * secret: env(\"JWT_SECRET\"),\r\n * expiresIn: NO_EXPIRATION, // Token expires within 100 years\r\n * },\r\n * };\r\n *\r\n * export default authConfigurations;\r\n * ```\r\n */\r\nexport const NO_EXPIRATION = \"100y\";\r\n\r\n/**\r\n * Behavior when logout is called without a refresh token\r\n * - \"revoke-all\": Revoke all refresh tokens for the user (secure default)\r\n * - \"error\": Return an error requiring the refresh token\r\n */\r\nexport type LogoutWithoutTokenBehavior = \"revoke-all\" | \"error\";\r\n\r\n/**\r\n * Access-token configuration.\r\n *\r\n * `secret` may also be supplied via the deprecated `jwt.secret` (resolved by a\r\n * backward-compatible shim); one of the two is required at runtime.\r\n */\r\nexport type AccessTokenConfig = {\r\n /**\r\n * Override the persisted access-token model — extend {@link AccessToken} to\r\n * add columns (e.g. a tenant key) or rename them, then register the subclass\r\n * here. Defaults to the package's `AccessToken`.\r\n */\r\n model?: typeof AccessToken;\r\n /**\r\n * Secret used to sign access tokens.\r\n */\r\n secret?: string;\r\n /**\r\n * JWT algorithm.\r\n * @default \"HS256\"\r\n */\r\n algorithm?: Algorithm;\r\n /**\r\n * Access-token lifetime — any value the `ms` package accepts.\r\n * @example \"1h\" or NO_EXPIRATION\r\n * @default \"1h\"\r\n */\r\n expiresIn?: string;\r\n};\r\n\r\n/**\r\n * Refresh-token configuration.\r\n */\r\nexport type RefreshTokenConfig = {\r\n /**\r\n * Override the persisted refresh-token model — extend {@link RefreshToken}.\r\n * Defaults to the package's `RefreshToken`.\r\n */\r\n model?: typeof RefreshToken;\r\n /**\r\n * Enable refresh tokens.\r\n * @default true\r\n */\r\n enabled?: boolean;\r\n /**\r\n * Separate secret for refresh tokens (recommended for security). Falls back\r\n * to the access-token secret when omitted.\r\n */\r\n secret?: string;\r\n /**\r\n * Refresh-token lifetime — any value the `ms` package accepts.\r\n * @example \"7d\" or \"1w\"\r\n * @default \"7d\"\r\n */\r\n expiresIn?: string;\r\n /**\r\n * Rotate the refresh token on each use (old token revoked, replay detected).\r\n * @default true\r\n */\r\n rotation?: boolean;\r\n /**\r\n * Maximum active refresh tokens per user; the oldest are revoked past it.\r\n * @default 5\r\n */\r\n maxPerUser?: number;\r\n /**\r\n * Behavior when logout is called without a refresh token.\r\n * @default \"revoke-all\"\r\n */\r\n logoutWithoutToken?: LogoutWithoutTokenBehavior;\r\n};\r\n\r\n/**\r\n * Legacy JWT configuration shape. Still honored by the resolver shim with a\r\n * deprecation warning.\r\n *\r\n * @deprecated Use the top-level `accessToken` / `refreshToken` blocks.\r\n */\r\nexport type LegacyJwtConfig = {\r\n secret?: string;\r\n algorithm?: Algorithm;\r\n expiresIn?: string;\r\n refresh?: {\r\n secret?: string;\r\n enabled?: boolean;\r\n expiresIn?: string;\r\n rotation?: boolean;\r\n maxPerUser?: number;\r\n logoutWithoutToken?: LogoutWithoutTokenBehavior;\r\n };\r\n};\r\n\r\nexport type AuthConfigurations = {\r\n /**\r\n * Define all user types — maps a user-type slug to its `Auth` model class so\r\n * tokens and the middleware can resolve the right model.\r\n */\r\n userType: {\r\n [userType: string]: ChildModel<Auth>;\r\n };\r\n /**\r\n * Access-token configuration (secret, expiry, algorithm, model override).\r\n */\r\n accessToken?: AccessTokenConfig;\r\n /**\r\n * Refresh-token configuration (rotation, lifetime, cap, model override).\r\n */\r\n refreshToken?: RefreshTokenConfig;\r\n /**\r\n * @deprecated Use `accessToken` / `refreshToken`. Read via a backward-compatible shim.\r\n */\r\n jwt?: LegacyJwtConfig;\r\n};\r\n\r\nexport type AccessTokenOutput = {\r\n /**\r\n * JWT Token\r\n */\r\n token: string;\r\n /**\r\n * Exprie time in ISO format UTC time\r\n */\r\n expiresAt: string;\r\n};\r\n\r\n/**\r\n * Token pair returned after login or token refresh\r\n */\r\nexport type TokenPair = {\r\n /**\r\n * JWT access token (short-lived)\r\n */\r\n accessToken: AccessTokenOutput;\r\n /**\r\n * JWT refresh token (long-lived)\r\n */\r\n refreshToken?: AccessTokenOutput;\r\n};\r\n\r\n/**\r\n * Device information for session tracking\r\n */\r\nexport type DeviceInfo = {\r\n /**\r\n * User agent string from request\r\n */\r\n userAgent?: string;\r\n /**\r\n * Client IP address\r\n */\r\n ip?: string;\r\n /**\r\n * Optional device identifier\r\n */\r\n deviceId?: string;\r\n /**\r\n * Token family ID (for rotation tracking)\r\n * @internal\r\n */\r\n familyId?: string;\r\n /**\r\n * Access token payload\r\n */\r\n payload?: Record<string, any>;\r\n};\r\n\r\nexport type LoginResult<UserType extends Auth> = {\r\n user: UserType;\r\n tokens: TokenPair;\r\n};\r\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAyBA,MAAa,gBAAgB"}
1
+ {"version":3,"file":"types.mjs","names":[],"sources":["../../../../../../../auth/src/contracts/types.ts"],"sourcesContent":["import { type ChildModel } from \"@warlock.js/cascade\";\r\nimport { type Algorithm } from \"fast-jwt\";\r\nimport type { AccessToken } from \"../models/access-token\";\r\nimport type { Auth } from \"../models/auth.model\";\r\nimport type { RefreshToken } from \"../models/refresh-token\";\r\n\r\n/**\r\n * Symbol to indicate no expiration for tokens\r\n * Use this when you explicitly want tokens to never expire\r\n *\r\n * @example\r\n * ```typescript\r\n * // src/config/auth.ts\r\n * import { NO_EXPIRATION, type AuthConfigurations } from \"@warlock.js/auth\";\r\n *\r\n * const authConfigurations: AuthConfigurations = {\r\n * accessToken: {\r\n * secret: env(\"JWT_SECRET\"),\r\n * expiresIn: NO_EXPIRATION, // Token expires within 100 years\r\n * },\r\n * };\r\n *\r\n * export default authConfigurations;\r\n * ```\r\n */\r\nexport const NO_EXPIRATION = \"100y\";\r\n\r\n/**\r\n * Behavior when logout is called without a refresh token\r\n * - \"revoke-all\": Revoke all refresh tokens for the user (secure default)\r\n * - \"error\": Return an error requiring the refresh token\r\n */\r\nexport type LogoutWithoutTokenBehavior = \"revoke-all\" | \"error\";\r\n\r\n/**\r\n * Access-token configuration.\r\n *\r\n * `secret` may also be supplied via the deprecated `jwt.secret` (resolved by a\r\n * backward-compatible shim); one of the two is required at runtime.\r\n */\r\nexport type AccessTokenConfig = {\r\n /**\r\n * Override the persisted access-token model — extend {@link AccessToken} to\r\n * add columns (e.g. a tenant key) or rename them, then register the subclass\r\n * here. Defaults to the package's `AccessToken`.\r\n */\r\n model?: typeof AccessToken;\r\n /**\r\n * Secret used to sign access tokens.\r\n */\r\n secret?: string;\r\n /**\r\n * JWT algorithm.\r\n * @default \"HS256\"\r\n */\r\n algorithm?: Algorithm;\r\n /**\r\n * Access-token lifetime — a duration string `ms` parses to a **positive**\r\n * number of milliseconds. A value `ms` cannot parse (`\"30dayz\"`), a\r\n * non-positive one (`\"0d\"`), or a bare number throws naming this key on the\r\n * first token issue: each of them would otherwise sign a token with no `exp`\r\n * claim at all.\r\n * @example \"1h\" or NO_EXPIRATION\r\n * @default \"1h\"\r\n */\r\n expiresIn?: string;\r\n};\r\n\r\n/**\r\n * Refresh-token configuration.\r\n */\r\nexport type RefreshTokenConfig = {\r\n /**\r\n * Override the persisted refresh-token model — extend {@link RefreshToken}.\r\n * Defaults to the package's `RefreshToken`.\r\n */\r\n model?: typeof RefreshToken;\r\n /**\r\n * Enable refresh tokens.\r\n * @default true\r\n */\r\n enabled?: boolean;\r\n /**\r\n * Separate secret for refresh tokens (recommended for security). Falls back\r\n * to the access-token secret when omitted.\r\n */\r\n secret?: string;\r\n /**\r\n * Refresh-token lifetime — a duration string `ms` parses to a **positive**\r\n * number of milliseconds; an unusable value throws naming this key on issue\r\n * (see {@link AccessTokenConfig.expiresIn}).\r\n * @example \"7d\" or \"1w\"\r\n * @default \"7d\"\r\n */\r\n expiresIn?: string;\r\n /**\r\n * Rotate the refresh token on each use (old token revoked, replay detected).\r\n * @default true\r\n */\r\n rotation?: boolean;\r\n /**\r\n * Maximum active refresh tokens per user; the oldest are revoked past it.\r\n * @default 5\r\n */\r\n maxPerUser?: number;\r\n /**\r\n * Behavior when logout is called without a refresh token.\r\n * @default \"revoke-all\"\r\n */\r\n logoutWithoutToken?: LogoutWithoutTokenBehavior;\r\n};\r\n\r\n/**\r\n * Legacy JWT configuration shape. Still honored by the resolver shim with a\r\n * deprecation warning.\r\n *\r\n * @deprecated Use the top-level `accessToken` / `refreshToken` blocks.\r\n */\r\nexport type LegacyJwtConfig = {\r\n secret?: string;\r\n algorithm?: Algorithm;\r\n expiresIn?: string;\r\n refresh?: {\r\n secret?: string;\r\n enabled?: boolean;\r\n expiresIn?: string;\r\n rotation?: boolean;\r\n maxPerUser?: number;\r\n logoutWithoutToken?: LogoutWithoutTokenBehavior;\r\n };\r\n};\r\n\r\nexport type AuthConfigurations = {\r\n /**\r\n * Define all user types — maps a user-type slug to its `Auth` model class so\r\n * tokens and the middleware can resolve the right model.\r\n */\r\n userType: {\r\n [userType: string]: ChildModel<Auth>;\r\n };\r\n /**\r\n * Access-token configuration (secret, expiry, algorithm, model override).\r\n */\r\n accessToken?: AccessTokenConfig;\r\n /**\r\n * Refresh-token configuration (rotation, lifetime, cap, model override).\r\n */\r\n refreshToken?: RefreshTokenConfig;\r\n /**\r\n * @deprecated Use `accessToken` / `refreshToken`. Read via a backward-compatible shim.\r\n */\r\n jwt?: LegacyJwtConfig;\r\n};\r\n\r\nexport type AccessTokenOutput = {\r\n /**\r\n * JWT Token\r\n */\r\n token: string;\r\n /**\r\n * Exprie time in ISO format UTC time\r\n */\r\n expiresAt: string;\r\n};\r\n\r\n/**\r\n * Token pair returned after login or token refresh\r\n */\r\nexport type TokenPair = {\r\n /**\r\n * JWT access token (short-lived)\r\n */\r\n accessToken: AccessTokenOutput;\r\n /**\r\n * JWT refresh token (long-lived)\r\n */\r\n refreshToken?: AccessTokenOutput;\r\n};\r\n\r\n/**\r\n * Device information for session tracking\r\n */\r\nexport type DeviceInfo = {\r\n /**\r\n * User agent string from request\r\n */\r\n userAgent?: string;\r\n /**\r\n * Client IP address\r\n */\r\n ip?: string;\r\n /**\r\n * Optional device identifier\r\n */\r\n deviceId?: string;\r\n /**\r\n * Token family ID (for rotation tracking)\r\n * @internal\r\n */\r\n familyId?: string;\r\n /**\r\n * Access token payload\r\n */\r\n payload?: Record<string, any>;\r\n};\r\n\r\nexport type LoginResult<UserType extends Auth> = {\r\n user: UserType;\r\n tokens: TokenPair;\r\n};\r\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAyBA,MAAa,gBAAgB"}
package/esm/index.d.mts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { registerAuthCleanupCommand } from "./commands/auth-cleanup-command.mjs";
2
+ import { registerAuthPurgeNeverExpiringCommand } from "./commands/auth-purge-never-expiring-command.mjs";
2
3
  import { registerJWTSecretGeneratorCommand } from "./commands/jwt-secret-generator-command.mjs";
3
4
  import { Auth } from "./models/auth.model.mjs";
4
5
  import { AccessToken, accessTokenSchema } from "./models/access-token/access-token.model.mjs";
@@ -13,4 +14,5 @@ import { authService } from "./services/auth.service.mjs";
13
14
  import { generateJWTSecret } from "./services/generate-jwt-secret.mjs";
14
15
  import { TokenType, jwt } from "./services/jwt.mjs";
15
16
  import { AuthErrorCodes } from "./utils/auth-error-codes.mjs";
16
- export { AccessToken, AccessTokenConfig, AccessTokenOutput, Auth, AuthConfigurations, AuthErrorCodes, AuthEventCallback, AuthEventName, AuthEventPayloads, Authenticable, DeviceInfo, LegacyJwtConfig, LoginResult, LoginThrottleIdentifier, LoginThrottleOptions, LogoutWithoutTokenBehavior, NO_EXPIRATION, RefreshToken, RefreshTokenConfig, RefreshTokenIssueOptions, TokenPair, TokenType, accessTokenSchema, authEvents, authMiddleware, authMigrations, authService, generateJWTSecret, jwt, loginThrottleMiddleware, refreshTokenSchema, registerAuthCleanupCommand, registerJWTSecretGeneratorCommand };
17
+ import { isNeverExpiring, isUsableExpiry, tokenHasExpClaim } from "./utils/token-expiry.mjs";
18
+ export { AccessToken, AccessTokenConfig, AccessTokenOutput, Auth, AuthConfigurations, AuthErrorCodes, AuthEventCallback, AuthEventName, AuthEventPayloads, Authenticable, DeviceInfo, LegacyJwtConfig, LoginResult, LoginThrottleIdentifier, LoginThrottleOptions, LogoutWithoutTokenBehavior, NO_EXPIRATION, RefreshToken, RefreshTokenConfig, RefreshTokenIssueOptions, TokenPair, TokenType, accessTokenSchema, authEvents, authMiddleware, authMigrations, authService, generateJWTSecret, isNeverExpiring, isUsableExpiry, jwt, loginThrottleMiddleware, refreshTokenSchema, registerAuthCleanupCommand, registerAuthPurgeNeverExpiringCommand, registerJWTSecretGeneratorCommand, tokenHasExpClaim };
package/esm/index.mjs CHANGED
@@ -1,9 +1,11 @@
1
+ import { isNeverExpiring, isUsableExpiry, tokenHasExpClaim } from "./utils/token-expiry.mjs";
1
2
  import { AccessToken, accessTokenSchema } from "./models/access-token/access-token.model.mjs";
2
3
  import { RefreshToken, refreshTokenSchema } from "./models/refresh-token/refresh-token.model.mjs";
3
4
  import { authEvents } from "./services/auth-events.mjs";
4
5
  import { jwt } from "./services/jwt.mjs";
5
6
  import { authService } from "./services/auth.service.mjs";
6
7
  import { registerAuthCleanupCommand } from "./commands/auth-cleanup-command.mjs";
8
+ import { registerAuthPurgeNeverExpiringCommand } from "./commands/auth-purge-never-expiring-command.mjs";
7
9
  import { generateJWTSecret } from "./services/generate-jwt-secret.mjs";
8
10
  import { registerJWTSecretGeneratorCommand } from "./commands/jwt-secret-generator-command.mjs";
9
11
  import { NO_EXPIRATION } from "./contracts/types.mjs";
@@ -14,5 +16,6 @@ import "./middleware/index.mjs";
14
16
  import "./services/index.mjs";
15
17
  import { Auth } from "./models/auth.model.mjs";
16
18
  import { authMigrations } from "./models/index.mjs";
19
+ import "./utils/index.mjs";
17
20
 
18
- export { AccessToken, Auth, AuthErrorCodes, NO_EXPIRATION, RefreshToken, accessTokenSchema, authEvents, authMiddleware, authMigrations, authService, generateJWTSecret, jwt, loginThrottleMiddleware, refreshTokenSchema, registerAuthCleanupCommand, registerJWTSecretGeneratorCommand };
21
+ export { AccessToken, Auth, AuthErrorCodes, NO_EXPIRATION, RefreshToken, accessTokenSchema, authEvents, authMiddleware, authMigrations, authService, generateJWTSecret, isNeverExpiring, isUsableExpiry, jwt, loginThrottleMiddleware, refreshTokenSchema, registerAuthCleanupCommand, registerAuthPurgeNeverExpiringCommand, registerJWTSecretGeneratorCommand, tokenHasExpClaim };
@@ -1 +1 @@
1
- {"version":3,"file":"auth.middleware.d.mts","names":[],"sources":["../../../../../../../auth/src/middleware/auth.middleware.ts"],"mappings":";;AAgCA;;;;AAAiE;;;;;;;;;;;;iBAAjD,cAAA,CAAe,eAAA,sBAAkC,UAAA"}
1
+ {"version":3,"file":"auth.middleware.d.mts","names":[],"sources":["../../../../../../../auth/src/middleware/auth.middleware.ts"],"mappings":";;AAmDA;;;;AAAiE;;;;;;;;;;;;iBAAjD,cAAA,CAAe,eAAA,sBAAkC,UAAA"}
@@ -7,6 +7,24 @@ import { log } from "@warlock.js/logger";
7
7
 
8
8
  //#region ../auth/src/middleware/auth.middleware.ts
9
9
  /**
10
+ * Whether the persisted row says the token is dead.
11
+ *
12
+ * The answer belongs to the model (`AccessToken.isExpired`), so a registered
13
+ * override that renames or reshapes its expiry column stays authoritative and
14
+ * the middleware never touches a column name. A row that cannot answer at all —
15
+ * an override that dropped the getter — is treated as **expired**: the failure
16
+ * mode of this whole defect class was a check that quietly answered "fine" when
17
+ * it had nothing to check, and that is not repeated here.
18
+ *
19
+ * This is independent of the `exp` claim required by `jwt.verify`. That guard
20
+ * catches a token whose *claims* carry no deadline; this one catches a token
21
+ * whose *row* says the deadline has passed — a logged-out or expired session
22
+ * whose JWT is still within its own lifetime. Neither subsumes the other.
23
+ */
24
+ function accessTokenRowIsExpired(accessToken) {
25
+ return typeof accessToken.isExpired === "boolean" ? accessToken.isExpired : true;
26
+ }
27
+ /**
10
28
  * Build a route gate that always requires an authenticated request.
11
29
  *
12
30
  * The argument is mandatory and selects which user types may pass:
@@ -39,6 +57,13 @@ function authMiddleware(allowedUserType) {
39
57
  error: t("auth.errors.invalidAccessToken"),
40
58
  errorCode: "EC002"
41
59
  });
60
+ if (accessTokenRowIsExpired(accessToken)) {
61
+ await accessToken.destroy();
62
+ return response.unauthorized({
63
+ error: t("auth.errors.invalidAccessToken"),
64
+ errorCode: "EC002"
65
+ });
66
+ }
42
67
  const userType = decoded.userType ?? accessToken.userType;
43
68
  if (allowedTypes.length && !allowedTypes.includes(userType)) return response.unauthorized({
44
69
  error: t("auth.errors.unauthorized"),
@@ -1 +1 @@
1
- {"version":3,"file":"auth.middleware.mjs","names":[],"sources":["../../../../../../../auth/src/middleware/auth.middleware.ts"],"sourcesContent":["import { config, t, type Middleware, type Request, type Response } from \"@warlock.js/core\";\r\nimport { log } from \"@warlock.js/logger\";\r\nimport { AccessToken } from \"../models/access-token\";\r\nimport { jwt } from \"../services/jwt\";\r\nimport { AuthErrorCodes } from \"../utils/auth-error-codes\";\r\n\r\n/**\r\n * Decoded access-token claims the middleware reads. The full payload carries\r\n * more (`created_at`, `tokenType`, `iat`, `exp`) but only these drive routing.\r\n */\r\ntype DecodedAccessToken = {\r\n id: string | number;\r\n userType?: string;\r\n};\r\n\r\n/**\r\n * Build a route gate that always requires an authenticated request.\r\n *\r\n * The argument is mandatory and selects which user types may pass:\r\n * - `[]` — any authenticated user (token required, type not checked).\r\n * - `\"admin\"` / `[\"admin\", \"staff\"]` — token required AND the user's\r\n * `userType` must be one of the listed types.\r\n *\r\n * There is no anonymous/optional mode: a request without a valid access\r\n * token is always rejected with `401`. Routes that should be public\r\n * simply omit the middleware.\r\n *\r\n * @example\r\n * router.get(\"/account\", authMiddleware([]), accountController);\r\n * router.get(\"/admin\", authMiddleware(\"admin\"), adminController);\r\n * router.get(\"/back-office\", authMiddleware([\"admin\", \"staff\"]), backOfficeController);\r\n */\r\nexport function authMiddleware(allowedUserType: string | string[]) {\r\n const allowedTypes = Array.isArray(allowedUserType) ? allowedUserType : [allowedUserType];\r\n\r\n const auth: Middleware = async (request: Request, response: Response) => {\r\n try {\r\n const authorizationValue = request.authorizationValue;\r\n\r\n if (!authorizationValue) {\r\n return response.unauthorized({\r\n error: t(\"auth.errors.missingAccessToken\"),\r\n errorCode: AuthErrorCodes.MissingAccessToken,\r\n });\r\n }\r\n\r\n const decoded = await jwt.verify<DecodedAccessToken>(authorizationValue);\r\n\r\n request.decodedAccessToken = decoded;\r\n\r\n // A valid signature is not enough — the token must still exist in storage,\r\n // so deleting the row (logout) invalidates it before its JWT expiry.\r\n const AccessTokenModel = config.key(\"auth.accessToken.model\", AccessToken);\r\n const accessToken = await AccessTokenModel.findByToken(authorizationValue);\r\n\r\n if (!accessToken) {\r\n return response.unauthorized({\r\n error: t(\"auth.errors.invalidAccessToken\"),\r\n errorCode: AuthErrorCodes.InvalidAccessToken,\r\n });\r\n }\r\n\r\n const userType = decoded.userType ?? accessToken.userType;\r\n\r\n if (allowedTypes.length && !allowedTypes.includes(userType)) {\r\n return response.unauthorized({\r\n error: t(\"auth.errors.unauthorized\"),\r\n errorCode: AuthErrorCodes.Unauthorized,\r\n });\r\n }\r\n\r\n const UserModel = config.key(`auth.userType.${userType}`);\r\n\r\n if (!UserModel) {\r\n throw new Error(`User type ${userType} is unknown type.`);\r\n }\r\n\r\n const currentUser = await UserModel.find(decoded.id);\r\n\r\n if (!currentUser) {\r\n await accessToken.destroy();\r\n\r\n return response.unauthorized({\r\n error: t(\"auth.errors.invalidAccessToken\"),\r\n errorCode: AuthErrorCodes.InvalidAccessToken,\r\n });\r\n }\r\n\r\n request.user = currentUser;\r\n } catch (error: any) {\r\n log.error(\"http\", \"auth\", error);\r\n\r\n request.clearCurrentUser();\r\n\r\n return response.unauthorized({\r\n error: t(\"auth.errors.invalidAccessToken\"),\r\n errorCode: AuthErrorCodes.InvalidAccessToken,\r\n });\r\n }\r\n };\r\n\r\n return auth;\r\n}\r\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;AAgCA,SAAgB,eAAe,iBAAoC;CACjE,MAAM,eAAe,MAAM,QAAQ,eAAe,IAAI,kBAAkB,CAAC,eAAe;CAExF,MAAM,OAAmB,OAAO,SAAkB,aAAuB;EACvE,IAAI;GACF,MAAM,qBAAqB,QAAQ;GAEnC,IAAI,CAAC,oBACH,OAAO,SAAS,aAAa;IAC3B,OAAO,EAAE,gCAAgC;IACzC;GACF,CAAC;GAGH,MAAM,UAAU,MAAM,IAAI,OAA2B,kBAAkB;GAEvE,QAAQ,qBAAqB;GAK7B,MAAM,cAAc,MADK,OAAO,IAAI,0BAA0B,WACrB,CAAC,CAAC,YAAY,kBAAkB;GAEzE,IAAI,CAAC,aACH,OAAO,SAAS,aAAa;IAC3B,OAAO,EAAE,gCAAgC;IACzC;GACF,CAAC;GAGH,MAAM,WAAW,QAAQ,YAAY,YAAY;GAEjD,IAAI,aAAa,UAAU,CAAC,aAAa,SAAS,QAAQ,GACxD,OAAO,SAAS,aAAa;IAC3B,OAAO,EAAE,0BAA0B;IACnC;GACF,CAAC;GAGH,MAAM,YAAY,OAAO,IAAI,iBAAiB,UAAU;GAExD,IAAI,CAAC,WACH,MAAM,IAAI,MAAM,aAAa,SAAS,kBAAkB;GAG1D,MAAM,cAAc,MAAM,UAAU,KAAK,QAAQ,EAAE;GAEnD,IAAI,CAAC,aAAa;IAChB,MAAM,YAAY,QAAQ;IAE1B,OAAO,SAAS,aAAa;KAC3B,OAAO,EAAE,gCAAgC;KACzC;IACF,CAAC;GACH;GAEA,QAAQ,OAAO;EACjB,SAAS,OAAY;GACnB,IAAI,MAAM,QAAQ,QAAQ,KAAK;GAE/B,QAAQ,iBAAiB;GAEzB,OAAO,SAAS,aAAa;IAC3B,OAAO,EAAE,gCAAgC;IACzC;GACF,CAAC;EACH;CACF;CAEA,OAAO;AACT"}
1
+ {"version":3,"file":"auth.middleware.mjs","names":[],"sources":["../../../../../../../auth/src/middleware/auth.middleware.ts"],"sourcesContent":["import { config, t, type Middleware, type Request, type Response } from \"@warlock.js/core\";\r\nimport { log } from \"@warlock.js/logger\";\r\nimport { AccessToken } from \"../models/access-token\";\r\nimport { jwt } from \"../services/jwt\";\r\nimport { AuthErrorCodes } from \"../utils/auth-error-codes\";\r\n\r\n/**\r\n * Decoded access-token claims the middleware reads. The full payload carries\r\n * more (`created_at`, `tokenType`, `iat`, `exp`) but only these drive routing.\r\n */\r\ntype DecodedAccessToken = {\r\n id: string | number;\r\n userType?: string;\r\n};\r\n\r\n/**\r\n * Whether the persisted row says the token is dead.\r\n *\r\n * The answer belongs to the model (`AccessToken.isExpired`), so a registered\r\n * override that renames or reshapes its expiry column stays authoritative and\r\n * the middleware never touches a column name. A row that cannot answer at all —\r\n * an override that dropped the getter — is treated as **expired**: the failure\r\n * mode of this whole defect class was a check that quietly answered \"fine\" when\r\n * it had nothing to check, and that is not repeated here.\r\n *\r\n * This is independent of the `exp` claim required by `jwt.verify`. That guard\r\n * catches a token whose *claims* carry no deadline; this one catches a token\r\n * whose *row* says the deadline has passed — a logged-out or expired session\r\n * whose JWT is still within its own lifetime. Neither subsumes the other.\r\n */\r\nfunction accessTokenRowIsExpired(accessToken: AccessToken): boolean {\r\n return typeof accessToken.isExpired === \"boolean\" ? accessToken.isExpired : true;\r\n}\r\n\r\n/**\r\n * Build a route gate that always requires an authenticated request.\r\n *\r\n * The argument is mandatory and selects which user types may pass:\r\n * - `[]` — any authenticated user (token required, type not checked).\r\n * - `\"admin\"` / `[\"admin\", \"staff\"]` — token required AND the user's\r\n * `userType` must be one of the listed types.\r\n *\r\n * There is no anonymous/optional mode: a request without a valid access\r\n * token is always rejected with `401`. Routes that should be public\r\n * simply omit the middleware.\r\n *\r\n * @example\r\n * router.get(\"/account\", authMiddleware([]), accountController);\r\n * router.get(\"/admin\", authMiddleware(\"admin\"), adminController);\r\n * router.get(\"/back-office\", authMiddleware([\"admin\", \"staff\"]), backOfficeController);\r\n */\r\nexport function authMiddleware(allowedUserType: string | string[]) {\r\n const allowedTypes = Array.isArray(allowedUserType) ? allowedUserType : [allowedUserType];\r\n\r\n const auth: Middleware = async (request: Request, response: Response) => {\r\n try {\r\n const authorizationValue = request.authorizationValue;\r\n\r\n if (!authorizationValue) {\r\n return response.unauthorized({\r\n error: t(\"auth.errors.missingAccessToken\"),\r\n errorCode: AuthErrorCodes.MissingAccessToken,\r\n });\r\n }\r\n\r\n const decoded = await jwt.verify<DecodedAccessToken>(authorizationValue);\r\n\r\n request.decodedAccessToken = decoded;\r\n\r\n // A valid signature is not enough — the token must still exist in storage,\r\n // so deleting the row (logout) invalidates it before its JWT expiry.\r\n const AccessTokenModel = config.key(\"auth.accessToken.model\", AccessToken);\r\n const accessToken = await AccessTokenModel.findByToken(authorizationValue);\r\n\r\n if (!accessToken) {\r\n return response.unauthorized({\r\n error: t(\"auth.errors.invalidAccessToken\"),\r\n errorCode: AuthErrorCodes.InvalidAccessToken,\r\n });\r\n }\r\n\r\n // ... and the row must still be live. Existence alone was the whole check\r\n // before 4.12.0, so a row whose own `expires_at` had passed still opened\r\n // the gate. The stored expiry is now enforced, and the dead row is\r\n // removed on the way out rather than left for the cleanup command.\r\n if (accessTokenRowIsExpired(accessToken)) {\r\n await accessToken.destroy();\r\n\r\n return response.unauthorized({\r\n error: t(\"auth.errors.invalidAccessToken\"),\r\n errorCode: AuthErrorCodes.InvalidAccessToken,\r\n });\r\n }\r\n\r\n const userType = decoded.userType ?? accessToken.userType;\r\n\r\n if (allowedTypes.length && !allowedTypes.includes(userType)) {\r\n return response.unauthorized({\r\n error: t(\"auth.errors.unauthorized\"),\r\n errorCode: AuthErrorCodes.Unauthorized,\r\n });\r\n }\r\n\r\n const UserModel = config.key(`auth.userType.${userType}`);\r\n\r\n if (!UserModel) {\r\n throw new Error(`User type ${userType} is unknown type.`);\r\n }\r\n\r\n const currentUser = await UserModel.find(decoded.id);\r\n\r\n if (!currentUser) {\r\n await accessToken.destroy();\r\n\r\n return response.unauthorized({\r\n error: t(\"auth.errors.invalidAccessToken\"),\r\n errorCode: AuthErrorCodes.InvalidAccessToken,\r\n });\r\n }\r\n\r\n request.user = currentUser;\r\n } catch (error: any) {\r\n log.error(\"http\", \"auth\", error);\r\n\r\n request.clearCurrentUser();\r\n\r\n return response.unauthorized({\r\n error: t(\"auth.errors.invalidAccessToken\"),\r\n errorCode: AuthErrorCodes.InvalidAccessToken,\r\n });\r\n }\r\n };\r\n\r\n return auth;\r\n}\r\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;AA8BA,SAAS,wBAAwB,aAAmC;CAClE,OAAO,OAAO,YAAY,cAAc,YAAY,YAAY,YAAY;AAC9E;;;;;;;;;;;;;;;;;;AAmBA,SAAgB,eAAe,iBAAoC;CACjE,MAAM,eAAe,MAAM,QAAQ,eAAe,IAAI,kBAAkB,CAAC,eAAe;CAExF,MAAM,OAAmB,OAAO,SAAkB,aAAuB;EACvE,IAAI;GACF,MAAM,qBAAqB,QAAQ;GAEnC,IAAI,CAAC,oBACH,OAAO,SAAS,aAAa;IAC3B,OAAO,EAAE,gCAAgC;IACzC;GACF,CAAC;GAGH,MAAM,UAAU,MAAM,IAAI,OAA2B,kBAAkB;GAEvE,QAAQ,qBAAqB;GAK7B,MAAM,cAAc,MADK,OAAO,IAAI,0BAA0B,WACrB,CAAC,CAAC,YAAY,kBAAkB;GAEzE,IAAI,CAAC,aACH,OAAO,SAAS,aAAa;IAC3B,OAAO,EAAE,gCAAgC;IACzC;GACF,CAAC;GAOH,IAAI,wBAAwB,WAAW,GAAG;IACxC,MAAM,YAAY,QAAQ;IAE1B,OAAO,SAAS,aAAa;KAC3B,OAAO,EAAE,gCAAgC;KACzC;IACF,CAAC;GACH;GAEA,MAAM,WAAW,QAAQ,YAAY,YAAY;GAEjD,IAAI,aAAa,UAAU,CAAC,aAAa,SAAS,QAAQ,GACxD,OAAO,SAAS,aAAa;IAC3B,OAAO,EAAE,0BAA0B;IACnC;GACF,CAAC;GAGH,MAAM,YAAY,OAAO,IAAI,iBAAiB,UAAU;GAExD,IAAI,CAAC,WACH,MAAM,IAAI,MAAM,aAAa,SAAS,kBAAkB;GAG1D,MAAM,cAAc,MAAM,UAAU,KAAK,QAAQ,EAAE;GAEnD,IAAI,CAAC,aAAa;IAChB,MAAM,YAAY,QAAQ;IAE1B,OAAO,SAAS,aAAa;KAC3B,OAAO,EAAE,gCAAgC;KACzC;IACF,CAAC;GACH;GAEA,QAAQ,OAAO;EACjB,SAAS,OAAY;GACnB,IAAI,MAAM,QAAQ,QAAQ,KAAK;GAE/B,QAAQ,iBAAiB;GAEzB,OAAO,SAAS,aAAa;IAC3B,OAAO,EAAE,gCAAgC;IACzC;GACF,CAAC;EACH;CACF;CAEA,OAAO;AACT"}
@@ -28,6 +28,29 @@ declare class AccessToken extends Model {
28
28
  get userId(): any;
29
29
  /** The user-type slug this token was issued for. */
30
30
  get userType(): string;
31
+ /**
32
+ * Whether the persisted expiry has passed.
33
+ *
34
+ * The middleware asks this on every request, which is what makes the row the
35
+ * authority it always looked like it was: before 4.12.0 the gate only checked
36
+ * that the row *existed*, so a row the database knew was dead still let its
37
+ * token through — the database was never asked.
38
+ *
39
+ * **Fails closed.** A missing or unparseable `expires_at` counts as expired.
40
+ * `expires_at` is `required` in the schema, so a row that cannot answer "when
41
+ * does this die" is malformed, and the safe reading of a malformed credential
42
+ * is that it is not one. This is deliberately stricter than a naive
43
+ * `now > expires_at`, which answers `false` for an `Invalid Date` and thereby
44
+ * grants exactly the poisoned rows an unlimited life.
45
+ */
46
+ get isExpired(): boolean;
47
+ /**
48
+ * Whether nothing about this row can ever retire it — an unusable
49
+ * `expires_at`, or a token carrying no `exp` claim. See
50
+ * {@link isNeverExpiring}; this is the predicate `purgeNeverExpiring` selects
51
+ * on.
52
+ */
53
+ get neverExpires(): boolean;
31
54
  /**
32
55
  * Persist a freshly-signed access token for the user.
33
56
  */
@@ -49,6 +72,24 @@ declare class AccessToken extends Model {
49
72
  * Runs from the `auth.cleanup` CLI command (a cold batch path).
50
73
  */
51
74
  static purgeExpired(): Promise<number>;
75
+ /**
76
+ * Every row that can never retire itself — see {@link neverExpires}.
77
+ *
78
+ * **A full scan, filtered in memory, on purpose.** The defining case is a row
79
+ * whose `expires_at` is an `Invalid Date`, which no date predicate can select
80
+ * (`< now` and `> now` are both `false` for it), and the definitive case is a
81
+ * token with no `exp` claim, which lives inside the token string rather than
82
+ * in a column. Neither is expressible as a `where`, so the rows have to be
83
+ * read to be judged. This runs from a one-off remediation command, not a
84
+ * request path.
85
+ */
86
+ static findNeverExpiring(): Promise<AccessToken[]>;
87
+ /**
88
+ * Hard-delete every never-expiring row, returning the rows removed so the
89
+ * caller can report what it revoked. Deletion *is* revocation for access
90
+ * tokens — the middleware rejects a token with no row.
91
+ */
92
+ static purgeNeverExpiring(): Promise<AccessToken[]>;
52
93
  }
53
94
  //#endregion
54
95
  export { AccessToken, accessTokenSchema };
@@ -1 +1 @@
1
- {"version":3,"file":"access-token.model.d.mts","names":[],"sources":["../../../../../../../../auth/src/models/access-token/access-token.model.ts"],"mappings":";;;;;;AAYA;;;;AAKE;AAYF;cAjBa,iBAAA;;;;;;;;;;;cAiBA,WAAA,SAAoB,KAAA;EAAA,OACjB,KAAA;EAAA,OAEA,MAAA;EAAA;EAAA,IAGH,MAAA;EAKA;EAAA,IAAA,QAAA;EAOe;;;EAAA,OAAZ,KAAA,CAAM,IAAA,EAAM,IAAA,EAAM,KAAA,UAAe,SAAA,EAAW,IAAA;EAAX;;;EAAA,OAYjC,WAAA,CAAY,KAAA,WAAgB,OAAA,CAAQ,WAAA;EAAA;;;EAAA,OAOpC,aAAA,CAAc,IAAA,EAAM,IAAA,EAAM,KAAA;EAAA;;;EAAA,OAO1B,gBAAA,CAAiB,IAAA,EAAM,IAAA;EAQjB;;;AAAuB;EAAvB,OAAA,YAAA,IAAgB,OAAA;AAAA"}
1
+ {"version":3,"file":"access-token.model.d.mts","names":[],"sources":["../../../../../../../../auth/src/models/access-token/access-token.model.ts"],"mappings":";;;;;;AAaA;;;;AAKE;AAYF;cAjBa,iBAAA;;;;;;;;;;;cAiBA,WAAA,SAAoB,KAAA;EAAA,OACjB,KAAA;EAAA,OAEA,MAAA;EAHsB;EAAA,IAMzB,MAAA;EANoB;EAAA,IAWpB,QAAA;EARG;;;;;;;;;;;;;;;EAAA,IA2BH,SAAA;EAwCuB;;;;;;EAAA,IA1BvB,YAAA;EAyCyB;;;EAAA,OAlCtB,KAAA,CAAM,IAAA,EAAM,IAAA,EAAM,KAAA,UAAe,SAAA,EAAW,IAAA;EAkEtC;;;EAAA,OAtDN,WAAA,CAAY,KAAA,WAAgB,OAAA,CAAQ,WAAA;EAsDW;;;EAAA,OA/C/C,aAAA,CAAc,IAAA,EAAM,IAAA,EAAM,KAAA;;;;SAO1B,gBAAA,CAAiB,IAAA,EAAM,IAAA;;;;;SAQjB,YAAA,IAAgB,OAAA;;;;;;;;;;;;SAqBhB,iBAAA,IAAqB,OAAA,CAAQ,WAAA;;;;;;SAW7B,kBAAA,IAAsB,OAAA,CAAQ,WAAA;AAAA"}
@@ -1,3 +1,4 @@
1
+ import { isNeverExpiring, isUsableExpiry } from "../../utils/token-expiry.mjs";
1
2
  import { Model } from "@warlock.js/cascade";
2
3
  import { v } from "@warlock.js/seal";
3
4
 
@@ -42,6 +43,35 @@ var AccessToken = class extends Model {
42
43
  return this.get("user_type");
43
44
  }
44
45
  /**
46
+ * Whether the persisted expiry has passed.
47
+ *
48
+ * The middleware asks this on every request, which is what makes the row the
49
+ * authority it always looked like it was: before 4.12.0 the gate only checked
50
+ * that the row *existed*, so a row the database knew was dead still let its
51
+ * token through — the database was never asked.
52
+ *
53
+ * **Fails closed.** A missing or unparseable `expires_at` counts as expired.
54
+ * `expires_at` is `required` in the schema, so a row that cannot answer "when
55
+ * does this die" is malformed, and the safe reading of a malformed credential
56
+ * is that it is not one. This is deliberately stricter than a naive
57
+ * `now > expires_at`, which answers `false` for an `Invalid Date` and thereby
58
+ * grants exactly the poisoned rows an unlimited life.
59
+ */
60
+ get isExpired() {
61
+ const expiresAt = this.get("expires_at");
62
+ if (!isUsableExpiry(expiresAt)) return true;
63
+ return new Date(expiresAt).getTime() <= Date.now();
64
+ }
65
+ /**
66
+ * Whether nothing about this row can ever retire it — an unusable
67
+ * `expires_at`, or a token carrying no `exp` claim. See
68
+ * {@link isNeverExpiring}; this is the predicate `purgeNeverExpiring` selects
69
+ * on.
70
+ */
71
+ get neverExpires() {
72
+ return isNeverExpiring(this.get("token"), this.get("expires_at"));
73
+ }
74
+ /**
45
75
  * Persist a freshly-signed access token for the user.
46
76
  */
47
77
  static issue(user, token, expiresAt) {
@@ -82,6 +112,30 @@ var AccessToken = class extends Model {
82
112
  for (const token of expiredTokens) await token.destroy();
83
113
  return expiredTokens.length;
84
114
  }
115
+ /**
116
+ * Every row that can never retire itself — see {@link neverExpires}.
117
+ *
118
+ * **A full scan, filtered in memory, on purpose.** The defining case is a row
119
+ * whose `expires_at` is an `Invalid Date`, which no date predicate can select
120
+ * (`< now` and `> now` are both `false` for it), and the definitive case is a
121
+ * token with no `exp` claim, which lives inside the token string rather than
122
+ * in a column. Neither is expressible as a `where`, so the rows have to be
123
+ * read to be judged. This runs from a one-off remediation command, not a
124
+ * request path.
125
+ */
126
+ static async findNeverExpiring() {
127
+ return (await this.query().get()).filter((token) => token.neverExpires);
128
+ }
129
+ /**
130
+ * Hard-delete every never-expiring row, returning the rows removed so the
131
+ * caller can report what it revoked. Deletion *is* revocation for access
132
+ * tokens — the middleware rejects a token with no row.
133
+ */
134
+ static async purgeNeverExpiring() {
135
+ const tokens = await this.findNeverExpiring();
136
+ for (const token of tokens) await token.destroy();
137
+ return tokens;
138
+ }
85
139
  };
86
140
 
87
141
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"access-token.model.mjs","names":[],"sources":["../../../../../../../../auth/src/models/access-token/access-token.model.ts"],"sourcesContent":["import { Model } from \"@warlock.js/cascade\";\r\nimport { v } from \"@warlock.js/seal\";\r\nimport type { Auth } from \"../auth.model\";\r\n\r\n/**\r\n * Seal schema for the persisted access-token record. Exported so an override\r\n * can spread it and add columns (e.g. a tenant key) without re-declaring the\r\n * base shape.\r\n *\r\n * `last_access` and the `is_active` soft-revoke flag were removed — neither was\r\n * ever read; access tokens are revoked by deleting the row.\r\n */\r\nexport const accessTokenSchema = v.object({\r\n token: v.string().required(),\r\n user_id: v.scalar().required(),\r\n user_type: v.string().required(),\r\n expires_at: v.date().required(),\r\n});\r\n\r\n/**\r\n * Persisted access-token record + the data layer for access tokens.\r\n *\r\n * **Role.** Owns access-token persistence and lookup. The middleware checks a\r\n * presented JWT against this table so deleting a row (logout) invalidates the\r\n * token immediately, before its JWT expiry. The auth service goes through the\r\n * named statics exclusively, so it never hard-codes a column name and an\r\n * override can rename/add columns by registering under\r\n * `config.auth.accessToken.model`.\r\n */\r\nexport class AccessToken extends Model {\r\n public static table = \"access_tokens\";\r\n\r\n public static schema = accessTokenSchema;\r\n\r\n /** The user this token was issued for. */\r\n public get userId() {\r\n return this.get(\"user_id\");\r\n }\r\n\r\n /** The user-type slug this token was issued for. */\r\n public get userType(): string {\r\n return this.get(\"user_type\");\r\n }\r\n\r\n /**\r\n * Persist a freshly-signed access token for the user.\r\n */\r\n public static issue(user: Auth, token: string, expiresAt: Date) {\r\n return this.create({\r\n token,\r\n user_id: user.id,\r\n user_type: user.userType,\r\n expires_at: expiresAt,\r\n });\r\n }\r\n\r\n /**\r\n * Find an access-token row by its raw token string.\r\n */\r\n public static findByToken(token: string): Promise<AccessToken | null> {\r\n return this.first({ token });\r\n }\r\n\r\n /**\r\n * Delete a specific token that belongs to the given user.\r\n */\r\n public static deleteForUser(user: Auth, token: string) {\r\n return this.delete({ token, user_id: user.id });\r\n }\r\n\r\n /**\r\n * Delete every access token belonging to the user.\r\n */\r\n public static deleteAllForUser(user: Auth) {\r\n return this.delete({ user_id: user.id });\r\n }\r\n\r\n /**\r\n * Hard-delete every expired access-token row. Returns the number removed.\r\n * Runs from the `auth.cleanup` CLI command (a cold batch path).\r\n */\r\n public static async purgeExpired(): Promise<number> {\r\n const expiredTokens = await this.query().where(\"expires_at\", \"<\", new Date()).get();\r\n\r\n for (const token of expiredTokens) {\r\n await token.destroy();\r\n }\r\n\r\n return expiredTokens.length;\r\n }\r\n}\r\n"],"mappings":";;;;;;;;;;;;AAYA,MAAa,oBAAoB,EAAE,OAAO;CACxC,OAAO,EAAE,OAAO,CAAC,CAAC,SAAS;CAC3B,SAAS,EAAE,OAAO,CAAC,CAAC,SAAS;CAC7B,WAAW,EAAE,OAAO,CAAC,CAAC,SAAS;CAC/B,YAAY,EAAE,KAAK,CAAC,CAAC,SAAS;AAChC,CAAC;;;;;;;;;;;AAYD,IAAa,cAAb,cAAiC,MAAM;;eACf;;;gBAEC;;;CAGvB,IAAW,SAAS;EAClB,OAAO,KAAK,IAAI,SAAS;CAC3B;;CAGA,IAAW,WAAmB;EAC5B,OAAO,KAAK,IAAI,WAAW;CAC7B;;;;CAKA,OAAc,MAAM,MAAY,OAAe,WAAiB;EAC9D,OAAO,KAAK,OAAO;GACjB;GACA,SAAS,KAAK;GACd,WAAW,KAAK;GAChB,YAAY;EACd,CAAC;CACH;;;;CAKA,OAAc,YAAY,OAA4C;EACpE,OAAO,KAAK,MAAM,EAAE,MAAM,CAAC;CAC7B;;;;CAKA,OAAc,cAAc,MAAY,OAAe;EACrD,OAAO,KAAK,OAAO;GAAE;GAAO,SAAS,KAAK;EAAG,CAAC;CAChD;;;;CAKA,OAAc,iBAAiB,MAAY;EACzC,OAAO,KAAK,OAAO,EAAE,SAAS,KAAK,GAAG,CAAC;CACzC;;;;;CAMA,aAAoB,eAAgC;EAClD,MAAM,gBAAgB,MAAM,KAAK,MAAM,CAAC,CAAC,MAAM,cAAc,qBAAK,IAAI,KAAK,CAAC,CAAC,CAAC,IAAI;EAElF,KAAK,MAAM,SAAS,eAClB,MAAM,MAAM,QAAQ;EAGtB,OAAO,cAAc;CACvB;AACF"}
1
+ {"version":3,"file":"access-token.model.mjs","names":[],"sources":["../../../../../../../../auth/src/models/access-token/access-token.model.ts"],"sourcesContent":["import { Model } from \"@warlock.js/cascade\";\r\nimport { v } from \"@warlock.js/seal\";\r\nimport { isNeverExpiring, isUsableExpiry } from \"../../utils/token-expiry\";\r\nimport type { Auth } from \"../auth.model\";\r\n\r\n/**\r\n * Seal schema for the persisted access-token record. Exported so an override\r\n * can spread it and add columns (e.g. a tenant key) without re-declaring the\r\n * base shape.\r\n *\r\n * `last_access` and the `is_active` soft-revoke flag were removed — neither was\r\n * ever read; access tokens are revoked by deleting the row.\r\n */\r\nexport const accessTokenSchema = v.object({\r\n token: v.string().required(),\r\n user_id: v.scalar().required(),\r\n user_type: v.string().required(),\r\n expires_at: v.date().required(),\r\n});\r\n\r\n/**\r\n * Persisted access-token record + the data layer for access tokens.\r\n *\r\n * **Role.** Owns access-token persistence and lookup. The middleware checks a\r\n * presented JWT against this table so deleting a row (logout) invalidates the\r\n * token immediately, before its JWT expiry. The auth service goes through the\r\n * named statics exclusively, so it never hard-codes a column name and an\r\n * override can rename/add columns by registering under\r\n * `config.auth.accessToken.model`.\r\n */\r\nexport class AccessToken extends Model {\r\n public static table = \"access_tokens\";\r\n\r\n public static schema = accessTokenSchema;\r\n\r\n /** The user this token was issued for. */\r\n public get userId() {\r\n return this.get(\"user_id\");\r\n }\r\n\r\n /** The user-type slug this token was issued for. */\r\n public get userType(): string {\r\n return this.get(\"user_type\");\r\n }\r\n\r\n /**\r\n * Whether the persisted expiry has passed.\r\n *\r\n * The middleware asks this on every request, which is what makes the row the\r\n * authority it always looked like it was: before 4.12.0 the gate only checked\r\n * that the row *existed*, so a row the database knew was dead still let its\r\n * token through — the database was never asked.\r\n *\r\n * **Fails closed.** A missing or unparseable `expires_at` counts as expired.\r\n * `expires_at` is `required` in the schema, so a row that cannot answer \"when\r\n * does this die\" is malformed, and the safe reading of a malformed credential\r\n * is that it is not one. This is deliberately stricter than a naive\r\n * `now > expires_at`, which answers `false` for an `Invalid Date` and thereby\r\n * grants exactly the poisoned rows an unlimited life.\r\n */\r\n public get isExpired(): boolean {\r\n const expiresAt = this.get(\"expires_at\");\r\n\r\n if (!isUsableExpiry(expiresAt)) return true;\r\n\r\n return new Date(expiresAt).getTime() <= Date.now();\r\n }\r\n\r\n /**\r\n * Whether nothing about this row can ever retire it — an unusable\r\n * `expires_at`, or a token carrying no `exp` claim. See\r\n * {@link isNeverExpiring}; this is the predicate `purgeNeverExpiring` selects\r\n * on.\r\n */\r\n public get neverExpires(): boolean {\r\n return isNeverExpiring(this.get(\"token\"), this.get(\"expires_at\"));\r\n }\r\n\r\n /**\r\n * Persist a freshly-signed access token for the user.\r\n */\r\n public static issue(user: Auth, token: string, expiresAt: Date) {\r\n return this.create({\r\n token,\r\n user_id: user.id,\r\n user_type: user.userType,\r\n expires_at: expiresAt,\r\n });\r\n }\r\n\r\n /**\r\n * Find an access-token row by its raw token string.\r\n */\r\n public static findByToken(token: string): Promise<AccessToken | null> {\r\n return this.first({ token });\r\n }\r\n\r\n /**\r\n * Delete a specific token that belongs to the given user.\r\n */\r\n public static deleteForUser(user: Auth, token: string) {\r\n return this.delete({ token, user_id: user.id });\r\n }\r\n\r\n /**\r\n * Delete every access token belonging to the user.\r\n */\r\n public static deleteAllForUser(user: Auth) {\r\n return this.delete({ user_id: user.id });\r\n }\r\n\r\n /**\r\n * Hard-delete every expired access-token row. Returns the number removed.\r\n * Runs from the `auth.cleanup` CLI command (a cold batch path).\r\n */\r\n public static async purgeExpired(): Promise<number> {\r\n const expiredTokens = await this.query().where(\"expires_at\", \"<\", new Date()).get();\r\n\r\n for (const token of expiredTokens) {\r\n await token.destroy();\r\n }\r\n\r\n return expiredTokens.length;\r\n }\r\n\r\n /**\r\n * Every row that can never retire itself — see {@link neverExpires}.\r\n *\r\n * **A full scan, filtered in memory, on purpose.** The defining case is a row\r\n * whose `expires_at` is an `Invalid Date`, which no date predicate can select\r\n * (`< now` and `> now` are both `false` for it), and the definitive case is a\r\n * token with no `exp` claim, which lives inside the token string rather than\r\n * in a column. Neither is expressible as a `where`, so the rows have to be\r\n * read to be judged. This runs from a one-off remediation command, not a\r\n * request path.\r\n */\r\n public static async findNeverExpiring(): Promise<AccessToken[]> {\r\n const tokens = await this.query().get();\r\n\r\n return tokens.filter((token: AccessToken) => token.neverExpires);\r\n }\r\n\r\n /**\r\n * Hard-delete every never-expiring row, returning the rows removed so the\r\n * caller can report what it revoked. Deletion *is* revocation for access\r\n * tokens — the middleware rejects a token with no row.\r\n */\r\n public static async purgeNeverExpiring(): Promise<AccessToken[]> {\r\n const tokens = await this.findNeverExpiring();\r\n\r\n for (const token of tokens) {\r\n await token.destroy();\r\n }\r\n\r\n return tokens;\r\n }\r\n}\r\n"],"mappings":";;;;;;;;;;;;;AAaA,MAAa,oBAAoB,EAAE,OAAO;CACxC,OAAO,EAAE,OAAO,CAAC,CAAC,SAAS;CAC3B,SAAS,EAAE,OAAO,CAAC,CAAC,SAAS;CAC7B,WAAW,EAAE,OAAO,CAAC,CAAC,SAAS;CAC/B,YAAY,EAAE,KAAK,CAAC,CAAC,SAAS;AAChC,CAAC;;;;;;;;;;;AAYD,IAAa,cAAb,cAAiC,MAAM;;eACf;;;gBAEC;;;CAGvB,IAAW,SAAS;EAClB,OAAO,KAAK,IAAI,SAAS;CAC3B;;CAGA,IAAW,WAAmB;EAC5B,OAAO,KAAK,IAAI,WAAW;CAC7B;;;;;;;;;;;;;;;;CAiBA,IAAW,YAAqB;EAC9B,MAAM,YAAY,KAAK,IAAI,YAAY;EAEvC,IAAI,CAAC,eAAe,SAAS,GAAG,OAAO;EAEvC,OAAO,IAAI,KAAK,SAAS,CAAC,CAAC,QAAQ,KAAK,KAAK,IAAI;CACnD;;;;;;;CAQA,IAAW,eAAwB;EACjC,OAAO,gBAAgB,KAAK,IAAI,OAAO,GAAG,KAAK,IAAI,YAAY,CAAC;CAClE;;;;CAKA,OAAc,MAAM,MAAY,OAAe,WAAiB;EAC9D,OAAO,KAAK,OAAO;GACjB;GACA,SAAS,KAAK;GACd,WAAW,KAAK;GAChB,YAAY;EACd,CAAC;CACH;;;;CAKA,OAAc,YAAY,OAA4C;EACpE,OAAO,KAAK,MAAM,EAAE,MAAM,CAAC;CAC7B;;;;CAKA,OAAc,cAAc,MAAY,OAAe;EACrD,OAAO,KAAK,OAAO;GAAE;GAAO,SAAS,KAAK;EAAG,CAAC;CAChD;;;;CAKA,OAAc,iBAAiB,MAAY;EACzC,OAAO,KAAK,OAAO,EAAE,SAAS,KAAK,GAAG,CAAC;CACzC;;;;;CAMA,aAAoB,eAAgC;EAClD,MAAM,gBAAgB,MAAM,KAAK,MAAM,CAAC,CAAC,MAAM,cAAc,qBAAK,IAAI,KAAK,CAAC,CAAC,CAAC,IAAI;EAElF,KAAK,MAAM,SAAS,eAClB,MAAM,MAAM,QAAQ;EAGtB,OAAO,cAAc;CACvB;;;;;;;;;;;;CAaA,aAAoB,oBAA4C;EAG9D,QAAO,MAFc,KAAK,MAAM,CAAC,CAAC,IAAI,EAEzB,CAAC,QAAQ,UAAuB,MAAM,YAAY;CACjE;;;;;;CAOA,aAAoB,qBAA6C;EAC/D,MAAM,SAAS,MAAM,KAAK,kBAAkB;EAE5C,KAAK,MAAM,SAAS,QAClB,MAAM,MAAM,QAAQ;EAGtB,OAAO;CACT;AACF"}
@@ -39,8 +39,22 @@ declare class RefreshToken extends Model {
39
39
  static schema: any;
40
40
  /** Token family this row belongs to (rotation / replay grouping). */
41
41
  get familyId(): string;
42
- /** Whether the token's `expires_at` is in the past. */
42
+ /**
43
+ * Whether the token's `expires_at` is in the past.
44
+ *
45
+ * **Fails closed as of 4.12.0**: a missing or unparseable `expires_at` now
46
+ * counts as expired. It previously answered `false` for both — "no expiry
47
+ * recorded ⇒ never expires" — which handed an unlimited life to precisely the
48
+ * malformed rows, including the `Invalid Date` a pre-4.12.0 unparseable
49
+ * `expiresIn` could write. `expires_at` is `required` in the schema, so a row
50
+ * that cannot say when it dies is malformed, not immortal.
51
+ */
43
52
  get isExpired(): boolean;
53
+ /**
54
+ * Whether nothing can ever retire this row — an unusable `expires_at`, or a
55
+ * token carrying no `exp` claim. See {@link isNeverExpiring}.
56
+ */
57
+ get neverExpires(): boolean;
44
58
  /** Whether the token has been revoked. */
45
59
  get isRevoked(): boolean;
46
60
  /** Whether the token is still usable (not expired and not revoked). */
@@ -108,6 +122,23 @@ declare class RefreshToken extends Model {
108
122
  * (a cold batch path).
109
123
  */
110
124
  static purgeExpired(): Promise<RefreshToken[]>;
125
+ /**
126
+ * Every row that can never retire itself — see {@link neverExpires}. A full
127
+ * scan filtered in memory, for the reason given on
128
+ * {@link AccessToken.findNeverExpiring}: neither an `Invalid Date` nor a
129
+ * missing `exp` claim is expressible as a `where`.
130
+ */
131
+ static findNeverExpiring(): Promise<RefreshToken[]>;
132
+ /**
133
+ * Hard-delete every never-expiring row, returning the rows removed.
134
+ *
135
+ * Deleted rather than `revoked_at`-stamped: a revoked row is still a row this
136
+ * table has to carry, and one of the two shapes being removed is a row whose
137
+ * date column cannot be compared at all — leaving it in place keeps a
138
+ * permanently unpurgeable record. Rotation replay-detection is unaffected;
139
+ * these rows can no longer be presented successfully either way.
140
+ */
141
+ static purgeNeverExpiring(): Promise<RefreshToken[]>;
111
142
  }
112
143
  //#endregion
113
144
  export { RefreshToken, RefreshTokenIssueOptions, refreshTokenSchema };