@warlock.js/auth 4.1.14 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -0
- package/LICENSE +21 -0
- package/README.md +78 -37
- package/esm/commands/auth-cleanup-command.d.mts +1 -1
- package/esm/commands/auth-cleanup-command.d.mts.map +1 -1
- package/esm/commands/auth-cleanup-command.mjs +1 -1
- package/esm/commands/auth-cleanup-command.mjs.map +1 -1
- package/esm/commands/jwt-secret-generator-command.d.mts +1 -1
- package/esm/commands/jwt-secret-generator-command.d.mts.map +1 -1
- package/esm/commands/jwt-secret-generator-command.mjs +1 -1
- package/esm/commands/jwt-secret-generator-command.mjs.map +1 -1
- package/esm/contracts/auth-contract.d.mts +2 -2
- package/esm/contracts/auth-contract.d.mts.map +1 -1
- package/esm/contracts/index.d.mts +1 -1
- package/esm/contracts/types.d.mts +105 -76
- package/esm/contracts/types.d.mts.map +1 -1
- package/esm/contracts/types.mjs +2 -2
- package/esm/contracts/types.mjs.map +1 -1
- package/esm/index.d.mts +6 -5
- package/esm/index.mjs +4 -3
- package/esm/middleware/auth.middleware.d.mts +1 -1
- package/esm/middleware/auth.middleware.d.mts.map +1 -1
- package/esm/middleware/auth.middleware.mjs +9 -9
- package/esm/middleware/auth.middleware.mjs.map +1 -1
- package/esm/middleware/index.d.mts +2 -0
- package/esm/middleware/index.mjs +1 -0
- package/esm/middleware/login-throttle.middleware.d.mts +116 -0
- package/esm/middleware/login-throttle.middleware.d.mts.map +1 -0
- package/esm/middleware/login-throttle.middleware.mjs +105 -0
- package/esm/middleware/login-throttle.middleware.mjs.map +1 -0
- package/esm/models/access-token/access-token.model.d.mts +47 -5
- package/esm/models/access-token/access-token.model.d.mts.map +1 -1
- package/esm/models/access-token/access-token.model.mjs +70 -4
- package/esm/models/access-token/access-token.model.mjs.map +1 -1
- package/esm/models/access-token/index.d.mts +1 -1
- package/esm/models/access-token/index.mjs +1 -1
- package/esm/models/access-token/migration.mjs +2 -3
- package/esm/models/access-token/migration.mjs.map +1 -1
- package/esm/models/auth.model.d.mts +2 -2
- package/esm/models/auth.model.d.mts.map +1 -1
- package/esm/models/auth.model.mjs +1 -1
- package/esm/models/auth.model.mjs.map +1 -1
- package/esm/models/index.d.mts +4 -3
- package/esm/models/index.d.mts.map +1 -1
- package/esm/models/index.mjs +3 -3
- package/esm/models/index.mjs.map +1 -1
- package/esm/models/refresh-token/index.d.mts +1 -1
- package/esm/models/refresh-token/index.mjs +1 -1
- package/esm/models/refresh-token/migration.mjs +2 -1
- package/esm/models/refresh-token/migration.mjs.map +1 -1
- package/esm/models/refresh-token/refresh-token.model.d.mts +94 -16
- package/esm/models/refresh-token/refresh-token.model.d.mts.map +1 -1
- package/esm/models/refresh-token/refresh-token.model.mjs +152 -13
- package/esm/models/refresh-token/refresh-token.model.mjs.map +1 -1
- package/esm/services/auth-config.mjs +63 -0
- package/esm/services/auth-config.mjs.map +1 -0
- package/esm/services/auth-events.d.mts +2 -2
- package/esm/services/auth-events.d.mts.map +1 -1
- package/esm/services/auth-events.mjs +7 -2
- package/esm/services/auth-events.mjs.map +1 -1
- package/esm/services/auth.service.d.mts +48 -30
- package/esm/services/auth.service.d.mts.map +1 -1
- package/esm/services/auth.service.mjs +88 -102
- package/esm/services/auth.service.mjs.map +1 -1
- package/esm/services/generate-jwt-secret.d.mts +1 -1
- package/esm/services/generate-jwt-secret.d.mts.map +1 -1
- package/esm/services/generate-jwt-secret.mjs +4 -4
- package/esm/services/generate-jwt-secret.mjs.map +1 -1
- package/esm/services/index.d.mts +1 -1
- package/esm/services/jwt.d.mts +13 -4
- package/esm/services/jwt.d.mts.map +1 -1
- package/esm/services/jwt.mjs +30 -11
- package/esm/services/jwt.mjs.map +1 -1
- package/esm/utils/auth-error-codes.d.mts +9 -2
- package/esm/utils/auth-error-codes.d.mts.map +1 -1
- package/esm/utils/auth-error-codes.mjs +7 -1
- package/esm/utils/auth-error-codes.mjs.map +1 -1
- package/llms-full.txt +269 -20
- package/llms.txt +2 -0
- package/package.json +9 -11
- package/skills/auth-basics/SKILL.md +8 -7
- package/skills/customize-token-storage/SKILL.md +112 -0
- package/skills/customize-user-type/SKILL.md +7 -2
- package/skills/handle-login-and-logout/SKILL.md +1 -1
- package/skills/manage-tokens/SKILL.md +9 -9
- package/skills/overview/SKILL.md +7 -1
- package/skills/protect-routes/SKILL.md +1 -0
- package/skills/run-auth-commands/SKILL.md +1 -1
- package/skills/throttle-login-attempts/SKILL.md +116 -0
- package/cjs/index.cjs +0 -807
- package/cjs/index.cjs.map +0 -1
package/esm/services/jwt.d.mts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { SignerOptions, VerifierOptions } from "fast-jwt";
|
|
2
2
|
|
|
3
|
-
//#region
|
|
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 =
|
|
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 =
|
|
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":["
|
|
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"}
|
package/esm/services/jwt.mjs
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import ms from "ms";
|
|
1
|
+
import { authConfig } from "./auth-config.mjs";
|
|
3
2
|
import { createSigner, createVerifier } from "fast-jwt";
|
|
4
3
|
|
|
5
|
-
//#region
|
|
6
|
-
const getSecretKey = () =>
|
|
7
|
-
const getAlgorithm = () =>
|
|
8
|
-
const getRefreshSecretKey = () =>
|
|
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
|
-
})({
|
|
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 =
|
|
27
|
-
|
|
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
|
-
})({
|
|
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
|
-
|
|
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
|
|
package/esm/services/jwt.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"jwt.mjs","names":[],"sources":["
|
|
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
|
|
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":["
|
|
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
|
|
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":["
|
|
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
|
-
|
|
67
|
+
accessToken: {
|
|
68
68
|
secret: env("JWT_SECRET"),
|
|
69
69
|
expiresIn: "1h",
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
271
|
+
accessToken: {
|
|
155
272
|
secret: env("JWT_SECRET"),
|
|
156
273
|
expiresIn: "1h",
|
|
157
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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`.
|