@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
|
@@ -5,7 +5,7 @@ import { AuthErrorCodes } from "../utils/auth-error-codes.mjs";
|
|
|
5
5
|
import { config, t } from "@warlock.js/core";
|
|
6
6
|
import { log } from "@warlock.js/logger";
|
|
7
7
|
|
|
8
|
-
//#region
|
|
8
|
+
//#region ../@warlock.js/auth/src/middleware/auth.middleware.ts
|
|
9
9
|
/**
|
|
10
10
|
* Build a route gate that always requires an authenticated request.
|
|
11
11
|
*
|
|
@@ -32,31 +32,31 @@ function authMiddleware(allowedUserType) {
|
|
|
32
32
|
error: t("auth.errors.missingAccessToken"),
|
|
33
33
|
errorCode: "EC001"
|
|
34
34
|
});
|
|
35
|
-
const
|
|
36
|
-
request.decodedAccessToken =
|
|
37
|
-
const accessToken = await AccessToken.
|
|
35
|
+
const decoded = await jwt.verify(authorizationValue);
|
|
36
|
+
request.decodedAccessToken = decoded;
|
|
37
|
+
const accessToken = await config.key("auth.accessToken.model", AccessToken).findByToken(authorizationValue);
|
|
38
38
|
if (!accessToken) return response.unauthorized({
|
|
39
39
|
error: t("auth.errors.invalidAccessToken"),
|
|
40
40
|
errorCode: "EC002"
|
|
41
41
|
});
|
|
42
|
-
const userType =
|
|
42
|
+
const userType = decoded.userType ?? accessToken.userType;
|
|
43
43
|
if (allowedTypes.length && !allowedTypes.includes(userType)) return response.unauthorized({
|
|
44
44
|
error: t("auth.errors.unauthorized"),
|
|
45
45
|
errorCode: "EC003"
|
|
46
46
|
});
|
|
47
47
|
const UserModel = config.key(`auth.userType.${userType}`);
|
|
48
48
|
if (!UserModel) throw new Error(`User type ${userType} is unknown type.`);
|
|
49
|
-
const currentUser = await UserModel.find(
|
|
49
|
+
const currentUser = await UserModel.find(decoded.id);
|
|
50
50
|
if (!currentUser) {
|
|
51
|
-
accessToken.destroy();
|
|
51
|
+
await accessToken.destroy();
|
|
52
52
|
return response.unauthorized({
|
|
53
53
|
error: t("auth.errors.invalidAccessToken"),
|
|
54
54
|
errorCode: "EC002"
|
|
55
55
|
});
|
|
56
56
|
}
|
|
57
57
|
request.user = currentUser;
|
|
58
|
-
} catch (
|
|
59
|
-
log.error("http", "auth",
|
|
58
|
+
} catch (error) {
|
|
59
|
+
log.error("http", "auth", error);
|
|
60
60
|
request.clearCurrentUser();
|
|
61
61
|
return response.unauthorized({
|
|
62
62
|
error: t("auth.errors.invalidAccessToken"),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.middleware.mjs","names":[],"sources":["
|
|
1
|
+
{"version":3,"file":"auth.middleware.mjs","names":[],"sources":["../../../../../../../@warlock.js/auth/src/middleware/auth.middleware.ts"],"sourcesContent":["import { config, t, type Middleware, type Request, type Response } from \"@warlock.js/core\";\nimport { log } from \"@warlock.js/logger\";\nimport { AccessToken } from \"../models/access-token\";\nimport { jwt } from \"../services/jwt\";\nimport { AuthErrorCodes } from \"../utils/auth-error-codes\";\n\n/**\n * Decoded access-token claims the middleware reads. The full payload carries\n * more (`created_at`, `tokenType`, `iat`, `exp`) but only these drive routing.\n */\ntype DecodedAccessToken = {\n id: string | number;\n userType?: string;\n};\n\n/**\n * Build a route gate that always requires an authenticated request.\n *\n * The argument is mandatory and selects which user types may pass:\n * - `[]` — any authenticated user (token required, type not checked).\n * - `\"admin\"` / `[\"admin\", \"staff\"]` — token required AND the user's\n * `userType` must be one of the listed types.\n *\n * There is no anonymous/optional mode: a request without a valid access\n * token is always rejected with `401`. Routes that should be public\n * simply omit the middleware.\n *\n * @example\n * router.get(\"/account\", authMiddleware([]), accountController);\n * router.get(\"/admin\", authMiddleware(\"admin\"), adminController);\n * router.get(\"/back-office\", authMiddleware([\"admin\", \"staff\"]), backOfficeController);\n */\nexport function authMiddleware(allowedUserType: string | string[]) {\n const allowedTypes = Array.isArray(allowedUserType) ? allowedUserType : [allowedUserType];\n\n const auth: Middleware = async (request: Request, response: Response) => {\n try {\n const authorizationValue = request.authorizationValue;\n\n if (!authorizationValue) {\n return response.unauthorized({\n error: t(\"auth.errors.missingAccessToken\"),\n errorCode: AuthErrorCodes.MissingAccessToken,\n });\n }\n\n const decoded = await jwt.verify<DecodedAccessToken>(authorizationValue);\n\n request.decodedAccessToken = decoded;\n\n // A valid signature is not enough — the token must still exist in storage,\n // so deleting the row (logout) invalidates it before its JWT expiry.\n const AccessTokenModel = config.key(\"auth.accessToken.model\", AccessToken);\n const accessToken = await AccessTokenModel.findByToken(authorizationValue);\n\n if (!accessToken) {\n return response.unauthorized({\n error: t(\"auth.errors.invalidAccessToken\"),\n errorCode: AuthErrorCodes.InvalidAccessToken,\n });\n }\n\n const userType = decoded.userType ?? accessToken.userType;\n\n if (allowedTypes.length && !allowedTypes.includes(userType)) {\n return response.unauthorized({\n error: t(\"auth.errors.unauthorized\"),\n errorCode: AuthErrorCodes.Unauthorized,\n });\n }\n\n const UserModel = config.key(`auth.userType.${userType}`);\n\n if (!UserModel) {\n throw new Error(`User type ${userType} is unknown type.`);\n }\n\n const currentUser = await UserModel.find(decoded.id);\n\n if (!currentUser) {\n await accessToken.destroy();\n\n return response.unauthorized({\n error: t(\"auth.errors.invalidAccessToken\"),\n errorCode: AuthErrorCodes.InvalidAccessToken,\n });\n }\n\n request.user = currentUser;\n } catch (error: any) {\n log.error(\"http\", \"auth\", error);\n\n request.clearCurrentUser();\n\n return response.unauthorized({\n error: t(\"auth.errors.invalidAccessToken\"),\n errorCode: AuthErrorCodes.InvalidAccessToken,\n });\n }\n };\n\n return auth;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;AAgCA,SAAgB,eAAe,iBAAoC;CACjE,MAAM,eAAe,MAAM,QAAQ,eAAe,IAAI,kBAAkB,CAAC,eAAe;CAExF,MAAM,OAAmB,OAAO,SAAkB,aAAuB;EACvE,IAAI;GACF,MAAM,qBAAqB,QAAQ;GAEnC,IAAI,CAAC,oBACH,OAAO,SAAS,aAAa;IAC3B,OAAO,EAAE,gCAAgC;IACzC;GACF,CAAC;GAGH,MAAM,UAAU,MAAM,IAAI,OAA2B,kBAAkB;GAEvE,QAAQ,qBAAqB;GAK7B,MAAM,cAAc,MADK,OAAO,IAAI,0BAA0B,WACrB,CAAC,CAAC,YAAY,kBAAkB;GAEzE,IAAI,CAAC,aACH,OAAO,SAAS,aAAa;IAC3B,OAAO,EAAE,gCAAgC;IACzC;GACF,CAAC;GAGH,MAAM,WAAW,QAAQ,YAAY,YAAY;GAEjD,IAAI,aAAa,UAAU,CAAC,aAAa,SAAS,QAAQ,GACxD,OAAO,SAAS,aAAa;IAC3B,OAAO,EAAE,0BAA0B;IACnC;GACF,CAAC;GAGH,MAAM,YAAY,OAAO,IAAI,iBAAiB,UAAU;GAExD,IAAI,CAAC,WACH,MAAM,IAAI,MAAM,aAAa,SAAS,kBAAkB;GAG1D,MAAM,cAAc,MAAM,UAAU,KAAK,QAAQ,EAAE;GAEnD,IAAI,CAAC,aAAa;IAChB,MAAM,YAAY,QAAQ;IAE1B,OAAO,SAAS,aAAa;KAC3B,OAAO,EAAE,gCAAgC;KACzC;IACF,CAAC;GACH;GAEA,QAAQ,OAAO;EACjB,SAAS,OAAY;GACnB,IAAI,MAAM,QAAQ,QAAQ,KAAK;GAE/B,QAAQ,iBAAiB;GAEzB,OAAO,SAAS,aAAa;IAC3B,OAAO,EAAE,gCAAgC;IACzC;GACF,CAAC;EACH;CACF;CAEA,OAAO;AACT"}
|
package/esm/middleware/index.mjs
CHANGED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { Middleware, Request, Response } from "@warlock.js/core";
|
|
2
|
+
|
|
3
|
+
//#region ../@warlock.js/auth/src/middleware/login-throttle.middleware.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Built-in identifier kinds the throttle knows how to extract from a request.
|
|
6
|
+
*
|
|
7
|
+
* - `"email"` tracks per-account, so an attacker can't spray one user from
|
|
8
|
+
* many IPs.
|
|
9
|
+
* - `"ip"` tracks per-source, so an attacker can't stuff many accounts from
|
|
10
|
+
* one IP.
|
|
11
|
+
*
|
|
12
|
+
* Lockout trips when ANY tracked identifier crosses `max` — defense-in-depth.
|
|
13
|
+
*/
|
|
14
|
+
type LoginThrottleIdentifier = "email" | "ip";
|
|
15
|
+
/**
|
|
16
|
+
* Options for {@link loginThrottleMiddleware}.
|
|
17
|
+
*
|
|
18
|
+
* Failure-aware by design: only counts requests whose response is non-2xx
|
|
19
|
+
* (or whatever `isFailure` says) and resets on success, so a user who types
|
|
20
|
+
* a wrong password three times then the right one is not penalised.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* loginThrottleMiddleware({ max: 5, window: "15m", lockoutDuration: "15m" });
|
|
24
|
+
*/
|
|
25
|
+
type LoginThrottleOptions = {
|
|
26
|
+
/**
|
|
27
|
+
* Failures allowed within the window before the identifier is locked.
|
|
28
|
+
*
|
|
29
|
+
* @default 5
|
|
30
|
+
*/
|
|
31
|
+
max?: number;
|
|
32
|
+
/**
|
|
33
|
+
* Counting window. `ms`-format string (`"15m"`, `"1h"`) or seconds as a number.
|
|
34
|
+
*
|
|
35
|
+
* @default "15m"
|
|
36
|
+
*/
|
|
37
|
+
window?: string | number;
|
|
38
|
+
/**
|
|
39
|
+
* Lockout duration once the threshold is tripped. `ms`-format string or seconds.
|
|
40
|
+
*
|
|
41
|
+
* @default "15m"
|
|
42
|
+
*/
|
|
43
|
+
lockoutDuration?: string | number;
|
|
44
|
+
/**
|
|
45
|
+
* Identifiers tracked. Each is counted and locked independently.
|
|
46
|
+
*
|
|
47
|
+
* @default ["email", "ip"]
|
|
48
|
+
*/
|
|
49
|
+
by?: LoginThrottleIdentifier[];
|
|
50
|
+
/**
|
|
51
|
+
* Credential field consulted when `"email"` is tracked. Switch to
|
|
52
|
+
* `"username"` (or any other input field) for systems that don't
|
|
53
|
+
* authenticate by email.
|
|
54
|
+
*
|
|
55
|
+
* @default "email"
|
|
56
|
+
*/
|
|
57
|
+
identifierKey?: string;
|
|
58
|
+
/**
|
|
59
|
+
* Override the default error message returned with the 429 response.
|
|
60
|
+
*/
|
|
61
|
+
errorMessage?: string;
|
|
62
|
+
/**
|
|
63
|
+
* Decide whether the response represents a failed login. Defaults to
|
|
64
|
+
* "non-2xx is a failure"; override if your controller signals failure
|
|
65
|
+
* through a 200-style body.
|
|
66
|
+
*
|
|
67
|
+
* @default (response) => !response.isOk
|
|
68
|
+
*/
|
|
69
|
+
isFailure?: (response: Response) => boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Compute the identifiers tracked for this request. Override to derive the
|
|
72
|
+
* account key from a header, a JWT subject, or any other shape that does
|
|
73
|
+
* not match the built-in `email` / `ip` extraction.
|
|
74
|
+
*/
|
|
75
|
+
identify?: (request: Request) => string[];
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Failure-aware login throttle. Locks an identifier (account, IP, or both)
|
|
79
|
+
* after too many failed login attempts within a window — without penalising
|
|
80
|
+
* a user who eventually types the right password.
|
|
81
|
+
*
|
|
82
|
+
* Mechanism (per identifier):
|
|
83
|
+
*
|
|
84
|
+
* 1. **Before the controller** — reject with `429` if a lock key is set,
|
|
85
|
+
* skipping the DB lookup AND the bcrypt verify (this also neutralises the
|
|
86
|
+
* CPU-DoS angle of brute-forcing).
|
|
87
|
+
* 2. **After the controller** — observe the outcome via `response.onSent`.
|
|
88
|
+
* On failure (non-2xx by default), bump a fixed-window counter; once it
|
|
89
|
+
* reaches `max`, set a lock key with `lockoutDuration` TTL. On success,
|
|
90
|
+
* clear both the counter and the lock for that identifier.
|
|
91
|
+
*
|
|
92
|
+
* Storage is `@warlock.js/cache` (a peer dependency of `@warlock.js/auth`,
|
|
93
|
+
* transitively guaranteed via `@warlock.js/core`). Lockout state is shared
|
|
94
|
+
* across replicas — correct security model, because an attacker can't bypass
|
|
95
|
+
* by round-robin to a different pod. Every cache call is guarded so a
|
|
96
|
+
* misconfigured driver fails open (a throttle outage must never escalate
|
|
97
|
+
* into a full auth outage).
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* import { loginThrottleMiddleware } from "@warlock.js/auth";
|
|
101
|
+
* import { router } from "@warlock.js/core";
|
|
102
|
+
*
|
|
103
|
+
* router.post("/auth/login", loginController, {
|
|
104
|
+
* middleware: [loginThrottleMiddleware({ max: 5, window: "15m" })],
|
|
105
|
+
* });
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* // Per-IP only — avoid account-lockout-as-DoS on anonymous-attack vectors
|
|
109
|
+
* router.post("/auth/refresh-token", refreshController, {
|
|
110
|
+
* middleware: [loginThrottleMiddleware({ by: ["ip"], max: 10, window: "1m" })],
|
|
111
|
+
* });
|
|
112
|
+
*/
|
|
113
|
+
declare function loginThrottleMiddleware(options?: LoginThrottleOptions): Middleware;
|
|
114
|
+
//#endregion
|
|
115
|
+
export { LoginThrottleIdentifier, LoginThrottleOptions, loginThrottleMiddleware };
|
|
116
|
+
//# sourceMappingURL=login-throttle.middleware.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"login-throttle.middleware.d.mts","names":[],"sources":["../../../../../../../@warlock.js/auth/src/middleware/login-throttle.middleware.ts"],"mappings":";;;;;AAeA;;;;AAAmC;AAYnC;;;KAZY,uBAAA;;;;;;;;;;;KAYA,oBAAA;EAkDV;;;;;EA5CA,GAAA;EAmD4B;AAAA;AA6E9B;;;EAzHE,MAAA;EAyH+C;;;;AAAsC;EAlHrF,eAAA;;;;;;EAOA,EAAA,GAAK,uBAAA;;;;;;;;EASL,aAAA;;;;EAKA,YAAA;;;;;;;;EASA,SAAA,IAAa,QAAA,EAAU,QAAA;;;;;;EAOvB,QAAA,IAAY,OAAA,EAAS,OAAA;AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBA6EP,uBAAA,CAAwB,OAAA,GAAS,oBAAA,GAA4B,UAAU"}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { AuthErrorCodes } from "../utils/auth-error-codes.mjs";
|
|
2
|
+
import { t } from "@warlock.js/core";
|
|
3
|
+
import { log } from "@warlock.js/logger";
|
|
4
|
+
import { cache } from "@warlock.js/cache";
|
|
5
|
+
|
|
6
|
+
//#region ../@warlock.js/auth/src/middleware/login-throttle.middleware.ts
|
|
7
|
+
const COUNTER_PREFIX = "auth.throttle.count.";
|
|
8
|
+
const LOCK_PREFIX = "auth.throttle.lock.";
|
|
9
|
+
const counterKey = (identifier) => `${COUNTER_PREFIX}${identifier}`;
|
|
10
|
+
const lockKey = (identifier) => `${LOCK_PREFIX}${identifier}`;
|
|
11
|
+
/**
|
|
12
|
+
* Built-in identifier extractor — reads the configured credential field from
|
|
13
|
+
* the request body for the per-account key, and `request.detectIp()` for the
|
|
14
|
+
* per-source key. Untruthy values are dropped; a request that yields zero
|
|
15
|
+
* identifiers is left untracked so the middleware no-ops for it.
|
|
16
|
+
*/
|
|
17
|
+
function buildDefaultIdentifiers(request, tracked, credentialField) {
|
|
18
|
+
const identifiers = [];
|
|
19
|
+
if (tracked.includes("email")) {
|
|
20
|
+
const value = request.input(credentialField);
|
|
21
|
+
if (typeof value === "string" && value.length > 0) identifiers.push(`email.${value.toLowerCase()}`);
|
|
22
|
+
}
|
|
23
|
+
if (tracked.includes("ip")) {
|
|
24
|
+
const ip = request.detectIp();
|
|
25
|
+
if (ip) identifiers.push(`ip.${ip}`);
|
|
26
|
+
}
|
|
27
|
+
return identifiers;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Failure-aware login throttle. Locks an identifier (account, IP, or both)
|
|
31
|
+
* after too many failed login attempts within a window — without penalising
|
|
32
|
+
* a user who eventually types the right password.
|
|
33
|
+
*
|
|
34
|
+
* Mechanism (per identifier):
|
|
35
|
+
*
|
|
36
|
+
* 1. **Before the controller** — reject with `429` if a lock key is set,
|
|
37
|
+
* skipping the DB lookup AND the bcrypt verify (this also neutralises the
|
|
38
|
+
* CPU-DoS angle of brute-forcing).
|
|
39
|
+
* 2. **After the controller** — observe the outcome via `response.onSent`.
|
|
40
|
+
* On failure (non-2xx by default), bump a fixed-window counter; once it
|
|
41
|
+
* reaches `max`, set a lock key with `lockoutDuration` TTL. On success,
|
|
42
|
+
* clear both the counter and the lock for that identifier.
|
|
43
|
+
*
|
|
44
|
+
* Storage is `@warlock.js/cache` (a peer dependency of `@warlock.js/auth`,
|
|
45
|
+
* transitively guaranteed via `@warlock.js/core`). Lockout state is shared
|
|
46
|
+
* across replicas — correct security model, because an attacker can't bypass
|
|
47
|
+
* by round-robin to a different pod. Every cache call is guarded so a
|
|
48
|
+
* misconfigured driver fails open (a throttle outage must never escalate
|
|
49
|
+
* into a full auth outage).
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* import { loginThrottleMiddleware } from "@warlock.js/auth";
|
|
53
|
+
* import { router } from "@warlock.js/core";
|
|
54
|
+
*
|
|
55
|
+
* router.post("/auth/login", loginController, {
|
|
56
|
+
* middleware: [loginThrottleMiddleware({ max: 5, window: "15m" })],
|
|
57
|
+
* });
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* // Per-IP only — avoid account-lockout-as-DoS on anonymous-attack vectors
|
|
61
|
+
* router.post("/auth/refresh-token", refreshController, {
|
|
62
|
+
* middleware: [loginThrottleMiddleware({ by: ["ip"], max: 10, window: "1m" })],
|
|
63
|
+
* });
|
|
64
|
+
*/
|
|
65
|
+
function loginThrottleMiddleware(options = {}) {
|
|
66
|
+
const max = options.max ?? 5;
|
|
67
|
+
const window = options.window ?? "15m";
|
|
68
|
+
const lockoutDuration = options.lockoutDuration ?? "15m";
|
|
69
|
+
const tracked = options.by ?? ["email", "ip"];
|
|
70
|
+
const credentialField = options.identifierKey ?? "email";
|
|
71
|
+
return async (request, response) => {
|
|
72
|
+
const identifiers = options.identify ? options.identify(request) : buildDefaultIdentifiers(request, tracked, credentialField);
|
|
73
|
+
if (identifiers.length === 0) return;
|
|
74
|
+
try {
|
|
75
|
+
for (const identifier of identifiers) if (await cache.get(lockKey(identifier))) return response.tooManyRequests({
|
|
76
|
+
error: options.errorMessage ?? t("auth.errors.tooManyAttempts"),
|
|
77
|
+
errorCode: "EC004"
|
|
78
|
+
});
|
|
79
|
+
} catch (error) {
|
|
80
|
+
log.error("auth", "login-throttle", error);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
response.onSent(async (resolvedResponse) => {
|
|
84
|
+
try {
|
|
85
|
+
if (!(options.isFailure ? options.isFailure(resolvedResponse) : !resolvedResponse.isOk)) {
|
|
86
|
+
for (const identifier of identifiers) {
|
|
87
|
+
await cache.remove(counterKey(identifier));
|
|
88
|
+
await cache.remove(lockKey(identifier));
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
for (const identifier of identifiers) {
|
|
93
|
+
const ttlOption = await cache.get(counterKey(identifier)) === null ? { ttl: window } : void 0;
|
|
94
|
+
if ((await cache.update(counterKey(identifier), (current) => (current ?? 0) + 1, ttlOption) ?? 0) >= max) await cache.set(lockKey(identifier), true, { ttl: lockoutDuration });
|
|
95
|
+
}
|
|
96
|
+
} catch (error) {
|
|
97
|
+
log.error("auth", "login-throttle", error);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
//#endregion
|
|
104
|
+
export { loginThrottleMiddleware };
|
|
105
|
+
//# sourceMappingURL=login-throttle.middleware.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"login-throttle.middleware.mjs","names":[],"sources":["../../../../../../../@warlock.js/auth/src/middleware/login-throttle.middleware.ts"],"sourcesContent":["import { cache } from \"@warlock.js/cache\";\nimport { t, type Middleware, type Request, type Response } from \"@warlock.js/core\";\nimport { log } from \"@warlock.js/logger\";\nimport { AuthErrorCodes } from \"../utils/auth-error-codes\";\n\n/**\n * Built-in identifier kinds the throttle knows how to extract from a request.\n *\n * - `\"email\"` tracks per-account, so an attacker can't spray one user from\n * many IPs.\n * - `\"ip\"` tracks per-source, so an attacker can't stuff many accounts from\n * one IP.\n *\n * Lockout trips when ANY tracked identifier crosses `max` — defense-in-depth.\n */\nexport type LoginThrottleIdentifier = \"email\" | \"ip\";\n\n/**\n * Options for {@link loginThrottleMiddleware}.\n *\n * Failure-aware by design: only counts requests whose response is non-2xx\n * (or whatever `isFailure` says) and resets on success, so a user who types\n * a wrong password three times then the right one is not penalised.\n *\n * @example\n * loginThrottleMiddleware({ max: 5, window: \"15m\", lockoutDuration: \"15m\" });\n */\nexport type LoginThrottleOptions = {\n /**\n * Failures allowed within the window before the identifier is locked.\n *\n * @default 5\n */\n max?: number;\n\n /**\n * Counting window. `ms`-format string (`\"15m\"`, `\"1h\"`) or seconds as a number.\n *\n * @default \"15m\"\n */\n window?: string | number;\n\n /**\n * Lockout duration once the threshold is tripped. `ms`-format string or seconds.\n *\n * @default \"15m\"\n */\n lockoutDuration?: string | number;\n\n /**\n * Identifiers tracked. Each is counted and locked independently.\n *\n * @default [\"email\", \"ip\"]\n */\n by?: LoginThrottleIdentifier[];\n\n /**\n * Credential field consulted when `\"email\"` is tracked. Switch to\n * `\"username\"` (or any other input field) for systems that don't\n * authenticate by email.\n *\n * @default \"email\"\n */\n identifierKey?: string;\n\n /**\n * Override the default error message returned with the 429 response.\n */\n errorMessage?: string;\n\n /**\n * Decide whether the response represents a failed login. Defaults to\n * \"non-2xx is a failure\"; override if your controller signals failure\n * through a 200-style body.\n *\n * @default (response) => !response.isOk\n */\n isFailure?: (response: Response) => boolean;\n\n /**\n * Compute the identifiers tracked for this request. Override to derive the\n * account key from a header, a JWT subject, or any other shape that does\n * not match the built-in `email` / `ip` extraction.\n */\n identify?: (request: Request) => string[];\n};\n\nconst COUNTER_PREFIX = \"auth.throttle.count.\";\nconst LOCK_PREFIX = \"auth.throttle.lock.\";\n\nconst counterKey = (identifier: string): string => `${COUNTER_PREFIX}${identifier}`;\nconst lockKey = (identifier: string): string => `${LOCK_PREFIX}${identifier}`;\n\n/**\n * Built-in identifier extractor — reads the configured credential field from\n * the request body for the per-account key, and `request.detectIp()` for the\n * per-source key. Untruthy values are dropped; a request that yields zero\n * identifiers is left untracked so the middleware no-ops for it.\n */\nfunction buildDefaultIdentifiers(\n request: Request,\n tracked: LoginThrottleIdentifier[],\n credentialField: string,\n): string[] {\n const identifiers: string[] = [];\n\n if (tracked.includes(\"email\")) {\n const value = request.input(credentialField);\n\n if (typeof value === \"string\" && value.length > 0) {\n identifiers.push(`email.${value.toLowerCase()}`);\n }\n }\n\n if (tracked.includes(\"ip\")) {\n const ip = request.detectIp();\n\n if (ip) {\n identifiers.push(`ip.${ip}`);\n }\n }\n\n return identifiers;\n}\n\n/**\n * Failure-aware login throttle. Locks an identifier (account, IP, or both)\n * after too many failed login attempts within a window — without penalising\n * a user who eventually types the right password.\n *\n * Mechanism (per identifier):\n *\n * 1. **Before the controller** — reject with `429` if a lock key is set,\n * skipping the DB lookup AND the bcrypt verify (this also neutralises the\n * CPU-DoS angle of brute-forcing).\n * 2. **After the controller** — observe the outcome via `response.onSent`.\n * On failure (non-2xx by default), bump a fixed-window counter; once it\n * reaches `max`, set a lock key with `lockoutDuration` TTL. On success,\n * clear both the counter and the lock for that identifier.\n *\n * Storage is `@warlock.js/cache` (a peer dependency of `@warlock.js/auth`,\n * transitively guaranteed via `@warlock.js/core`). Lockout state is shared\n * across replicas — correct security model, because an attacker can't bypass\n * by round-robin to a different pod. Every cache call is guarded so a\n * misconfigured driver fails open (a throttle outage must never escalate\n * into a full auth outage).\n *\n * @example\n * import { loginThrottleMiddleware } from \"@warlock.js/auth\";\n * import { router } from \"@warlock.js/core\";\n *\n * router.post(\"/auth/login\", loginController, {\n * middleware: [loginThrottleMiddleware({ max: 5, window: \"15m\" })],\n * });\n *\n * @example\n * // Per-IP only — avoid account-lockout-as-DoS on anonymous-attack vectors\n * router.post(\"/auth/refresh-token\", refreshController, {\n * middleware: [loginThrottleMiddleware({ by: [\"ip\"], max: 10, window: \"1m\" })],\n * });\n */\nexport function loginThrottleMiddleware(options: LoginThrottleOptions = {}): Middleware {\n const max = options.max ?? 5;\n const window = options.window ?? \"15m\";\n const lockoutDuration = options.lockoutDuration ?? \"15m\";\n const tracked = options.by ?? [\"email\", \"ip\"];\n const credentialField = options.identifierKey ?? \"email\";\n\n return async (request: Request, response: Response) => {\n const identifiers = options.identify\n ? options.identify(request)\n : buildDefaultIdentifiers(request, tracked, credentialField);\n\n if (identifiers.length === 0) return;\n\n try {\n for (const identifier of identifiers) {\n const locked = await cache.get(lockKey(identifier));\n\n if (locked) {\n return response.tooManyRequests({\n error: options.errorMessage ?? t(\"auth.errors.tooManyAttempts\"),\n errorCode: AuthErrorCodes.TooManyAttempts,\n });\n }\n }\n } catch (error) {\n // Fail open — a cache outage (driver not initialised, network blip)\n // must never escalate into a full auth outage. Log and let the request\n // through; the onSent recorder below is independently guarded so an\n // outage there is also harmless.\n log.error(\"auth\", \"login-throttle\", error);\n\n return;\n }\n\n response.onSent(async (resolvedResponse: Response) => {\n try {\n const failed = options.isFailure\n ? options.isFailure(resolvedResponse)\n : !resolvedResponse.isOk;\n\n if (!failed) {\n for (const identifier of identifiers) {\n await cache.remove(counterKey(identifier));\n await cache.remove(lockKey(identifier));\n }\n\n return;\n }\n\n for (const identifier of identifiers) {\n const existing = await cache.get(counterKey(identifier));\n const ttlOption = existing === null ? { ttl: window } : undefined;\n const count = await cache.update<number>(\n counterKey(identifier),\n (current) => (current ?? 0) + 1,\n ttlOption,\n );\n\n if ((count ?? 0) >= max) {\n await cache.set(lockKey(identifier), true, { ttl: lockoutDuration });\n }\n }\n } catch (error) {\n log.error(\"auth\", \"login-throttle\", error);\n }\n });\n };\n}\n"],"mappings":";;;;;;AAuFA,MAAM,iBAAiB;AACvB,MAAM,cAAc;AAEpB,MAAM,cAAc,eAA+B,GAAG,iBAAiB;AACvE,MAAM,WAAW,eAA+B,GAAG,cAAc;;;;;;;AAQjE,SAAS,wBACP,SACA,SACA,iBACU;CACV,MAAM,cAAwB,CAAC;CAE/B,IAAI,QAAQ,SAAS,OAAO,GAAG;EAC7B,MAAM,QAAQ,QAAQ,MAAM,eAAe;EAE3C,IAAI,OAAO,UAAU,YAAY,MAAM,SAAS,GAC9C,YAAY,KAAK,SAAS,MAAM,YAAY,GAAG;CAEnD;CAEA,IAAI,QAAQ,SAAS,IAAI,GAAG;EAC1B,MAAM,KAAK,QAAQ,SAAS;EAE5B,IAAI,IACF,YAAY,KAAK,MAAM,IAAI;CAE/B;CAEA,OAAO;AACT;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsCA,SAAgB,wBAAwB,UAAgC,CAAC,GAAe;CACtF,MAAM,MAAM,QAAQ,OAAO;CAC3B,MAAM,SAAS,QAAQ,UAAU;CACjC,MAAM,kBAAkB,QAAQ,mBAAmB;CACnD,MAAM,UAAU,QAAQ,MAAM,CAAC,SAAS,IAAI;CAC5C,MAAM,kBAAkB,QAAQ,iBAAiB;CAEjD,OAAO,OAAO,SAAkB,aAAuB;EACrD,MAAM,cAAc,QAAQ,WACxB,QAAQ,SAAS,OAAO,IACxB,wBAAwB,SAAS,SAAS,eAAe;EAE7D,IAAI,YAAY,WAAW,GAAG;EAE9B,IAAI;GACF,KAAK,MAAM,cAAc,aAGvB,IAAI,MAFiB,MAAM,IAAI,QAAQ,UAAU,CAAC,GAGhD,OAAO,SAAS,gBAAgB;IAC9B,OAAO,QAAQ,gBAAgB,EAAE,6BAA6B;IAC9D;GACF,CAAC;EAGP,SAAS,OAAO;GAKd,IAAI,MAAM,QAAQ,kBAAkB,KAAK;GAEzC;EACF;EAEA,SAAS,OAAO,OAAO,qBAA+B;GACpD,IAAI;IAKF,IAAI,EAJW,QAAQ,YACnB,QAAQ,UAAU,gBAAgB,IAClC,CAAC,iBAAiB,OAET;KACX,KAAK,MAAM,cAAc,aAAa;MACpC,MAAM,MAAM,OAAO,WAAW,UAAU,CAAC;MACzC,MAAM,MAAM,OAAO,QAAQ,UAAU,CAAC;KACxC;KAEA;IACF;IAEA,KAAK,MAAM,cAAc,aAAa;KAEpC,MAAM,YAAY,MADK,MAAM,IAAI,WAAW,UAAU,CAAC,MACxB,OAAO,EAAE,KAAK,OAAO,IAAI;KAOxD,KAAK,MANe,MAAM,OACxB,WAAW,UAAU,IACpB,aAAa,WAAW,KAAK,GAC9B,SACF,KAEc,MAAM,KAClB,MAAM,MAAM,IAAI,QAAQ,UAAU,GAAG,MAAM,EAAE,KAAK,gBAAgB,CAAC;IAEvE;GACF,SAAS,OAAO;IACd,IAAI,MAAM,QAAQ,kBAAkB,KAAK;GAC3C;EACF,CAAC;CACH;AACF"}
|
|
@@ -1,13 +1,55 @@
|
|
|
1
|
+
import { Auth } from "../auth.model.mjs";
|
|
1
2
|
import { Model } from "@warlock.js/cascade";
|
|
2
3
|
|
|
3
|
-
//#region
|
|
4
|
+
//#region ../@warlock.js/auth/src/models/access-token/access-token.model.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Seal schema for the persisted access-token record. Exported so an override
|
|
7
|
+
* can spread it and add columns (e.g. a tenant key) without re-declaring the
|
|
8
|
+
* base shape.
|
|
9
|
+
*
|
|
10
|
+
* `last_access` and the `is_active` soft-revoke flag were removed — neither was
|
|
11
|
+
* ever read; access tokens are revoked by deleting the row.
|
|
12
|
+
*/
|
|
13
|
+
declare const accessTokenSchema: any;
|
|
14
|
+
/**
|
|
15
|
+
* Persisted access-token record + the data layer for access tokens.
|
|
16
|
+
*
|
|
17
|
+
* **Role.** Owns access-token persistence and lookup. The middleware checks a
|
|
18
|
+
* presented JWT against this table so deleting a row (logout) invalidates the
|
|
19
|
+
* token immediately, before its JWT expiry. The auth service goes through the
|
|
20
|
+
* named statics exclusively, so it never hard-codes a column name and an
|
|
21
|
+
* override can rename/add columns by registering under
|
|
22
|
+
* `config.auth.accessToken.model`.
|
|
23
|
+
*/
|
|
4
24
|
declare class AccessToken extends Model {
|
|
5
|
-
/**
|
|
6
|
-
* {@inheritDoc}
|
|
7
|
-
*/
|
|
8
25
|
static table: string;
|
|
9
26
|
static schema: any;
|
|
27
|
+
/** The user this token was issued for. */
|
|
28
|
+
get userId(): any;
|
|
29
|
+
/** The user-type slug this token was issued for. */
|
|
30
|
+
get userType(): string;
|
|
31
|
+
/**
|
|
32
|
+
* Persist a freshly-signed access token for the user.
|
|
33
|
+
*/
|
|
34
|
+
static issue(user: Auth, token: string, expiresAt: Date): any;
|
|
35
|
+
/**
|
|
36
|
+
* Find an access-token row by its raw token string.
|
|
37
|
+
*/
|
|
38
|
+
static findByToken(token: string): Promise<AccessToken | null>;
|
|
39
|
+
/**
|
|
40
|
+
* Delete a specific token that belongs to the given user.
|
|
41
|
+
*/
|
|
42
|
+
static deleteForUser(user: Auth, token: string): any;
|
|
43
|
+
/**
|
|
44
|
+
* Delete every access token belonging to the user.
|
|
45
|
+
*/
|
|
46
|
+
static deleteAllForUser(user: Auth): any;
|
|
47
|
+
/**
|
|
48
|
+
* Hard-delete every expired access-token row. Returns the number removed.
|
|
49
|
+
* Runs from the `auth.cleanup` CLI command (a cold batch path).
|
|
50
|
+
*/
|
|
51
|
+
static purgeExpired(): Promise<number>;
|
|
10
52
|
}
|
|
11
53
|
//#endregion
|
|
12
|
-
export { AccessToken };
|
|
54
|
+
export { AccessToken, accessTokenSchema };
|
|
13
55
|
//# sourceMappingURL=access-token.model.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"access-token.model.d.mts","names":[],"sources":["
|
|
1
|
+
{"version":3,"file":"access-token.model.d.mts","names":[],"sources":["../../../../../../../../@warlock.js/auth/src/models/access-token/access-token.model.ts"],"mappings":";;;;;;AAYA;;;;AAKE;AAYF;cAjBa,iBAAA;;;;;;;;;;;cAiBA,WAAA,SAAoB,KAAA;EAAA,OACjB,KAAA;EAAA,OAEA,MAAA;EAAA;EAAA,IAGH,MAAA;EAKA;EAAA,IAAA,QAAA;EAOe;;;EAAA,OAAZ,KAAA,CAAM,IAAA,EAAM,IAAA,EAAM,KAAA,UAAe,SAAA,EAAW,IAAA;EAAX;;;EAAA,OAYjC,WAAA,CAAY,KAAA,WAAgB,OAAA,CAAQ,WAAA;EAAA;;;EAAA,OAOpC,aAAA,CAAc,IAAA,EAAM,IAAA,EAAM,KAAA;EAAA;;;EAAA,OAO1B,gBAAA,CAAiB,IAAA,EAAM,IAAA;EAQjB;;;AAAuB;EAAvB,OAAA,YAAA,IAAgB,OAAA;AAAA"}
|
|
@@ -1,14 +1,31 @@
|
|
|
1
1
|
import { Model } from "@warlock.js/cascade";
|
|
2
2
|
import { v } from "@warlock.js/seal";
|
|
3
3
|
|
|
4
|
-
//#region
|
|
4
|
+
//#region ../@warlock.js/auth/src/models/access-token/access-token.model.ts
|
|
5
|
+
/**
|
|
6
|
+
* Seal schema for the persisted access-token record. Exported so an override
|
|
7
|
+
* can spread it and add columns (e.g. a tenant key) without re-declaring the
|
|
8
|
+
* base shape.
|
|
9
|
+
*
|
|
10
|
+
* `last_access` and the `is_active` soft-revoke flag were removed — neither was
|
|
11
|
+
* ever read; access tokens are revoked by deleting the row.
|
|
12
|
+
*/
|
|
5
13
|
const accessTokenSchema = v.object({
|
|
6
14
|
token: v.string().required(),
|
|
7
|
-
last_access: v.date().defaultNow().optional(),
|
|
8
15
|
user_id: v.scalar().required(),
|
|
9
16
|
user_type: v.string().required(),
|
|
10
|
-
|
|
17
|
+
expires_at: v.date().required()
|
|
11
18
|
});
|
|
19
|
+
/**
|
|
20
|
+
* Persisted access-token record + the data layer for access tokens.
|
|
21
|
+
*
|
|
22
|
+
* **Role.** Owns access-token persistence and lookup. The middleware checks a
|
|
23
|
+
* presented JWT against this table so deleting a row (logout) invalidates the
|
|
24
|
+
* token immediately, before its JWT expiry. The auth service goes through the
|
|
25
|
+
* named statics exclusively, so it never hard-codes a column name and an
|
|
26
|
+
* override can rename/add columns by registering under
|
|
27
|
+
* `config.auth.accessToken.model`.
|
|
28
|
+
*/
|
|
12
29
|
var AccessToken = class extends Model {
|
|
13
30
|
static {
|
|
14
31
|
this.table = "access_tokens";
|
|
@@ -16,8 +33,57 @@ var AccessToken = class extends Model {
|
|
|
16
33
|
static {
|
|
17
34
|
this.schema = accessTokenSchema;
|
|
18
35
|
}
|
|
36
|
+
/** The user this token was issued for. */
|
|
37
|
+
get userId() {
|
|
38
|
+
return this.get("user_id");
|
|
39
|
+
}
|
|
40
|
+
/** The user-type slug this token was issued for. */
|
|
41
|
+
get userType() {
|
|
42
|
+
return this.get("user_type");
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Persist a freshly-signed access token for the user.
|
|
46
|
+
*/
|
|
47
|
+
static issue(user, token, expiresAt) {
|
|
48
|
+
return this.create({
|
|
49
|
+
token,
|
|
50
|
+
user_id: user.id,
|
|
51
|
+
user_type: user.userType,
|
|
52
|
+
expires_at: expiresAt
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Find an access-token row by its raw token string.
|
|
57
|
+
*/
|
|
58
|
+
static findByToken(token) {
|
|
59
|
+
return this.first({ token });
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Delete a specific token that belongs to the given user.
|
|
63
|
+
*/
|
|
64
|
+
static deleteForUser(user, token) {
|
|
65
|
+
return this.delete({
|
|
66
|
+
token,
|
|
67
|
+
user_id: user.id
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Delete every access token belonging to the user.
|
|
72
|
+
*/
|
|
73
|
+
static deleteAllForUser(user) {
|
|
74
|
+
return this.delete({ user_id: user.id });
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Hard-delete every expired access-token row. Returns the number removed.
|
|
78
|
+
* Runs from the `auth.cleanup` CLI command (a cold batch path).
|
|
79
|
+
*/
|
|
80
|
+
static async purgeExpired() {
|
|
81
|
+
const expiredTokens = await this.query().where("expires_at", "<", /* @__PURE__ */ new Date()).get();
|
|
82
|
+
for (const token of expiredTokens) await token.destroy();
|
|
83
|
+
return expiredTokens.length;
|
|
84
|
+
}
|
|
19
85
|
};
|
|
20
86
|
|
|
21
87
|
//#endregion
|
|
22
|
-
export { AccessToken };
|
|
88
|
+
export { AccessToken, accessTokenSchema };
|
|
23
89
|
//# sourceMappingURL=access-token.model.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"access-token.model.mjs","names":[],"sources":["
|
|
1
|
+
{"version":3,"file":"access-token.model.mjs","names":[],"sources":["../../../../../../../../@warlock.js/auth/src/models/access-token/access-token.model.ts"],"sourcesContent":["import { Model } from \"@warlock.js/cascade\";\nimport { v } from \"@warlock.js/seal\";\nimport type { Auth } from \"../auth.model\";\n\n/**\n * Seal schema for the persisted access-token record. Exported so an override\n * can spread it and add columns (e.g. a tenant key) without re-declaring the\n * base shape.\n *\n * `last_access` and the `is_active` soft-revoke flag were removed — neither was\n * ever read; access tokens are revoked by deleting the row.\n */\nexport const accessTokenSchema = v.object({\n token: v.string().required(),\n user_id: v.scalar().required(),\n user_type: v.string().required(),\n expires_at: v.date().required(),\n});\n\n/**\n * Persisted access-token record + the data layer for access tokens.\n *\n * **Role.** Owns access-token persistence and lookup. The middleware checks a\n * presented JWT against this table so deleting a row (logout) invalidates the\n * token immediately, before its JWT expiry. The auth service goes through the\n * named statics exclusively, so it never hard-codes a column name and an\n * override can rename/add columns by registering under\n * `config.auth.accessToken.model`.\n */\nexport class AccessToken extends Model {\n public static table = \"access_tokens\";\n\n public static schema = accessTokenSchema;\n\n /** The user this token was issued for. */\n public get userId() {\n return this.get(\"user_id\");\n }\n\n /** The user-type slug this token was issued for. */\n public get userType(): string {\n return this.get(\"user_type\");\n }\n\n /**\n * Persist a freshly-signed access token for the user.\n */\n public static issue(user: Auth, token: string, expiresAt: Date) {\n return this.create({\n token,\n user_id: user.id,\n user_type: user.userType,\n expires_at: expiresAt,\n });\n }\n\n /**\n * Find an access-token row by its raw token string.\n */\n public static findByToken(token: string): Promise<AccessToken | null> {\n return this.first({ token });\n }\n\n /**\n * Delete a specific token that belongs to the given user.\n */\n public static deleteForUser(user: Auth, token: string) {\n return this.delete({ token, user_id: user.id });\n }\n\n /**\n * Delete every access token belonging to the user.\n */\n public static deleteAllForUser(user: Auth) {\n return this.delete({ user_id: user.id });\n }\n\n /**\n * Hard-delete every expired access-token row. Returns the number removed.\n * Runs from the `auth.cleanup` CLI command (a cold batch path).\n */\n public static async purgeExpired(): Promise<number> {\n const expiredTokens = await this.query().where(\"expires_at\", \"<\", new Date()).get();\n\n for (const token of expiredTokens) {\n await token.destroy();\n }\n\n return expiredTokens.length;\n }\n}\n"],"mappings":";;;;;;;;;;;;AAYA,MAAa,oBAAoB,EAAE,OAAO;CACxC,OAAO,EAAE,OAAO,CAAC,CAAC,SAAS;CAC3B,SAAS,EAAE,OAAO,CAAC,CAAC,SAAS;CAC7B,WAAW,EAAE,OAAO,CAAC,CAAC,SAAS;CAC/B,YAAY,EAAE,KAAK,CAAC,CAAC,SAAS;AAChC,CAAC;;;;;;;;;;;AAYD,IAAa,cAAb,cAAiC,MAAM;;eACf;;;gBAEC;;;CAGvB,IAAW,SAAS;EAClB,OAAO,KAAK,IAAI,SAAS;CAC3B;;CAGA,IAAW,WAAmB;EAC5B,OAAO,KAAK,IAAI,WAAW;CAC7B;;;;CAKA,OAAc,MAAM,MAAY,OAAe,WAAiB;EAC9D,OAAO,KAAK,OAAO;GACjB;GACA,SAAS,KAAK;GACd,WAAW,KAAK;GAChB,YAAY;EACd,CAAC;CACH;;;;CAKA,OAAc,YAAY,OAA4C;EACpE,OAAO,KAAK,MAAM,EAAE,MAAM,CAAC;CAC7B;;;;CAKA,OAAc,cAAc,MAAY,OAAe;EACrD,OAAO,KAAK,OAAO;GAAE;GAAO,SAAS,KAAK;EAAG,CAAC;CAChD;;;;CAKA,OAAc,iBAAiB,MAAY;EACzC,OAAO,KAAK,OAAO,EAAE,SAAS,KAAK,GAAG,CAAC;CACzC;;;;;CAMA,aAAoB,eAAgC;EAClD,MAAM,gBAAgB,MAAM,KAAK,MAAM,CAAC,CAAC,MAAM,cAAc,qBAAK,IAAI,KAAK,CAAC,CAAC,CAAC,IAAI;EAElF,KAAK,MAAM,SAAS,eAClB,MAAM,MAAM,QAAQ;EAGtB,OAAO,cAAc;CACvB;AACF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import { AccessToken } from "./access-token.model.mjs";
|
|
1
|
+
import { AccessToken, accessTokenSchema } from "./access-token.model.mjs";
|
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
import { AccessToken } from "./access-token.model.mjs";
|
|
2
2
|
import { migrate } from "@warlock.js/cascade";
|
|
3
3
|
|
|
4
|
-
//#region
|
|
4
|
+
//#region ../@warlock.js/auth/src/models/access-token/migration.ts
|
|
5
5
|
const AccessTokenMigration = migrate(AccessToken, {
|
|
6
6
|
name: "accessToken",
|
|
7
7
|
up() {
|
|
8
8
|
this.createTableIfNotExists();
|
|
9
9
|
this.primaryUuid();
|
|
10
10
|
this.text("token").unique();
|
|
11
|
-
this.timestamp("last_access").nullable();
|
|
12
11
|
this.uuid("user_id").index();
|
|
13
12
|
this.string("user_type", 50).nullable();
|
|
14
|
-
this.
|
|
13
|
+
this.timestamp("expires_at").index().nullable();
|
|
15
14
|
this.timestamps();
|
|
16
15
|
},
|
|
17
16
|
down() {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"migration.mjs","names":[],"sources":["
|
|
1
|
+
{"version":3,"file":"migration.mjs","names":[],"sources":["../../../../../../../../@warlock.js/auth/src/models/access-token/migration.ts"],"sourcesContent":["import { migrate } from \"@warlock.js/cascade\";\nimport { AccessToken } from \"./access-token.model\";\n\nexport const AccessTokenMigration = migrate(AccessToken, {\n name: \"accessToken\",\n up() {\n // Create table\n this.createTableIfNotExists();\n\n // Primary key\n this.primaryUuid();\n\n // Token field\n this.text(\"token\").unique();\n\n // User reference (flat columns for cross-driver compatibility)\n this.uuid(\"user_id\").index();\n this.string(\"user_type\", 50).nullable();\n\n // Expiry — enables server-side cleanup of stale access-token rows\n this.timestamp(\"expires_at\").index().nullable();\n\n // Timestamps\n this.timestamps();\n },\n down() {\n this.dropTableIfExists();\n },\n});\n"],"mappings":";;;;AAGA,MAAa,uBAAuB,QAAQ,aAAa;CACvD,MAAM;CACN,KAAK;EAEH,KAAK,uBAAuB;EAG5B,KAAK,YAAY;EAGjB,KAAK,KAAK,OAAO,CAAC,CAAC,OAAO;EAG1B,KAAK,KAAK,SAAS,CAAC,CAAC,MAAM;EAC3B,KAAK,OAAO,aAAa,EAAE,CAAC,CAAC,SAAS;EAGtC,KAAK,UAAU,YAAY,CAAC,CAAC,MAAM,CAAC,CAAC,SAAS;EAG9C,KAAK,WAAW;CAClB;CACA,OAAO;EACL,KAAK,kBAAkB;CACzB;AACF,CAAC"}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { RefreshToken } from "./refresh-token/refresh-token.model.mjs";
|
|
2
1
|
import { AccessTokenOutput, DeviceInfo, TokenPair } from "../contracts/types.mjs";
|
|
2
|
+
import { RefreshToken } from "./refresh-token/refresh-token.model.mjs";
|
|
3
3
|
import { Authenticable } from "../contracts/auth-contract.mjs";
|
|
4
4
|
import { ChildModel, Model, ModelSchema } from "@warlock.js/cascade";
|
|
5
5
|
|
|
6
|
-
//#region
|
|
6
|
+
//#region ../@warlock.js/auth/src/models/auth.model.d.ts
|
|
7
7
|
declare abstract class Auth<Schema extends ModelSchema = ModelSchema> extends Model<Schema> implements Authenticable {
|
|
8
8
|
/**
|
|
9
9
|
* Get user type
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.model.d.mts","names":[],"sources":["
|
|
1
|
+
{"version":3,"file":"auth.model.d.mts","names":[],"sources":["../../../../../../../@warlock.js/auth/src/models/auth.model.ts"],"mappings":";;;;;;uBAMsB,IAAA,gBAAoB,WAAA,GAAc,WAAA,UAC9C,KAAA,CAAM,MAAA,aACH,aAAA;;AAFb;;eAOsB,QAAA;EAPoB;;;EAYjC,kBAAA;;;;;EAqBwC;;;EAdlC,eAAA,CAAgB,UAAA,GAAa,UAAA,GAAa,OAAA,CAAQ,SAAA;EA4Bf;;;EArBnC,mBAAA,CAAoB,IAAA,SAAa,OAAA,CAAQ,iBAAA;EA0CvB;;;EAnClB,oBAAA,CAAqB,UAAA,GAAa,UAAA,GAAa,OAAA,CAAQ,YAAA;EA0CJ;;;EAnCnD,iBAAA,CAAkB,KAAA,WAAgB,OAAA;EAtCvB;;;EA6CX,kBAAA,CAAmB,KAAA,WAAgB,OAAA;EA/CM;;;EAsDzC,qBAAA,IAAyB,OAAA;EA/ClB;;;EAsDP,eAAA,IAAmB,OAAA;;;;EAOnB,cAAA,IAAkB,OAAA,CAAQ,YAAA;EAjDgB;;;EAAA,OAwDnC,OAAA,CAAQ,IAAA,EAAM,UAAA,CAAW,IAAA,GAAO,IAAA,QAAY,OAAA,CAAQ,IAAA;EAjD1B;;;EAwDjC,eAAA,CAAgB,QAAA,WAAmB,OAAA;AAAA"}
|
|
@@ -2,7 +2,7 @@ import { authService } from "../services/auth.service.mjs";
|
|
|
2
2
|
import "../services/index.mjs";
|
|
3
3
|
import { Model } from "@warlock.js/cascade";
|
|
4
4
|
|
|
5
|
-
//#region
|
|
5
|
+
//#region ../@warlock.js/auth/src/models/auth.model.ts
|
|
6
6
|
var Auth = class extends Model {
|
|
7
7
|
/**
|
|
8
8
|
* Get access token payload
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.model.mjs","names":[],"sources":["
|
|
1
|
+
{"version":3,"file":"auth.model.mjs","names":[],"sources":["../../../../../../../@warlock.js/auth/src/models/auth.model.ts"],"sourcesContent":["import { type ChildModel, Model, type ModelSchema } from \"@warlock.js/cascade\";\r\nimport type { Authenticable } from \"../contracts/auth-contract\";\r\nimport type { AccessTokenOutput, DeviceInfo, TokenPair } from \"../contracts/types\";\r\nimport { authService } from \"../services\";\r\nimport type { RefreshToken } from \"./refresh-token/refresh-token.model\";\r\n\r\nexport abstract class Auth<Schema extends ModelSchema = ModelSchema>\r\n extends Model<Schema>\r\n implements Authenticable\r\n{\r\n /**\r\n * Get user type\r\n */\r\n public abstract get userType(): string;\r\n\r\n /**\r\n * Get access token payload\r\n */\r\n public accessTokenPayload() {\r\n return authService.buildAccessTokenPayload(this);\r\n }\r\n\r\n /**\r\n * Create both access and refresh tokens\r\n */\r\n public async createTokenPair(deviceInfo?: DeviceInfo): Promise<TokenPair> {\r\n return authService.createTokenPair(this, deviceInfo);\r\n }\r\n\r\n /**\r\n * Generate access token\r\n */\r\n public async generateAccessToken(data?: any): Promise<AccessTokenOutput> {\r\n return authService.generateAccessToken(this, data);\r\n }\r\n\r\n /**\r\n * Generate refresh token\r\n */\r\n public async generateRefreshToken(deviceInfo?: DeviceInfo): Promise<RefreshToken | undefined> {\r\n return authService.createRefreshToken(this, deviceInfo);\r\n }\r\n\r\n /**\r\n * Remove current access token\r\n */\r\n public async removeAccessToken(token: string): Promise<void> {\r\n return authService.removeAccessToken(this, token);\r\n }\r\n\r\n /**\r\n * Remove refresh token\r\n */\r\n public async removeRefreshToken(token: string): Promise<void> {\r\n return authService.removeRefreshToken(this, token);\r\n }\r\n\r\n /**\r\n * Remove all access tokens\r\n */\r\n public async removeAllAccessTokens(): Promise<void> {\r\n return authService.removeAllAccessTokens(this);\r\n }\r\n\r\n /**\r\n * Revoke all tokens (logout from all devices)\r\n */\r\n public async revokeAllTokens(): Promise<void> {\r\n return authService.revokeAllTokens(this);\r\n }\r\n\r\n /**\r\n * Get active sessions\r\n */\r\n public async activeSessions(): Promise<RefreshToken[]> {\r\n return authService.getActiveSessions(this);\r\n }\r\n\r\n /**\r\n * Attempt to login the user\r\n */\r\n public static async attempt(this: ChildModel<Auth>, data: any): Promise<Auth | null> {\r\n return authService.attemptLogin(this, data);\r\n }\r\n\r\n /**\r\n * Confirm password\r\n */\r\n public async confirmPassword(password: string): Promise<boolean> {\r\n return authService.verifyPassword(password, this.string(\"password\")!);\r\n }\r\n}\r\n"],"mappings":";;;;;AAMA,IAAsB,OAAtB,cACU,MAEV;;;;CASE,AAAO,qBAAqB;EAC1B,OAAO,YAAY,wBAAwB,IAAI;CACjD;;;;CAKA,MAAa,gBAAgB,YAA6C;EACxE,OAAO,YAAY,gBAAgB,MAAM,UAAU;CACrD;;;;CAKA,MAAa,oBAAoB,MAAwC;EACvE,OAAO,YAAY,oBAAoB,MAAM,IAAI;CACnD;;;;CAKA,MAAa,qBAAqB,YAA4D;EAC5F,OAAO,YAAY,mBAAmB,MAAM,UAAU;CACxD;;;;CAKA,MAAa,kBAAkB,OAA8B;EAC3D,OAAO,YAAY,kBAAkB,MAAM,KAAK;CAClD;;;;CAKA,MAAa,mBAAmB,OAA8B;EAC5D,OAAO,YAAY,mBAAmB,MAAM,KAAK;CACnD;;;;CAKA,MAAa,wBAAuC;EAClD,OAAO,YAAY,sBAAsB,IAAI;CAC/C;;;;CAKA,MAAa,kBAAiC;EAC5C,OAAO,YAAY,gBAAgB,IAAI;CACzC;;;;CAKA,MAAa,iBAA0C;EACrD,OAAO,YAAY,kBAAkB,IAAI;CAC3C;;;;CAKA,aAAoB,QAAgC,MAAiC;EACnF,OAAO,YAAY,aAAa,MAAM,IAAI;CAC5C;;;;CAKA,MAAa,gBAAgB,UAAoC;EAC/D,OAAO,YAAY,eAAe,UAAU,KAAK,OAAO,UAAU,CAAE;CACtE;AACF"}
|
package/esm/models/index.d.mts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { RefreshToken } from "./refresh-token/refresh-token.model.mjs";
|
|
2
1
|
import { Auth } from "./auth.model.mjs";
|
|
3
|
-
import { AccessToken } from "./access-token/access-token.model.mjs";
|
|
4
|
-
|
|
2
|
+
import { AccessToken, accessTokenSchema } from "./access-token/access-token.model.mjs";
|
|
3
|
+
import { RefreshToken, RefreshTokenIssueOptions, refreshTokenSchema } from "./refresh-token/refresh-token.model.mjs";
|
|
4
|
+
|
|
5
|
+
//#region ../@warlock.js/auth/src/models/index.d.ts
|
|
5
6
|
declare const authMigrations: any[];
|
|
6
7
|
//#endregion
|
|
7
8
|
export { authMigrations };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../../../../../../@warlock.js/auth/src/models/index.ts"],"mappings":";;;;;cAOa,cAAA"}
|