@warlock.js/auth 4.1.15 → 4.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/LICENSE +21 -0
  3. package/README.md +78 -37
  4. package/esm/commands/auth-cleanup-command.d.mts +1 -1
  5. package/esm/commands/auth-cleanup-command.d.mts.map +1 -1
  6. package/esm/commands/auth-cleanup-command.mjs +1 -1
  7. package/esm/commands/auth-cleanup-command.mjs.map +1 -1
  8. package/esm/commands/jwt-secret-generator-command.d.mts +1 -1
  9. package/esm/commands/jwt-secret-generator-command.d.mts.map +1 -1
  10. package/esm/commands/jwt-secret-generator-command.mjs +1 -1
  11. package/esm/commands/jwt-secret-generator-command.mjs.map +1 -1
  12. package/esm/contracts/auth-contract.d.mts +2 -2
  13. package/esm/contracts/auth-contract.d.mts.map +1 -1
  14. package/esm/contracts/index.d.mts +1 -1
  15. package/esm/contracts/types.d.mts +105 -76
  16. package/esm/contracts/types.d.mts.map +1 -1
  17. package/esm/contracts/types.mjs +2 -2
  18. package/esm/contracts/types.mjs.map +1 -1
  19. package/esm/index.d.mts +6 -5
  20. package/esm/index.mjs +4 -3
  21. package/esm/middleware/auth.middleware.d.mts +1 -1
  22. package/esm/middleware/auth.middleware.d.mts.map +1 -1
  23. package/esm/middleware/auth.middleware.mjs +9 -9
  24. package/esm/middleware/auth.middleware.mjs.map +1 -1
  25. package/esm/middleware/index.d.mts +2 -0
  26. package/esm/middleware/index.mjs +1 -0
  27. package/esm/middleware/login-throttle.middleware.d.mts +116 -0
  28. package/esm/middleware/login-throttle.middleware.d.mts.map +1 -0
  29. package/esm/middleware/login-throttle.middleware.mjs +105 -0
  30. package/esm/middleware/login-throttle.middleware.mjs.map +1 -0
  31. package/esm/models/access-token/access-token.model.d.mts +47 -5
  32. package/esm/models/access-token/access-token.model.d.mts.map +1 -1
  33. package/esm/models/access-token/access-token.model.mjs +70 -4
  34. package/esm/models/access-token/access-token.model.mjs.map +1 -1
  35. package/esm/models/access-token/index.d.mts +1 -1
  36. package/esm/models/access-token/index.mjs +1 -1
  37. package/esm/models/access-token/migration.mjs +2 -3
  38. package/esm/models/access-token/migration.mjs.map +1 -1
  39. package/esm/models/auth.model.d.mts +2 -2
  40. package/esm/models/auth.model.d.mts.map +1 -1
  41. package/esm/models/auth.model.mjs +1 -1
  42. package/esm/models/auth.model.mjs.map +1 -1
  43. package/esm/models/index.d.mts +4 -3
  44. package/esm/models/index.d.mts.map +1 -1
  45. package/esm/models/index.mjs +3 -3
  46. package/esm/models/index.mjs.map +1 -1
  47. package/esm/models/refresh-token/index.d.mts +1 -1
  48. package/esm/models/refresh-token/index.mjs +1 -1
  49. package/esm/models/refresh-token/migration.mjs +2 -1
  50. package/esm/models/refresh-token/migration.mjs.map +1 -1
  51. package/esm/models/refresh-token/refresh-token.model.d.mts +94 -16
  52. package/esm/models/refresh-token/refresh-token.model.d.mts.map +1 -1
  53. package/esm/models/refresh-token/refresh-token.model.mjs +152 -13
  54. package/esm/models/refresh-token/refresh-token.model.mjs.map +1 -1
  55. package/esm/services/auth-config.mjs +63 -0
  56. package/esm/services/auth-config.mjs.map +1 -0
  57. package/esm/services/auth-events.d.mts +2 -2
  58. package/esm/services/auth-events.d.mts.map +1 -1
  59. package/esm/services/auth-events.mjs +7 -2
  60. package/esm/services/auth-events.mjs.map +1 -1
  61. package/esm/services/auth.service.d.mts +48 -30
  62. package/esm/services/auth.service.d.mts.map +1 -1
  63. package/esm/services/auth.service.mjs +88 -102
  64. package/esm/services/auth.service.mjs.map +1 -1
  65. package/esm/services/generate-jwt-secret.d.mts +1 -1
  66. package/esm/services/generate-jwt-secret.d.mts.map +1 -1
  67. package/esm/services/generate-jwt-secret.mjs +4 -4
  68. package/esm/services/generate-jwt-secret.mjs.map +1 -1
  69. package/esm/services/index.d.mts +1 -1
  70. package/esm/services/jwt.d.mts +13 -4
  71. package/esm/services/jwt.d.mts.map +1 -1
  72. package/esm/services/jwt.mjs +30 -11
  73. package/esm/services/jwt.mjs.map +1 -1
  74. package/esm/utils/auth-error-codes.d.mts +9 -2
  75. package/esm/utils/auth-error-codes.d.mts.map +1 -1
  76. package/esm/utils/auth-error-codes.mjs +7 -1
  77. package/esm/utils/auth-error-codes.mjs.map +1 -1
  78. package/llms-full.txt +269 -20
  79. package/llms.txt +2 -0
  80. package/package.json +9 -11
  81. package/skills/auth-basics/SKILL.md +8 -7
  82. package/skills/customize-token-storage/SKILL.md +112 -0
  83. package/skills/customize-user-type/SKILL.md +7 -2
  84. package/skills/handle-login-and-logout/SKILL.md +1 -1
  85. package/skills/manage-tokens/SKILL.md +9 -9
  86. package/skills/overview/SKILL.md +7 -1
  87. package/skills/protect-routes/SKILL.md +1 -0
  88. package/skills/run-auth-commands/SKILL.md +1 -1
  89. package/skills/throttle-login-attempts/SKILL.md +116 -0
  90. package/cjs/index.cjs +0 -807
  91. package/cjs/index.cjs.map +0 -1
@@ -1,6 +1,15 @@
1
1
  import { SignerOptions, VerifierOptions } from "fast-jwt";
2
2
 
3
- //#region ../../@warlock.js/auth/src/services/jwt.d.ts
3
+ //#region ../@warlock.js/auth/src/services/jwt.d.ts
4
+ /**
5
+ * Token class. Stamped as the `tokenType` claim on every signed token and
6
+ * checked on verify so an access token can never be accepted where a refresh
7
+ * token is expected (and vice versa) — even when both share the same secret
8
+ * under the documented refresh-secret fallback. Legacy tokens minted before
9
+ * this claim existed carry no `tokenType` and remain accepted; a *mismatched*
10
+ * type is always rejected.
11
+ */
12
+ type TokenType = "access" | "refresh";
4
13
  declare const jwt: {
5
14
  /**
6
15
  * Generate a new JWT token for the user.
@@ -18,7 +27,7 @@ declare const jwt: {
18
27
  * @param token The JWT token to verify.
19
28
  * @returns The decoded token payload if verification is successful.
20
29
  */
21
- verify<T = any>(token: string, {
30
+ verify<T = unknown>(token: string, {
22
31
  key,
23
32
  algorithms,
24
33
  ...options
@@ -39,7 +48,7 @@ declare const jwt: {
39
48
  /**
40
49
  * Verify the given refresh token.
41
50
  */
42
- verifyRefreshToken<T = any>(token: string, {
51
+ verifyRefreshToken<T = unknown>(token: string, {
43
52
  key,
44
53
  algorithms,
45
54
  ...options
@@ -48,5 +57,5 @@ declare const jwt: {
48
57
  }): Promise<T>;
49
58
  };
50
59
  //#endregion
51
- export { jwt };
60
+ export { TokenType, jwt };
52
61
  //# sourceMappingURL=jwt.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"jwt.d.mts","names":[],"sources":["../../../../../../@warlock.js/auth/src/services/jwt.ts"],"mappings":";;;cA0Ba,GAAA;;AAAb;;;;IAMgB,GAAA;IAAA,SAAA;IAAA,GAAA;EAAA,IAKT,aAAA;IAAkB,GAAA;EAAA,IACpB,OAAA;EAAA;;;;;kBAaW,KAAA;IACC,GAAA;IAAA,UAAA;IAAA,GAAA;EAAA,IAKV,eAAA;IAAoB,GAAA;EAAA,IACtB,OAAA,CAAQ,CAAA;EAUG;;;;IAAA,GAAA;IAAA,SAAA;IAAA,SAAA;IAAA,GAAA;EAAA,IAMT,aAAA;IAAkB,GAAA;EAAA,IACpB,OAAA;EAeQ;;;8BAPe,KAAA;IACX,GAAA;IAAA,UAAA;IAAA,GAAA;EAAA,IAKV,eAAA;IAAoB,GAAA;EAAA,IACtB,OAAA,CAAQ,CAAA;AAAA"}
1
+ {"version":3,"file":"jwt.d.mts","names":[],"sources":["../../../../../../../@warlock.js/auth/src/services/jwt.ts"],"mappings":";;;;;AAkBA;;;;AAAqB;AAiBrB;KAjBY,SAAA;AAAA,cAiBC,GAAA;EAMG;;;;;IAAA,GAAA;IAAA,SAAA;IAAA,GAAA;EAAA,IAKT,aAAA;IAAkB,GAAA;EAAA,IACpB,OAAA;EAoBQ;;;;;sBAPG,KAAA;IACC,GAAA;IAAA,UAAA;IAAA,GAAA;EAAA,IAKV,eAAA;IAAoB,GAAA;EAAA,IACtB,OAAA,CAAQ,CAAA;EA8BI;;;;IAhBD,GAAA;IAAA,SAAA;IAAA,SAAA;IAAA,GAAA;EAAA,IAMT,aAAA;IAAkB,GAAA;EAAA,IACpB,OAAA;;;;kCAQuB,KAAA;IACX,GAAA;IAAA,UAAA;IAAA,GAAA;EAAA,IAKV,eAAA;IAAoB,GAAA;EAAA,IACtB,OAAA,CAAQ,CAAA;AAAA"}
@@ -1,11 +1,20 @@
1
- import { config } from "@warlock.js/core";
2
- import ms from "ms";
1
+ import { authConfig } from "./auth-config.mjs";
3
2
  import { createSigner, createVerifier } from "fast-jwt";
4
3
 
5
- //#region ../../@warlock.js/auth/src/services/jwt.ts
6
- const getSecretKey = () => config.key("auth.jwt.secret");
7
- const getAlgorithm = () => config.key("auth.jwt.algorithm", "HS256");
8
- const getRefreshSecretKey = () => config.key("auth.jwt.refresh.secret") || getSecretKey();
4
+ //#region ../@warlock.js/auth/src/services/jwt.ts
5
+ const getSecretKey = () => authConfig.accessToken.secret();
6
+ const getAlgorithm = () => authConfig.accessToken.algorithm();
7
+ const getRefreshSecretKey = () => authConfig.refreshToken.secret() || getSecretKey();
8
+ const ACCESS_TOKEN_TYPE = "access";
9
+ const REFRESH_TOKEN_TYPE = "refresh";
10
+ /**
11
+ * Reject the token when its `tokenType` claim is present and does not match the
12
+ * expected class. Absent claim ⇒ legacy token, accepted (backward compatible).
13
+ */
14
+ function assertTokenType(decoded, expected) {
15
+ const actual = decoded?.tokenType;
16
+ if (typeof actual === "string" && actual !== expected) throw new Error(`Invalid token type: expected "${expected}", received "${actual}".`);
17
+ }
9
18
  const jwt = {
10
19
  /**
11
20
  * Generate a new JWT token for the user.
@@ -16,19 +25,24 @@ const jwt = {
16
25
  key,
17
26
  ...options,
18
27
  algorithm
19
- })({ ...payload });
28
+ })({
29
+ ...payload,
30
+ tokenType: ACCESS_TOKEN_TYPE
31
+ });
20
32
  },
21
33
  /**
22
34
  * Verify the given token.
23
35
  * @param token The JWT token to verify.
24
36
  * @returns The decoded token payload if verification is successful.
25
37
  */
26
- async verify(token, { key = getSecretKey(), algorithms = getAlgorithm() ? [getAlgorithm()] : void 0, ...options } = {}) {
27
- return await createVerifier({
38
+ async verify(token, { key = getSecretKey(), algorithms = [getAlgorithm()], ...options } = {}) {
39
+ const decoded = await createVerifier({
28
40
  key,
29
41
  ...options,
30
42
  algorithms
31
43
  })(token);
44
+ assertTokenType(decoded, ACCESS_TOKEN_TYPE);
45
+ return decoded;
32
46
  },
33
47
  /**
34
48
  * Generate a new refresh token for the user.
@@ -39,17 +53,22 @@ const jwt = {
39
53
  expiresIn,
40
54
  algorithm,
41
55
  ...options
42
- })({ ...payload });
56
+ })({
57
+ ...payload,
58
+ tokenType: REFRESH_TOKEN_TYPE
59
+ });
43
60
  },
44
61
  /**
45
62
  * Verify the given refresh token.
46
63
  */
47
64
  async verifyRefreshToken(token, { key = getRefreshSecretKey(), algorithms = [getAlgorithm()], ...options } = {}) {
48
- return await createVerifier({
65
+ const decoded = await createVerifier({
49
66
  key,
50
67
  algorithms,
51
68
  ...options
52
69
  })(token);
70
+ assertTokenType(decoded, REFRESH_TOKEN_TYPE);
71
+ return decoded;
53
72
  }
54
73
  };
55
74
 
@@ -1 +1 @@
1
- {"version":3,"file":"jwt.mjs","names":[],"sources":["../../../../../../@warlock.js/auth/src/services/jwt.ts"],"sourcesContent":["import { config } from \"@warlock.js/core\";\r\nimport {\r\n createSigner,\r\n createVerifier,\r\n type Algorithm,\r\n type SignerOptions,\r\n type VerifierOptions,\r\n} from \"fast-jwt\";\r\nimport ms from \"ms\";\r\n\r\nconst getSecretKey = () => config.key(\"auth.jwt.secret\") as string;\r\nconst getAlgorithm = () => config.key(\"auth.jwt.algorithm\", \"HS256\") as Algorithm;\r\n\r\n// Refresh tokens may declare their own secret. When `auth.jwt.refresh.secret`\r\n// is unset/empty we fall back to the main JWT secret, matching the documented\r\n// optional behavior in `contracts/types.ts`.\r\nconst getRefreshSecretKey = () =>\r\n (config.key(\"auth.jwt.refresh.secret\") || getSecretKey()) as string;\r\n// Refresh token validity defaults to 7d when not configured. Opt in to\r\n// no-expiry semantics with `NO_EXPIRATION` (100y) from `contracts/types.ts`.\r\nconst getRefreshTokenValidity = () => {\r\n const expiresIn = config.key(\"auth.jwt.refresh.expiresIn\") || \"7d\";\r\n\r\n return ms(expiresIn);\r\n};\r\n\r\nexport const jwt = {\r\n /**\r\n * Generate a new JWT token for the user.\r\n * @param payload The payload to encode in the JWT token.\r\n */\r\n async generate(\r\n payload: any,\r\n {\r\n key = getSecretKey(),\r\n algorithm = getAlgorithm(),\r\n ...options\r\n }: SignerOptions & { key?: string } = {},\r\n ): Promise<string> {\r\n // Create a signer function with predefined options\r\n const sign = createSigner({ key, ...options, algorithm });\r\n\r\n const token = await sign({ ...payload });\r\n return token;\r\n },\r\n\r\n /**\r\n * Verify the given token.\r\n * @param token The JWT token to verify.\r\n * @returns The decoded token payload if verification is successful.\r\n */\r\n async verify<T = any>(\r\n token: string,\r\n {\r\n key = getSecretKey(),\r\n algorithms = getAlgorithm() ? [getAlgorithm()] : undefined,\r\n ...options\r\n }: VerifierOptions & { key?: string } = {},\r\n ): Promise<T> {\r\n const verify = createVerifier({ key, ...options, algorithms });\r\n\r\n return await verify(token as string);\r\n },\r\n\r\n /**\r\n * Generate a new refresh token for the user.\r\n */\r\n async generateRefreshToken(\r\n payload: any,\r\n {\r\n key = getRefreshSecretKey(),\r\n expiresIn,\r\n algorithm = getAlgorithm(),\r\n ...options\r\n }: SignerOptions & { key?: string } = {},\r\n ): Promise<string> {\r\n const sign = createSigner({ key, expiresIn, algorithm, ...options });\r\n return sign({ ...payload });\r\n },\r\n\r\n /**\r\n * Verify the given refresh token.\r\n */\r\n async verifyRefreshToken<T = any>(\r\n token: string,\r\n {\r\n key = getRefreshSecretKey(),\r\n algorithms = [getAlgorithm()],\r\n ...options\r\n }: VerifierOptions & { key?: string } = {},\r\n ): Promise<T> {\r\n const verify = createVerifier({ key, algorithms, ...options });\r\n return await verify(token);\r\n },\r\n};\r\n"],"mappings":";;;;;AAUA,MAAM,qBAAqB,OAAO,IAAI,iBAAiB;AACvD,MAAM,qBAAqB,OAAO,IAAI,sBAAsB,OAAO;AAKnE,MAAM,4BACH,OAAO,IAAI,yBAAyB,KAAK,aAAa;AASzD,MAAa,MAAM;;;;;CAKjB,MAAM,SACJ,SACA,EACE,MAAM,aAAa,GACnB,YAAY,aAAa,GACzB,GAAG,YACiC,CAAC,GACtB;EAKjB,OAAO,MAHM,aAAa;GAAE;GAAK,GAAG;GAAS;EAAU,CAEhC,EAAE,EAAE,GAAG,QAAQ,CAAC;CAEzC;;;;;;CAOA,MAAM,OACJ,OACA,EACE,MAAM,aAAa,GACnB,aAAa,aAAa,IAAI,CAAC,aAAa,CAAC,IAAI,QACjD,GAAG,YACmC,CAAC,GAC7B;EAGZ,OAAO,MAFQ,eAAe;GAAE;GAAK,GAAG;GAAS;EAAW,CAE1C,EAAE,KAAe;CACrC;;;;CAKA,MAAM,qBACJ,SACA,EACE,MAAM,oBAAoB,GAC1B,WACA,YAAY,aAAa,GACzB,GAAG,YACiC,CAAC,GACtB;EAEjB,OADa,aAAa;GAAE;GAAK;GAAW;GAAW,GAAG;EAAQ,CACxD,EAAE,EAAE,GAAG,QAAQ,CAAC;CAC5B;;;;CAKA,MAAM,mBACJ,OACA,EACE,MAAM,oBAAoB,GAC1B,aAAa,CAAC,aAAa,CAAC,GAC5B,GAAG,YACmC,CAAC,GAC7B;EAEZ,OAAO,MADQ,eAAe;GAAE;GAAK;GAAY,GAAG;EAAQ,CAC1C,EAAE,KAAK;CAC3B;AACF"}
1
+ {"version":3,"file":"jwt.mjs","names":[],"sources":["../../../../../../../@warlock.js/auth/src/services/jwt.ts"],"sourcesContent":["import { createSigner, createVerifier, type SignerOptions, type VerifierOptions } from \"fast-jwt\";\r\nimport { authConfig } from \"./auth-config\";\r\n\r\nconst getSecretKey = () => authConfig.accessToken.secret();\r\nconst getAlgorithm = () => authConfig.accessToken.algorithm();\r\n\r\n// Refresh tokens may declare their own secret; when unset/empty we fall back to\r\n// the access-token secret (the documented optional behavior).\r\nconst getRefreshSecretKey = () => authConfig.refreshToken.secret() || getSecretKey();\r\n\r\n/**\r\n * Token class. Stamped as the `tokenType` claim on every signed token and\r\n * checked on verify so an access token can never be accepted where a refresh\r\n * token is expected (and vice versa) — even when both share the same secret\r\n * under the documented refresh-secret fallback. Legacy tokens minted before\r\n * this claim existed carry no `tokenType` and remain accepted; a *mismatched*\r\n * type is always rejected.\r\n */\r\nexport type TokenType = \"access\" | \"refresh\";\r\n\r\nconst ACCESS_TOKEN_TYPE: TokenType = \"access\";\r\nconst REFRESH_TOKEN_TYPE: TokenType = \"refresh\";\r\n\r\n/**\r\n * Reject the token when its `tokenType` claim is present and does not match the\r\n * expected class. Absent claim ⇒ legacy token, accepted (backward compatible).\r\n */\r\nfunction assertTokenType(decoded: unknown, expected: TokenType): void {\r\n const actual = (decoded as { tokenType?: unknown } | null | undefined)?.tokenType;\r\n\r\n if (typeof actual === \"string\" && actual !== expected) {\r\n throw new Error(`Invalid token type: expected \"${expected}\", received \"${actual}\".`);\r\n }\r\n}\r\n\r\nexport const jwt = {\r\n /**\r\n * Generate a new JWT token for the user.\r\n * @param payload The payload to encode in the JWT token.\r\n */\r\n async generate(\r\n payload: any,\r\n {\r\n key = getSecretKey(),\r\n algorithm = getAlgorithm(),\r\n ...options\r\n }: SignerOptions & { key?: string } = {},\r\n ): Promise<string> {\r\n // Create a signer function with predefined options\r\n const sign = createSigner({ key, ...options, algorithm });\r\n\r\n const token = await sign({ ...payload, tokenType: ACCESS_TOKEN_TYPE });\r\n return token;\r\n },\r\n\r\n /**\r\n * Verify the given token.\r\n * @param token The JWT token to verify.\r\n * @returns The decoded token payload if verification is successful.\r\n */\r\n async verify<T = unknown>(\r\n token: string,\r\n {\r\n key = getSecretKey(),\r\n algorithms = [getAlgorithm()],\r\n ...options\r\n }: VerifierOptions & { key?: string } = {},\r\n ): Promise<T> {\r\n const verify = createVerifier({ key, ...options, algorithms });\r\n\r\n const decoded = await verify(token as string);\r\n\r\n assertTokenType(decoded, ACCESS_TOKEN_TYPE);\r\n\r\n return decoded;\r\n },\r\n\r\n /**\r\n * Generate a new refresh token for the user.\r\n */\r\n async generateRefreshToken(\r\n payload: any,\r\n {\r\n key = getRefreshSecretKey(),\r\n expiresIn,\r\n algorithm = getAlgorithm(),\r\n ...options\r\n }: SignerOptions & { key?: string } = {},\r\n ): Promise<string> {\r\n const sign = createSigner({ key, expiresIn, algorithm, ...options });\r\n return sign({ ...payload, tokenType: REFRESH_TOKEN_TYPE });\r\n },\r\n\r\n /**\r\n * Verify the given refresh token.\r\n */\r\n async verifyRefreshToken<T = unknown>(\r\n token: string,\r\n {\r\n key = getRefreshSecretKey(),\r\n algorithms = [getAlgorithm()],\r\n ...options\r\n }: VerifierOptions & { key?: string } = {},\r\n ): Promise<T> {\r\n const verify = createVerifier({ key, algorithms, ...options });\r\n\r\n const decoded = await verify(token);\r\n\r\n assertTokenType(decoded, REFRESH_TOKEN_TYPE);\r\n\r\n return decoded;\r\n },\r\n};\r\n"],"mappings":";;;;AAGA,MAAM,qBAAqB,WAAW,YAAY,OAAO;AACzD,MAAM,qBAAqB,WAAW,YAAY,UAAU;AAI5D,MAAM,4BAA4B,WAAW,aAAa,OAAO,KAAK,aAAa;AAYnF,MAAM,oBAA+B;AACrC,MAAM,qBAAgC;;;;;AAMtC,SAAS,gBAAgB,SAAkB,UAA2B;CACpE,MAAM,SAAU,SAAwD;CAExE,IAAI,OAAO,WAAW,YAAY,WAAW,UAC3C,MAAM,IAAI,MAAM,iCAAiC,SAAS,eAAe,OAAO,GAAG;AAEvF;AAEA,MAAa,MAAM;;;;;CAKjB,MAAM,SACJ,SACA,EACE,MAAM,aAAa,GACnB,YAAY,aAAa,GACzB,GAAG,YACiC,CAAC,GACtB;EAKjB,OAAO,MAHM,aAAa;GAAE;GAAK,GAAG;GAAS;EAAU,CAEhC,CAAC,CAAC;GAAE,GAAG;GAAS,WAAW;EAAkB,CAAC;CAEvE;;;;;;CAOA,MAAM,OACJ,OACA,EACE,MAAM,aAAa,GACnB,aAAa,CAAC,aAAa,CAAC,GAC5B,GAAG,YACmC,CAAC,GAC7B;EAGZ,MAAM,UAAU,MAFD,eAAe;GAAE;GAAK,GAAG;GAAS;EAAW,CAEjC,CAAC,CAAC,KAAe;EAE5C,gBAAgB,SAAS,iBAAiB;EAE1C,OAAO;CACT;;;;CAKA,MAAM,qBACJ,SACA,EACE,MAAM,oBAAoB,GAC1B,WACA,YAAY,aAAa,GACzB,GAAG,YACiC,CAAC,GACtB;EAEjB,OADa,aAAa;GAAE;GAAK;GAAW;GAAW,GAAG;EAAQ,CACxD,CAAC,CAAC;GAAE,GAAG;GAAS,WAAW;EAAmB,CAAC;CAC3D;;;;CAKA,MAAM,mBACJ,OACA,EACE,MAAM,oBAAoB,GAC1B,aAAa,CAAC,aAAa,CAAC,GAC5B,GAAG,YACmC,CAAC,GAC7B;EAGZ,MAAM,UAAU,MAFD,eAAe;GAAE;GAAK;GAAY,GAAG;EAAQ,CAEjC,CAAC,CAAC,KAAK;EAElC,gBAAgB,SAAS,kBAAkB;EAE3C,OAAO;CACT;AACF"}
@@ -1,4 +1,4 @@
1
- //#region ../../@warlock.js/auth/src/utils/auth-error-codes.d.ts
1
+ //#region ../@warlock.js/auth/src/utils/auth-error-codes.d.ts
2
2
  declare enum AuthErrorCodes {
3
3
  /**
4
4
  * Missing Access Token Error Code EC001
@@ -16,7 +16,14 @@ declare enum AuthErrorCodes {
16
16
  * Unauthorized Error Code EC003
17
17
  * EC003 = Unauthorized
18
18
  */
19
- Unauthorized = "EC003"
19
+ Unauthorized = "EC003",
20
+ // Error Code 003
21
+ /**
22
+ * Too Many Attempts Error Code EC004
23
+ * EC004 = Too Many Attempts — issued by the login-throttle middleware once
24
+ * a per-account or per-IP failure counter trips its threshold.
25
+ */
26
+ TooManyAttempts = "EC004"
20
27
  }
21
28
  //#endregion
22
29
  export { AuthErrorCodes };
@@ -1 +1 @@
1
- {"version":3,"file":"auth-error-codes.d.mts","names":[],"sources":["../../../../../../@warlock.js/auth/src/utils/auth-error-codes.ts"],"mappings":";aAAY,cAAA;EAAA;;;;EAKV,kBAAA;EAAA;EAKA;;;AAKY;EALZ,kBAAA;EAAA;;;;;EAKA,YAAA;AAAA"}
1
+ {"version":3,"file":"auth-error-codes.d.mts","names":[],"sources":["../../../../../../../@warlock.js/auth/src/utils/auth-error-codes.ts"],"mappings":";aAAY,cAAA;EAAA;;;;EAKV,kBAAA;EAAA;EAKA;;;;EAAA,kBAAA;EAAA;;;;;EAKA,YAAA;EAAA;;;;;;EAMA,eAAA;AAAA"}
@@ -1,4 +1,4 @@
1
- //#region ../../@warlock.js/auth/src/utils/auth-error-codes.ts
1
+ //#region ../@warlock.js/auth/src/utils/auth-error-codes.ts
2
2
  let AuthErrorCodes = /* @__PURE__ */ function(AuthErrorCodes) {
3
3
  /**
4
4
  * Missing Access Token Error Code EC001
@@ -15,6 +15,12 @@ let AuthErrorCodes = /* @__PURE__ */ function(AuthErrorCodes) {
15
15
  * EC003 = Unauthorized
16
16
  */
17
17
  AuthErrorCodes["Unauthorized"] = "EC003";
18
+ /**
19
+ * Too Many Attempts Error Code EC004
20
+ * EC004 = Too Many Attempts — issued by the login-throttle middleware once
21
+ * a per-account or per-IP failure counter trips its threshold.
22
+ */
23
+ AuthErrorCodes["TooManyAttempts"] = "EC004";
18
24
  return AuthErrorCodes;
19
25
  }({});
20
26
 
@@ -1 +1 @@
1
- {"version":3,"file":"auth-error-codes.mjs","names":[],"sources":["../../../../../../@warlock.js/auth/src/utils/auth-error-codes.ts"],"sourcesContent":["export enum AuthErrorCodes {\n /**\n * Missing Access Token Error Code EC001\n * EC001 = Missing Access Token\n */\n MissingAccessToken = \"EC001\", // Error Code 001\n /**\n * Invalid Access Token Error Code EC002\n * EC002 = Invalid Access Token\n */\n InvalidAccessToken = \"EC002\", // Error Code 002\n /**\n * Unauthorized Error Code EC003\n * EC003 = Unauthorized\n */\n Unauthorized = \"EC003\", // Error Code 003\n}\n"],"mappings":";AAAA,IAAY,iBAAL;;;;;CAKL;;;;;CAKA;;;;;CAKA;;AACF"}
1
+ {"version":3,"file":"auth-error-codes.mjs","names":[],"sources":["../../../../../../../@warlock.js/auth/src/utils/auth-error-codes.ts"],"sourcesContent":["export enum AuthErrorCodes {\n /**\n * Missing Access Token Error Code EC001\n * EC001 = Missing Access Token\n */\n MissingAccessToken = \"EC001\", // Error Code 001\n /**\n * Invalid Access Token Error Code EC002\n * EC002 = Invalid Access Token\n */\n InvalidAccessToken = \"EC002\", // Error Code 002\n /**\n * Unauthorized Error Code EC003\n * EC003 = Unauthorized\n */\n Unauthorized = \"EC003\", // Error Code 003\n /**\n * Too Many Attempts Error Code EC004\n * EC004 = Too Many Attempts — issued by the login-throttle middleware once\n * a per-account or per-IP failure counter trips its threshold.\n */\n TooManyAttempts = \"EC004\", // Error Code 004\n}\n"],"mappings":";AAAA,IAAY,iBAAL;;;;;CAKL;;;;;CAKA;;;;;CAKA;;;;;;CAMA;;AACF"}
package/llms-full.txt CHANGED
@@ -64,15 +64,16 @@ export default {
64
64
  user: User,
65
65
  // admin: Admin, // for multi-user-type
66
66
  },
67
- jwt: {
67
+ accessToken: {
68
68
  secret: env("JWT_SECRET"),
69
69
  expiresIn: "1h",
70
- refresh: {
71
- enabled: true,
72
- expiresIn: "30d",
73
- rotation: true,
74
- maxPerUser: 5,
75
- },
70
+ },
71
+ refreshToken: {
72
+ secret: env("JWT_REFRESH_SECRET"), // recommended: a separate refresh secret
73
+ enabled: true,
74
+ expiresIn: "30d",
75
+ rotation: true,
76
+ maxPerUser: 5,
76
77
  },
77
78
  };
78
79
  ```
@@ -96,6 +97,122 @@ export default {
96
97
  - Don't run `auth.cleanup` from app boot. Schedule it (cron, scheduler) as a periodic task — see [`@warlock.js/scheduler/scheduler-basics/SKILL.md`](@warlock.js/scheduler/scheduler-basics/SKILL.md).
97
98
 
98
99
 
100
+ ## customize-token-storage `@warlock.js/auth/customize-token-storage/SKILL.md`
101
+
102
+ ---
103
+ name: customize-token-storage
104
+ description: 'Override the persisted AccessToken / RefreshToken models to add columns (multi-tenant `organization_id`, device metadata), rename, or change storage — without forking the package. Extend the model + `schema.extend(...)`, register it under `config.auth.accessToken.model` / `config.auth.refreshToken.model`, override `issue()` to populate the new column, and add a migration. Triggers: `accessToken.model`, `refreshToken.model`, `AccessToken.issue`, `RefreshToken.issue`, `accessTokenSchema`, `refreshTokenSchema`, "add a column to the token table", "multi-tenant tokens", "organization_id on access token", "override the token model", "custom token storage"; typical import `import { AccessToken, accessTokenSchema } from "@warlock.js/auth"`. Skip: multiple user TYPES (not token storage) — `@warlock.js/auth/customize-user-type/SKILL.md`; the token lifecycle API — `@warlock.js/auth/manage-tokens/SKILL.md`; the config blocks themselves — `@warlock.js/auth/auth-basics/SKILL.md`.'
105
+ ---
106
+
107
+ # Customize token storage
108
+
109
+ The package ships `AccessToken` and `RefreshToken` models and runs the whole token flow through their **named statics** — `issue`, `findByToken`, `activeFor`, `revokeAllFor`, etc. The auth service never references a column name directly; it resolves the *active* model from config. So you change token storage by **registering a subclass**, not by forking.
110
+
111
+ Reach for this when you need an extra column on the token tables — the common case is a tenant key (`organization_id`) so tokens are partitioned per organization, or richer device metadata.
112
+
113
+ ## The three pieces
114
+
115
+ A storage override is always three coordinated steps. Miss any one and it breaks — see the strict-mode note below.
116
+
117
+ ### 1. Extend the model + its schema
118
+
119
+ `accessTokenSchema` / `refreshTokenSchema` are exported so you compose them with `.extend(...)` instead of re-declaring the base shape. Override `issue()` to populate your new column (the user is in hand there).
120
+
121
+ ```ts title="src/app/auth/models/app-access-token.ts"
122
+ import { AccessToken, accessTokenSchema, type Auth } from "@warlock.js/auth";
123
+ import { v } from "@warlock.js/seal";
124
+
125
+ export class AppAccessToken extends AccessToken {
126
+ public static schema = accessTokenSchema.extend({
127
+ organization_id: v.string().exists("Organization", { column: "id" }),
128
+ });
129
+
130
+ // populate the tenant key on issue — read it off the authenticating user
131
+ public static issue(user: Auth, token: string, expiresAt: Date) {
132
+ return this.create({
133
+ token,
134
+ user_id: user.id,
135
+ user_type: user.userType,
136
+ expires_at: expiresAt,
137
+ organization_id: user.get("organization_id"),
138
+ });
139
+ }
140
+ }
141
+ ```
142
+
143
+ Refresh tokens follow the same shape — extend `refreshTokenSchema`, and override `issue(user, token, options)` (`options` is `{ familyId, expiresAt, deviceInfo? }`) the same way, copying the base fields plus your column.
144
+
145
+ ### 2. Register the subclass in config
146
+
147
+ ```ts title="src/config/auth.ts"
148
+ import { AppAccessToken } from "app/auth/models/app-access-token";
149
+ import { AppRefreshToken } from "app/auth/models/app-refresh-token";
150
+
151
+ export default {
152
+ userType: { user: User },
153
+ accessToken: {
154
+ model: AppAccessToken, // ← the override
155
+ secret: env("JWT_SECRET"),
156
+ expiresIn: "1h",
157
+ },
158
+ refreshToken: {
159
+ model: AppRefreshToken, // ← the override
160
+ secret: env("JWT_REFRESH_SECRET"),
161
+ expiresIn: "30d",
162
+ },
163
+ };
164
+ ```
165
+
166
+ From here the service, the middleware, and every `authService` helper transparently use your model — `findByToken`, `revokeAllFor`, `enforceMax`, and the rest all run against your columns.
167
+
168
+ ### 3. Add the column to the migration
169
+
170
+ The new column needs a real database column. Add it to your token-table migration (the FK + index match your `User` model's tenant convention):
171
+
172
+ ```ts
173
+ this.uuid("organization_id").references("organizations").onDelete("cascade").index();
174
+ ```
175
+
176
+ See [`@warlock.js/cascade/write-migration/SKILL.md`](@warlock.js/cascade/write-migration/SKILL.md) for the migration mechanics.
177
+
178
+ ## Why all three — the strict-mode trap
179
+
180
+ This is the failure people hit. Cascade's `strictMode: "strip"` **drops any field your schema doesn't declare** before the INSERT. So if you set `organization_id` in `issue()` but don't add it to the schema (step 1), the value is silently stripped, and a NOT-NULL `organization_id` column then **fails the INSERT**. The chain is: `issue()` sets it → the *schema* must declare it so it survives → the *migration* must create the column. All three, or nothing.
181
+
182
+ ## You do NOT need read-side scoping
183
+
184
+ Token lookups go by the unique `token` string (`findByToken`), which already uniquely identifies a row regardless of tenant. So you don't need a tenant-scoped global query scope on the token model — `organization_id` is there for FK cascade-cleanup when an org is deleted, plus partitioning and analytics, not for lookup safety.
185
+
186
+ ## What you're overriding (the contract)
187
+
188
+ Your subclass inherits and may override these statics — the service calls them, never raw queries:
189
+
190
+ | Static | Role |
191
+ | --- | --- |
192
+ | `issue(user, token, …)` | persist a freshly-signed token (override to add columns) |
193
+ | `findByToken(token)` | look a row up by its token string |
194
+ | `findForUser(user, token)` | a user-scoped lookup (logout) |
195
+ | `activeFor(user)` | active, unexpired sessions, newest-first |
196
+ | `revokeAllFor(user)` / `revokeFamily(id)` | revoke a set, returning the revoked rows |
197
+ | `enforceMax(user, max)` | cap concurrent refresh tokens |
198
+ | `purgeExpired()` | delete expired rows (CLI cleanup) |
199
+
200
+ If you rename a column, override the statics that reference it so they map to your name — the service depends on the method, not the column.
201
+
202
+ ## Things NOT to do
203
+
204
+ - **Don't set a column without declaring it in `schema.extend(...)`.** `strictMode: "strip"` removes it; a NOT-NULL column then fails the INSERT.
205
+ - **Don't add a tenant-scoped global scope to the token model.** Lookups are by unique token; a leaky scope is a cross-tenant exposure risk for no lookup benefit.
206
+ - **Don't fork the package to add a column.** Extend + register — you keep receiving package fixes.
207
+ - **Don't forget the migration.** The schema declares the field; only the migration creates the database column.
208
+
209
+ ## See also
210
+
211
+ - [`@warlock.js/auth/customize-user-type/SKILL.md`](@warlock.js/auth/customize-user-type/SKILL.md) — multiple user *types* (a different axis from token *storage*).
212
+ - [`@warlock.js/auth/manage-tokens/SKILL.md`](@warlock.js/auth/manage-tokens/SKILL.md) — the token lifecycle your statics power.
213
+ - [`@warlock.js/cascade/define-model/SKILL.md`](@warlock.js/cascade/define-model/SKILL.md) — extending models and schemas.
214
+
215
+
99
216
  ## customize-user-type `@warlock.js/auth/customize-user-type/SKILL.md`
100
217
 
101
218
  ---
@@ -151,10 +268,15 @@ export default {
151
268
  // staff: Staff,
152
269
  // client: Client,
153
270
  },
154
- jwt: {
271
+ accessToken: {
155
272
  secret: env("JWT_SECRET"),
156
273
  expiresIn: "1h",
157
- refresh: { enabled: true, expiresIn: "30d", rotation: true },
274
+ },
275
+ refreshToken: {
276
+ secret: env("JWT_REFRESH_SECRET"),
277
+ enabled: true,
278
+ expiresIn: "30d",
279
+ rotation: true,
158
280
  },
159
281
  };
160
282
  ```
@@ -337,7 +459,7 @@ async function logoutController(request: Request, response: Response) {
337
459
  The contract:
338
460
  - **Pass the access token** → that specific access-token row is deleted.
339
461
  - **Pass the refresh token** → that specific refresh-token row is revoked.
340
- - **Omit refresh token** → behavior depends on `config.auth.jwt.refresh.logoutWithoutToken`:
462
+ - **Omit refresh token** → behavior depends on `config.auth.refreshToken.logoutWithoutToken`:
341
463
  - `"revoke-all"` (default) — every refresh token for this user is revoked. Fail-safe.
342
464
  - `"error"` — throws. Force the client to send the refresh token.
343
465
 
@@ -420,7 +542,7 @@ type RefreshTokenOutput = { token: string; expiresAt: string };
420
542
 
421
543
  type TokenPair = {
422
544
  accessToken: AccessTokenOutput;
423
- refreshToken?: RefreshTokenOutput; // omitted if config.auth.jwt.refresh.enabled = false
545
+ refreshToken?: RefreshTokenOutput; // omitted if config.auth.refreshToken.enabled = false
424
546
  };
425
547
  ```
426
548
 
@@ -439,7 +561,7 @@ const refresh = await authService.createRefreshToken(user, deviceInfo);
439
561
  const pair = await authService.createTokenPair(user, deviceInfo);
440
562
  ```
441
563
 
442
- `createTokenPair` is the typical issuance path. It respects `config.auth.jwt.refresh.enabled` — if disabled, returns only `accessToken`.
564
+ `createTokenPair` is the typical issuance path. It respects `config.auth.refreshToken.enabled` — if disabled, returns only `accessToken`.
443
565
 
444
566
  ## Refresh with rotation — `refreshTokens`
445
567
 
@@ -453,7 +575,7 @@ What happens internally:
453
575
  1. Verify the JWT signature on the old refresh token.
454
576
  2. Find the row in `RefreshToken` — must exist + not be revoked.
455
577
  3. Look up the user via `config.auth.userType[token.userType]`.
456
- 4. **Rotation** (default — `config.auth.jwt.refresh.rotation = true`): revoke the old refresh token, create a new pair from the same `family_id`.
578
+ 4. **Rotation** (default — `config.auth.refreshToken.rotation = true`): revoke the old refresh token, create a new pair from the same `family_id`.
457
579
  5. **No rotation**: mark the old as "used" but keep it valid.
458
580
 
459
581
  **Replay detection.** If the old refresh token is presented again after rotation (already revoked but still in the DB):
@@ -497,7 +619,7 @@ Use this for "active sessions" UIs. Revoke a specific session by calling `.revok
497
619
  await authService.removeAccessToken(user, accessTokenString);
498
620
 
499
621
  // Specific refresh token (via the RefreshToken instance)
500
- const rt = await RefreshToken.first({ token: refreshString });
622
+ const rt = await RefreshToken.findByToken(refreshString);
501
623
  await rt?.revoke();
502
624
 
503
625
  // All access tokens for a user
@@ -513,7 +635,7 @@ await authService.revokeTokenFamily(familyId);
513
635
  ## Max refresh tokens per user
514
636
 
515
637
  ```ts
516
- // In config.auth.jwt.refresh:
638
+ // In config.auth.refreshToken:
517
639
  {
518
640
  maxPerUser: 5, // default
519
641
  }
@@ -525,7 +647,7 @@ When issuing a new refresh token, the service counts active tokens for the user
525
647
 
526
648
  ```ts
527
649
  const cleaned = await authService.cleanupExpiredTokens();
528
- // Returns: number of expired refresh tokens removed.
650
+ // Returns: number of expired refresh tokens removed (also purges expired access-token rows).
529
651
  // Fires "token.expired" event per token + "cleanup.completed" with the count.
530
652
  ```
531
653
 
@@ -551,19 +673,19 @@ For low-level JWT signing/verification (outside the authService flow):
551
673
  ```ts
552
674
  import { jwt } from "@warlock.js/auth";
553
675
 
554
- const token = await jwt.generate(payload, { expiresIn: 3600 });
676
+ const token = await jwt.generate(payload, { expiresIn: "1h" }); // string or ms; a bare number is milliseconds
555
677
  const decoded = await jwt.verify(token);
556
678
 
557
679
  const refreshToken = await jwt.generateRefreshToken(payload, { expiresIn });
558
680
  const decodedRefresh = await jwt.verifyRefreshToken(refreshToken);
559
681
  ```
560
682
 
561
- The package signs access and refresh tokens with independent secrets — `config.auth.jwt.secret` and `config.auth.jwt.refresh.secret`. Setting a distinct `refresh.secret` is recommended: it prevents an access-token compromise from forging refresh tokens (and vice versa). The refresh secret is **optional** — when `config.auth.jwt.refresh.secret` is unset, refresh tokens fall back to the main `config.auth.jwt.secret`, so refresh works out of the box without a second secret.
683
+ 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.
562
684
 
563
685
  ## Things NOT to do
564
686
 
565
687
  - Don't use raw JWT libraries directly. The package handles signing, verification, secret loading, and the access/refresh split.
566
- - Don't disable rotation (`config.auth.jwt.refresh.rotation = false`) unless you genuinely understand the tradeoff — you lose replay detection.
688
+ - Don't disable rotation (`config.auth.refreshToken.rotation = false`) unless you genuinely understand the tradeoff — you lose replay detection.
567
689
  - Don't increase `maxPerUser` to a huge number "to be safe." Each active refresh token is a revocation surface; fewer simultaneous tokens means less attack surface.
568
690
  - Don't manually delete `AccessToken` rows in a service. The user might be hitting a request mid-revoke and get an inconsistent state. Use the `authService` helpers.
569
691
 
@@ -601,7 +723,7 @@ Your user model extends the `Auth` base model and declares its `userType`. A log
601
723
 
602
724
  ## Skills index
603
725
 
604
- Seven task skills. Most apps need `auth-basics` + `protect-routes` + `handle-login-and-logout`.
726
+ Nine task skills. Most apps need `auth-basics` + `protect-routes` + `handle-login-and-logout`.
605
727
 
606
728
  ### Foundations
607
729
 
@@ -627,6 +749,12 @@ The token lifecycle — `generateAccessToken`, `createRefreshToken`, `createToke
627
749
  #### [`customize-user-type`](@warlock.js/auth/customize-user-type/SKILL.md)
628
750
  Support multiple user types in one system — each `Auth` subclass overrides `userType`, `config.auth.userType.<slug>` maps the slug to a model class, `authMiddleware("admin")` / `authMiddleware(["admin", "staff"])` gates per type.
629
751
 
752
+ #### [`customize-token-storage`](@warlock.js/auth/customize-token-storage/SKILL.md)
753
+ Override the persisted `AccessToken` / `RefreshToken` models to add columns (multi-tenant `organization_id`), rename, or change storage — extend the model + schema and register it under `config.auth.accessToken.model` / `config.auth.refreshToken.model`.
754
+
755
+ #### [`throttle-login-attempts`](@warlock.js/auth/throttle-login-attempts/SKILL.md)
756
+ Brute-force / credential-stuffing protection — `loginThrottleMiddleware()` counts only failed logins, resets on success, locks per-account + per-IP after a threshold, and rejects pre-controller with 429. Cache-backed, fails open on a cache outage.
757
+
630
758
  #### [`run-auth-commands`](@warlock.js/auth/run-auth-commands/SKILL.md)
631
759
  Two CLI commands — `warlock jwt.generate` (strong JWT secret → `.env`) and `warlock auth.cleanup` (remove expired refresh tokens). Register via `registerJWTSecretGeneratorCommand()` and `registerAuthCleanupCommand()`.
632
760
 
@@ -751,6 +879,7 @@ The middleware uses the framework's `response.unauthorized({...})` shape. To ove
751
879
 
752
880
  - [`@warlock.js/auth/customize-user-type/SKILL.md`](@warlock.js/auth/customize-user-type/SKILL.md) — config and multi-user-type semantics
753
881
  - [`@warlock.js/auth/handle-login-and-logout/SKILL.md`](@warlock.js/auth/handle-login-and-logout/SKILL.md) — where the access token gets issued in the first place
882
+ - [`@warlock.js/auth/throttle-login-attempts/SKILL.md`](@warlock.js/auth/throttle-login-attempts/SKILL.md) — brute-force throttle to stack in front of the login route
754
883
 
755
884
 
756
885
  ## register-user `@warlock.js/auth/register-user/SKILL.md`
@@ -1021,3 +1150,123 @@ Register it the same way as the bundled commands — call the factory inside `de
1021
1150
  - [`@warlock.js/scheduler/scheduler-basics/SKILL.md`](@warlock.js/scheduler/scheduler-basics/SKILL.md) — in-process scheduling
1022
1151
 
1023
1152
 
1153
+ ## throttle-login-attempts `@warlock.js/auth/throttle-login-attempts/SKILL.md`
1154
+
1155
+ ---
1156
+ name: throttle-login-attempts
1157
+ description: 'Brute-force / credential-stuffing protection via `loginThrottleMiddleware` — a failure-aware route gate that counts only failed logins (resets on success), locks per-account and per-source after a threshold, and rejects pre-controller with 429 so the DB lookup and bcrypt verify are skipped. Cache-backed (shared across replicas), fixed-window, fails open on a cache outage. Triggers: `loginThrottleMiddleware`, `AuthErrorCodes.TooManyAttempts`, `EC004`, "rate limit login", "brute force protection", "lock account after failed logins", "throttle login attempts", "too many login attempts 429"; typical import `import { loginThrottleMiddleware } from "@warlock.js/auth"`. Skip: generic per-route request rate limiting that counts every request (use core `middleware.rateLimit`); gating a route by auth — `@warlock.js/auth/protect-routes/SKILL.md`; issuing tokens — `@warlock.js/auth/handle-login-and-logout/SKILL.md`.'
1158
+ ---
1159
+
1160
+ # Throttle login attempts with `loginThrottleMiddleware`
1161
+
1162
+ `loginThrottleMiddleware(options?)` returns a Warlock middleware that defends the login (and refresh / password-reset) routes against brute-force and credential-stuffing — without punishing a user who fat-fingers a password then gets it right.
1163
+
1164
+ The trick that makes it different from a plain rate limiter: it's **failure-aware**. It hooks the response *after* your controller runs (`response.onSent`), counts only the attempts that actually failed, and **clears the counter the moment a login succeeds**. A generic request limiter can't do that — it runs before the controller and counts every hit, success or not.
1165
+
1166
+ ## The shortest version
1167
+
1168
+ ```ts
1169
+ import { loginThrottleMiddleware } from "@warlock.js/auth";
1170
+
1171
+ router.post("/auth/login", loginController, {
1172
+ middleware: [loginThrottleMiddleware()], // 5 failures / 15m → 15m lockout, per email + ip
1173
+ });
1174
+ ```
1175
+
1176
+ That's the whole feature for most apps. Defaults: **5** failed attempts within a **15m** window trips a **15m** lockout, tracked independently per account (the `email` field) **and** per source IP.
1177
+
1178
+ > **Heads-up — account-lockout is a DoS surface.** Because the default tracks per-`email`, an attacker who knows a victim's address can lock them out by spamming failed logins. The per-IP counter mitigates it (the attacker's own IP trips too); for purely anonymous endpoints prefer `by: ["ip"]`, and for high-value accounts consider a CAPTCHA step over a hard lock. See [Gotchas](#gotchas).
1179
+
1180
+ ## How it behaves
1181
+
1182
+ 1. **Before the controller** — if the account or the IP is currently locked, it short-circuits with `429` and never touches the database or the bcrypt verify (this is also what neutralises the CPU-DoS angle of brute-forcing). The body carries `AuthErrorCodes.TooManyAttempts` (`EC004`).
1183
+ 2. **After the controller** — it inspects the outcome. A non-2xx response is a failed login: it bumps a fixed-window counter for each tracked identifier, and once a counter reaches `max` it writes a lock key. A 2xx response is a success: it **clears** the counter and lock for that identifier.
1184
+
1185
+ Per-account tracking stops a password-spray against one user from many IPs; per-source tracking stops one IP from stuffing many accounts. A lock on **either** identifier rejects the request — defense-in-depth.
1186
+
1187
+ ## Options
1188
+
1189
+ ```ts
1190
+ loginThrottleMiddleware({
1191
+ max: 5, // failures allowed in the window before lockout
1192
+ window: "15m", // counting window — ms-format string or seconds (number)
1193
+ lockoutDuration: "15m", // how long the lock lasts once tripped
1194
+ by: ["email", "ip"], // identifiers tracked, each independently
1195
+ identifierKey: "email", // which credential field is the account key
1196
+ errorMessage: "Too many attempts. Try again later.",
1197
+ });
1198
+ ```
1199
+
1200
+ Two escape hatches for non-standard setups:
1201
+
1202
+ ```ts
1203
+ loginThrottleMiddleware({
1204
+ // your controller signals failure with a 200 body instead of a status code
1205
+ isFailure: (response) => response.parsedBody?.ok === false,
1206
+
1207
+ // derive the account key from somewhere other than email/ip
1208
+ identify: (request) => [`tenant.${request.input("tenant")}.${request.input("username")}`],
1209
+ });
1210
+ ```
1211
+
1212
+ | Option | Default | Purpose |
1213
+ | --- | --- | --- |
1214
+ | `max` | `5` | Failures within the window before lockout |
1215
+ | `window` | `"15m"` | Counting window (`ms`-string or seconds) |
1216
+ | `lockoutDuration` | `"15m"` | Lock TTL once tripped |
1217
+ | `by` | `["email", "ip"]` | Identifiers tracked independently |
1218
+ | `identifierKey` | `"email"` | Credential field used as the account key |
1219
+ | `errorMessage` | i18n `auth.errors.tooManyAttempts` | 429 message override |
1220
+ | `isFailure` | `(res) => !res.isOk` | What counts as a failed attempt |
1221
+ | `identify` | built-in email + ip extraction | Custom identifier list |
1222
+
1223
+ ## The 429 response
1224
+
1225
+ On lockout the middleware sends:
1226
+
1227
+ ```jsonc
1228
+ { "error": "...", "errorCode": "EC004" } // AuthErrorCodes.TooManyAttempts
1229
+ ```
1230
+
1231
+ Define the `auth.errors.tooManyAttempts` translation key (or pass `errorMessage`) so clients see a real message instead of the raw key. Map `EC004` in your error transformer the same way you map the other `AuthErrorCodes`.
1232
+
1233
+ ## Beyond login
1234
+
1235
+ The middleware isn't login-specific — drop it on any route where repeated failures should lock something out. For the refresh and reset endpoints there's no email in the body, so track by IP only:
1236
+
1237
+ ```ts
1238
+ router.post("/auth/refresh-token", refreshController, {
1239
+ middleware: [loginThrottleMiddleware({ by: ["ip"], max: 10, window: "1m" })],
1240
+ });
1241
+
1242
+ router.post("/auth/forgot-password", forgotController, {
1243
+ middleware: [loginThrottleMiddleware({ max: 3, window: "1h", lockoutDuration: "1h" })],
1244
+ });
1245
+ ```
1246
+
1247
+ ## When to use this vs core's `rateLimit`
1248
+
1249
+ | | `loginThrottleMiddleware` (auth) | `middleware.rateLimit` (core) |
1250
+ | --- | --- | --- |
1251
+ | Counts | only **failed** logins | **every** request |
1252
+ | Resets on a successful login | yes | no |
1253
+ | Locks per account / per IP | yes | per key (default IP) |
1254
+ | Storage | `@warlock.js/cache` (shared across replicas) | in-process map |
1255
+
1256
+ They compose. For a hard ceiling on request volume *and* failure-aware account lockout, stack both: `middleware: [middleware.rateLimit({ max: 30, duration: 60_000 }), loginThrottleMiddleware()]`.
1257
+
1258
+ ## Gotchas
1259
+
1260
+ - **Needs an initialised cache driver.** Storage is `@warlock.js/cache` (a peer dep, transitively present via core). If the driver isn't configured at runtime, the middleware **fails open** — it logs and lets the request through, because a throttle outage must never become an auth outage. That also means: no cache, no protection. Verify your cache config in production.
1261
+ - **Account lockout can be weaponised.** An attacker who knows a victim's email can lock them out by spamming failures. The per-IP counter mitigates this (the attacker's IP locks too), but for purely anonymous endpoints prefer `by: ["ip"]`. For high-value accounts, consider a CAPTCHA step instead of a hard lock.
1262
+ - **Fixed window, not sliding.** The window is anchored at the first failure and does not extend on each subsequent one — predictable lockout timing. The lock key is independent and always lasts `lockoutDuration`.
1263
+ - **Defense-in-depth, not a WAF.** This is application-layer. It won't stop a volumetric L3/L4 flood — pair it with an edge/CDN rate limit for that.
1264
+ - **Don't call it per-request.** Like every middleware, call it once at route definition; it returns the function that runs per request.
1265
+
1266
+ ## See also
1267
+
1268
+ - [`@warlock.js/auth/protect-routes/SKILL.md`](@warlock.js/auth/protect-routes/SKILL.md) — gate a route behind a valid token (`authMiddleware`); stack the throttle in front of it on login routes.
1269
+ - [`@warlock.js/auth/handle-login-and-logout/SKILL.md`](@warlock.js/auth/handle-login-and-logout/SKILL.md) — the login flow whose failures this middleware counts.
1270
+ - [`@warlock.js/core/use-middleware/SKILL.md`](@warlock.js/core/use-middleware/SKILL.md) — `middleware.rateLimit` and the built-in middleware suite.
1271
+
1272
+
package/llms.txt CHANGED
@@ -7,6 +7,7 @@
7
7
  ## Skills
8
8
 
9
9
  - [auth-basics](@warlock.js/auth/auth-basics/SKILL.md): Start with @warlock.js/auth — JWT auth, Auth base model, authMiddleware route gate, authService (login / logout / refresh), AccessToken + RefreshToken persistence, multi-user-type support. Triggers: `Auth`, `authMiddleware`, `authService`, `AccessToken`, `RefreshToken`, `authMigrations`; "set up auth in a new app", "which auth skill do I need", "JWT authentication overview", "wire warlock auth"; typical import `import { authMiddleware, authService, Auth, authMigrations } from "@warlock.js/auth"`. Skip: routing — `@warlock.js/auth/protect-routes/SKILL.md`; login — `@warlock.js/auth/handle-login-and-logout/SKILL.md`; competing libs `passport`, `next-auth`, `lucia-auth`, `auth0`.
10
+ - [customize-token-storage](@warlock.js/auth/customize-token-storage/SKILL.md): Override the persisted AccessToken / RefreshToken models to add columns (multi-tenant `organization_id`, device metadata), rename, or change storage — without forking the package. Extend the model + `schema.extend(...)`, register it under `config.auth.accessToken.model` / `config.auth.refreshToken.model`, override `issue()` to populate the new column, and add a migration. Triggers: `accessToken.model`, `refreshToken.model`, `AccessToken.issue`, `RefreshToken.issue`, `accessTokenSchema`, `refreshTokenSchema`, "add a column to the token table", "multi-tenant tokens", "organization_id on access token", "override the token model", "custom token storage"; typical import `import { AccessToken, accessTokenSchema } from "@warlock.js/auth"`. Skip: multiple user TYPES (not token storage) — `@warlock.js/auth/customize-user-type/SKILL.md`; the token lifecycle API — `@warlock.js/auth/manage-tokens/SKILL.md`; the config blocks themselves — `@warlock.js/auth/auth-basics/SKILL.md`.
10
11
  - [customize-user-type](@warlock.js/auth/customize-user-type/SKILL.md): Support multiple user types (user / admin / client / staff) in one auth system — each Auth subclass overrides userType, config.auth.userType.<slug> maps slug to model class, authMiddleware('admin') gates per type. Triggers: `Auth`, `userType`, `config.auth.userType`, `Authenticable`, `@RegisterModel`, `confirmPassword`; "add admins and users", "multiple user types", "separate client and vendor personas", "per-type login"; typical import `import { Auth } from "@warlock.js/auth"`. Skip: `authMiddleware` semantics — `@warlock.js/auth/protect-routes/SKILL.md`; login flow — `@warlock.js/auth/handle-login-and-logout/SKILL.md`; RBAC libs `casl`, `accesscontrol`, `rbac`.
11
12
  - [handle-login-and-logout](@warlock.js/auth/handle-login-and-logout/SKILL.md): Run the full login flow via authService.login(Model, credentials, deviceInfo?) — verify password, create access + refresh token pair, fire events. Logout via authService.logout(user, accessToken?, refreshToken?) revokes tokens. Triggers: `authService.login`, `authService.logout`, `authService.attemptLogin`, `authService.refreshTokens`, `authService.revokeAllTokens`, `authEvents`; "build a login endpoint", "POST /login controller", "logout from all devices", "verify credentials and issue tokens"; typical import `import { authService, authEvents } from "@warlock.js/auth"`. Skip: token internals — `@warlock.js/auth/manage-tokens/SKILL.md`; sign-up — `@warlock.js/auth/register-user/SKILL.md`; competing libs `passport-local`, `next-auth` credentials.
12
13
  - [manage-tokens](@warlock.js/auth/manage-tokens/SKILL.md): Token lifecycle — generateAccessToken, createRefreshToken, createTokenPair, refreshTokens (with rotation + replay detection), revokeAllTokens, revokeTokenFamily, cleanupExpiredTokens, getActiveSessions. Triggers: `createTokenPair`, `refreshTokens`, `revokeTokenFamily`, `cleanupExpiredTokens`, `getActiveSessions`, `jwt.generate`, `jwt.verify`, `AccessToken`, `RefreshToken`; "rotate refresh tokens", "detect token replay", "logout from all devices", "list active sessions", "clean up expired tokens"; typical import `import { authService, jwt } from "@warlock.js/auth"`. Skip: login flow — `@warlock.js/auth/handle-login-and-logout/SKILL.md`; CLI cleanup — `@warlock.js/auth/run-auth-commands/SKILL.md`; competing libs `jsonwebtoken`, `jose`, `fast-jwt`.
@@ -14,3 +15,4 @@
14
15
  - [protect-routes](@warlock.js/auth/protect-routes/SKILL.md): Gate HTTP routes via authMiddleware(allowedUserType) — the argument is required and a valid token is always required: [] allows any authenticated user, a user-type restricts to those types. Sets request.user + request.decodedAccessToken on success, 401 on failure. Triggers: `authMiddleware`, `request.user`, `request.decodedAccessToken`, `AuthErrorCodes`, `MissingAccessToken`, `InvalidAccessToken`; "how do I protect a route", "restrict route by user type", "require any logged-in user"; typical import `import { authMiddleware } from "@warlock.js/auth"`. Skip: multi-user-type config — `@warlock.js/auth/customize-user-type/SKILL.md`; issuing the token — `@warlock.js/auth/handle-login-and-logout/SKILL.md`; competing libs `passport`, `express-jwt`, `next-auth` middleware.
15
16
  - [register-user](@warlock.js/auth/register-user/SKILL.md): Sign up a new user and issue the initial token pair — User.create({...password: await hashPassword(plain)}) then authService.createTokenPair(user). Triggers: `User.create`, `hashPassword`, `verifyPassword`, `authService.createTokenPair`, `toJsonColumns`, `strongPassword`, `authEvents`; "build a register endpoint", "POST /register controller", "sign up a new user", "hash password on signup", "email verification flow"; typical import `import { authService } from "@warlock.js/auth"; import { hashPassword } from "@warlock.js/core"`. Skip: login — `@warlock.js/auth/handle-login-and-logout/SKILL.md`; token internals — `@warlock.js/auth/manage-tokens/SKILL.md`; competing libs `bcrypt`, `bcryptjs`, `argon2`.
16
17
  - [run-auth-commands](@warlock.js/auth/run-auth-commands/SKILL.md): Two bundled CLI commands — warlock jwt.generate (creates strong JWT secret + writes to .env) and warlock auth.cleanup (removes expired refresh tokens). Register via registerJWTSecretGeneratorCommand() and registerAuthCleanupCommand(). Triggers: `registerJWTSecretGeneratorCommand`, `registerAuthCleanupCommand`, `warlock jwt.generate`, `warlock auth.cleanup`, `cleanupExpiredTokens`, `command`; "generate JWT secret", "bootstrap .env JWT_SECRET", "cron job for expired tokens", "schedule auth cleanup"; typical import `import { registerJWTSecretGeneratorCommand, registerAuthCleanupCommand } from "@warlock.js/auth"`. Skip: programmatic cleanup — `@warlock.js/auth/manage-tokens/SKILL.md`; in-process scheduling — `@warlock.js/scheduler/scheduler-basics/SKILL.md`; competing tools `dotenv-cli`, `node-cron`.
18
+ - [throttle-login-attempts](@warlock.js/auth/throttle-login-attempts/SKILL.md): Brute-force / credential-stuffing protection via `loginThrottleMiddleware` — a failure-aware route gate that counts only failed logins (resets on success), locks per-account and per-source after a threshold, and rejects pre-controller with 429 so the DB lookup and bcrypt verify are skipped. Cache-backed (shared across replicas), fixed-window, fails open on a cache outage. Triggers: `loginThrottleMiddleware`, `AuthErrorCodes.TooManyAttempts`, `EC004`, "rate limit login", "brute force protection", "lock account after failed logins", "throttle login attempts", "too many login attempts 429"; typical import `import { loginThrottleMiddleware } from "@warlock.js/auth"`. Skip: generic per-route request rate limiting that counts every request (use core `middleware.rateLimit`); gating a route by auth — `@warlock.js/auth/protect-routes/SKILL.md`; issuing tokens — `@warlock.js/auth/handle-login-and-logout/SKILL.md`.