@warlock.js/auth 5.23.2 → 5.25.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 +370 -357
- package/LICENSE +21 -21
- package/esm/contracts/types.mjs.map +1 -1
- package/esm/middleware/auth.middleware.mjs +1 -1
- package/esm/middleware/auth.middleware.mjs.map +1 -1
- package/esm/middleware/login-throttle.middleware.mjs.map +1 -1
- package/esm/models/access-token/access-token.model.mjs.map +1 -1
- package/esm/models/access-token/migration.mjs.map +1 -1
- package/esm/models/auth-token-family/auth-token-family.model.d.mts.map +1 -1
- package/esm/models/auth-token-family/auth-token-family.model.mjs +5 -0
- package/esm/models/auth-token-family/auth-token-family.model.mjs.map +1 -1
- package/esm/models/auth.model.mjs.map +1 -1
- package/esm/models/refresh-token/migration.mjs.map +1 -1
- package/esm/models/refresh-token/refresh-token.model.mjs.map +1 -1
- package/esm/services/auth-config.mjs.map +1 -1
- package/esm/services/auth-events.mjs.map +1 -1
- package/esm/services/auth.service.d.mts.map +1 -1
- package/esm/services/auth.service.mjs +10 -9
- package/esm/services/auth.service.mjs.map +1 -1
- package/esm/utils/auth-error-codes.mjs.map +1 -1
- package/llms-full.txt +1927 -1927
- package/package.json +8 -8
- package/skills/auth-basics/SKILL.md +1 -1
- package/skills/customize-token-storage/SKILL.md +160 -160
- package/skills/customize-user-type/SKILL.md +168 -168
- package/skills/handle-login-and-logout/SKILL.md +179 -179
- package/skills/login-with-providers/SKILL.md +283 -283
- package/skills/manage-tokens/SKILL.md +196 -196
- package/skills/overview/SKILL.md +74 -74
- package/skills/protect-routes/SKILL.md +299 -299
- package/skills/register-user/SKILL.md +147 -147
- package/skills/run-auth-commands/SKILL.md +159 -159
- package/skills/throttle-login-attempts/SKILL.md +117 -117
- package/skills/verify-email-and-reset-password/SKILL.md +137 -137
|
@@ -1,299 +1,299 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: protect-routes
|
|
3
|
-
description: '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.locals.user + request.decodedAccessToken on success, 401 on failure. Triggers: `authMiddleware`, `request.locals.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.'
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Gate routes with `authMiddleware`
|
|
7
|
-
|
|
8
|
-
`authMiddleware` returns a Warlock middleware. Attach it to routes or route groups. The legacy string overload is preserved; the object overload makes credential source, optional resolution, and a page-local redirect explicit.
|
|
9
|
-
|
|
10
|
-
```ts title="src/app/users/models/user/user.model.ts"
|
|
11
|
-
import { Auth } from "@warlock.js/auth";
|
|
12
|
-
import { RegisterModel } from "@warlock.js/cascade";
|
|
13
|
-
|
|
14
|
-
@RegisterModel()
|
|
15
|
-
export class User extends Auth {
|
|
16
|
-
public static table = "users";
|
|
17
|
-
|
|
18
|
-
public get userType(): string {
|
|
19
|
-
return "user";
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
## Two modes
|
|
25
|
-
|
|
26
|
-
Middleware is attached via the route's `options.middleware` array (the third argument) — never as a positional argument.
|
|
27
|
-
|
|
28
|
-
```ts
|
|
29
|
-
import { router, type RequestHandler } from "@warlock.js/core";
|
|
30
|
-
import { authMiddleware } from "@warlock.js/auth";
|
|
31
|
-
|
|
32
|
-
const accountController: RequestHandler = async ({ response }) => response.success({});
|
|
33
|
-
const adminController: RequestHandler = async ({ response }) => response.success({});
|
|
34
|
-
const staffController: RequestHandler = async ({ response }) => response.success({});
|
|
35
|
-
|
|
36
|
-
// Required default type (`auth.defaultUserType`, or the sole configured type)
|
|
37
|
-
router.get("/account", accountController, {
|
|
38
|
-
middleware: [authMiddleware()],
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
// Mode 2 — required, specific user type(s)
|
|
42
|
-
// Rejects missing/invalid credentials with 401 and a disallowed user type with 403.
|
|
43
|
-
router.get("/admin", adminController, {
|
|
44
|
-
middleware: [authMiddleware("admin")],
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
router.get("/staff", staffController, {
|
|
48
|
-
middleware: [authMiddleware(["admin", "staff"])],
|
|
49
|
-
});
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
The `userType` slug must match a key in `config.auth.userType.<name>` — see [`@warlock.js/auth/customize-user-type/SKILL.md`](@warlock.js/auth/customize-user-type/SKILL.md).
|
|
53
|
-
|
|
54
|
-
## What the middleware does
|
|
55
|
-
|
|
56
|
-
On success, before your controller runs:
|
|
57
|
-
|
|
58
|
-
```ts
|
|
59
|
-
request.locals.user = <hydrated user model instance>;
|
|
60
|
-
request.decodedAccessToken = <decoded JWT payload>;
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
The user is loaded via `Model.find(decodedToken.id)` against the `config.auth.userType[userType]` class. If the user no longer exists (deleted), the access token row is destroyed and the request gets 401.
|
|
64
|
-
|
|
65
|
-
A token passes three separate checks, in order — a token can fail any one of them while satisfying the other two:
|
|
66
|
-
|
|
67
|
-
1. **The JWT verifies** — signature, algorithm, `tokenType`, and (since 4.12.0) the presence of an `exp` claim that has not passed. A token with *no* `exp` is rejected: there is no deadline to check, so it would otherwise verify indefinitely.
|
|
68
|
-
2. **The access-token row still exists** — deleting it (logout) invalidates the token immediately, before its JWT expiry.
|
|
69
|
-
3. **The row's own `expires_at` has not passed** (since 4.12.0). The database is the authority on the session: a row whose expiry has elapsed is rejected and deleted, even if the JWT itself is still within its lifetime. A row with a missing or unparseable `expires_at` is treated as expired, not as never-expiring.
|
|
70
|
-
|
|
71
|
-
Missing or invalid credentials return 401; an authenticated disallowed user type returns 403:
|
|
72
|
-
|
|
73
|
-
| Error code | When |
|
|
74
|
-
| --- | --- |
|
|
75
|
-
| `MissingAccessToken` | No credential at the configured source |
|
|
76
|
-
| `InvalidAccessToken` | Token doesn't verify (signature, missing/passed `exp`, wrong token type), has no DB row, or the row's `expires_at` has passed |
|
|
77
|
-
| `Unauthorized` | Token valid but user-type isn't in the allowed list (403) |
|
|
78
|
-
|
|
79
|
-
## Page routes: redirect to login instead of the JSON 401
|
|
80
|
-
|
|
81
|
-
By default every auth failure — API or page — returns the JSON `401` above. That's right for an API client, but a logged-out human who *navigates* to a guarded page route gets a raw JSON error blob instead of a login screen.
|
|
82
|
-
|
|
83
|
-
Opt into a redirect with `auth.pageAuth` (5.8+). Set `loginPath` and a guarded **page** route (`route.isPage`) that a logged-out browser hits redirects to `loginPath?returnUrl=<original path>` instead of returning the JSON 401:
|
|
84
|
-
|
|
85
|
-
```ts title="src/config/auth.ts"
|
|
86
|
-
import { User } from "app/users/models/user/user.model";
|
|
87
|
-
|
|
88
|
-
export default {
|
|
89
|
-
userType: { user: User },
|
|
90
|
-
pageAuth: {
|
|
91
|
-
loginPath: "/login", // set this to turn the redirect on
|
|
92
|
-
returnUrlParam: "returnUrl", // optional, this is the default
|
|
93
|
-
},
|
|
94
|
-
};
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
Behavior, precisely:
|
|
98
|
-
|
|
99
|
-
- **Page route + `loginPath` set** → `302` redirect to `loginPath?returnUrl=<encoded request path>`. The login flow reads `returnUrl` to send the user back where they were headed. If `loginPath` already carries a query string, the param is appended with `&` instead of `?`.
|
|
100
|
-
- **Page route + `loginPath` unset** → the JSON `401` is unchanged. The redirect is **opt-in**, so an app that hasn't configured `pageAuth` keeps exactly its current behavior.
|
|
101
|
-
- **API route** → always the JSON `401`, regardless of `pageAuth`. The API contract is never altered.
|
|
102
|
-
|
|
103
|
-
The page-vs-API signal is `request.route.isPage`. Config is typed as `PageAuthConfig` on `AuthConfigurations` (`loginPath?: string`, `returnUrlParam?: string`, default `"returnUrl"`); read at runtime through `authConfig.pageAuth`.
|
|
104
|
-
|
|
105
|
-
> A local `redirect` object is preferred for a page that needs a different login destination; absent it, `auth.pageAuth` remains the compatibility fallback.
|
|
106
|
-
|
|
107
|
-
## Reading the user in a controller
|
|
108
|
-
|
|
109
|
-
`request.locals.user` types as `RequestUser` — an empty interface by default. Narrow it once, app-wide, via module augmentation so it carries your model's shape:
|
|
110
|
-
|
|
111
|
-
```ts title="src/app/users/request-user.ts"
|
|
112
|
-
import type { User } from "app/users/models/user/user.model";
|
|
113
|
-
|
|
114
|
-
declare module "@warlock.js/auth" {
|
|
115
|
-
interface RequestUser extends User {}
|
|
116
|
-
}
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
```ts
|
|
120
|
-
import { type RequestHandler } from "@warlock.js/core";
|
|
121
|
-
|
|
122
|
-
export const accountController: RequestHandler = async ({ request, response }) => {
|
|
123
|
-
const user = request.locals.user!; // typed via the RequestUser augmentation above
|
|
124
|
-
return response.success({
|
|
125
|
-
id: user.id,
|
|
126
|
-
email: user.get("email"),
|
|
127
|
-
});
|
|
128
|
-
};
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
Because the middleware always requires a valid token, `request.locals.user` is guaranteed present inside any gated controller (the middleware would have responded 401 otherwise). The `!` is safe here.
|
|
132
|
-
|
|
133
|
-
## Route-group protection
|
|
134
|
-
|
|
135
|
-
```ts
|
|
136
|
-
router.group({ prefix: "/admin", middleware: [authMiddleware("admin")] }, () => {
|
|
137
|
-
router.get("/users", listUsersController);
|
|
138
|
-
router.post("/users", createUserController);
|
|
139
|
-
});
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
Every route inside the group is gated — the group's `middleware` array applies to each route in the callback. Cleaner than repeating the middleware on each route.
|
|
143
|
-
|
|
144
|
-
## Optional auth
|
|
145
|
-
|
|
146
|
-
Use `optional: true` in the object form when a public route should receive a verified user when one is present. Missing or invalid credentials continue anonymously; a hard gate remains the default.
|
|
147
|
-
|
|
148
|
-
```ts
|
|
149
|
-
middleware: [authMiddleware({ source: "header", optional: true })]
|
|
150
|
-
```
|
|
151
|
-
|
|
152
|
-
## Automatic cookie renewal
|
|
153
|
-
|
|
154
|
-
Add `refresh: { source: "cookie", key: "refresh_token", overlapMs?: 5000 }`
|
|
155
|
-
to a typed cookie middleware to renew an absent, invalid, or expired access
|
|
156
|
-
cookie before the handler. It writes the successor access and refresh cookies
|
|
157
|
-
through `authService.setAuthCookie`; it never replays a handler. `overlapMs` is
|
|
158
|
-
clamped to 0–10,000 ms (default 5,000). Only the exact immediate successor is
|
|
159
|
-
tolerated during that window. The legacy `refreshTokens` API stays strict; a
|
|
160
|
-
later old-token presentation revokes its family.
|
|
161
|
-
## Custom error responses
|
|
162
|
-
|
|
163
|
-
The middleware uses the framework's `response.unauthorized({...})` shape. To override the response globally, hook the framework's error transformer to remap `AuthErrorCodes.*` codes.
|
|
164
|
-
|
|
165
|
-
## When flat user types aren't enough — reach for `@warlock.js/access`
|
|
166
|
-
|
|
167
|
-
`authMiddleware` gates on **user type** by flat string membership. That answers "is this an admin?" and nothing more. It deliberately cannot express:
|
|
168
|
-
|
|
169
|
-
- a permission matrix (`articles.publish` rather than `"editor"`)
|
|
170
|
-
- role hierarchy or inheritance
|
|
171
|
-
- **who may act on whom** — "an `admin` may create a `teacher`, but only a `superAdmin` may create or promote an `admin`"
|
|
172
|
-
|
|
173
|
-
That last one is the difference between a role check and an authorization model: without it, an account-creation endpoint gated on `authMiddleware("admin")` is a privilege-escalation path.
|
|
174
|
-
|
|
175
|
-
Install [`@warlock.js/access`](@warlock.js/access/overview/SKILL.md) for that. It layers RBAC plus per-permission ABAC policies over the same authenticated user:
|
|
176
|
-
|
|
177
|
-
```ts
|
|
178
|
-
import { router, type RequestHandler } from "@warlock.js/core";
|
|
179
|
-
import { authMiddleware } from "@warlock.js/auth";
|
|
180
|
-
import { gate, can, definePolicy } from "@warlock.js/access";
|
|
181
|
-
import { User } from "app/users/models/user/user.model";
|
|
182
|
-
|
|
183
|
-
const articlesController: RequestHandler = async ({ request, response }) => {
|
|
184
|
-
// Who-may-act-on-whom: an ABAC policy on top of the RBAC grant
|
|
185
|
-
definePolicy("users.create", (actor, target, ctx) =>
|
|
186
|
-
ctx.hasRole("superAdmin") || (target as User).userType === "teacher",
|
|
187
|
-
);
|
|
188
|
-
|
|
189
|
-
if (await can(request.locals.user!, "users.create", { resource: request.body })) {
|
|
190
|
-
/* ... */
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
return response.success({});
|
|
194
|
-
};
|
|
195
|
-
|
|
196
|
-
// Permission-based route gate, in place of a user-type gate
|
|
197
|
-
router.post("/articles", articlesController, { middleware: [authMiddleware([]), gate("articles.create")] });
|
|
198
|
-
```
|
|
199
|
-
|
|
200
|
-
Use `authMiddleware` to establish *who the caller is*, and `access` to decide *what they may do*. They compose — `access` reads the user `authMiddleware` put on the request.
|
|
201
|
-
|
|
202
|
-
## Cookie credentials
|
|
203
|
-
|
|
204
|
-
Use `{ source: "cookie", key: "token" }` in the object form. The same checks and `request.locals.user` outcome apply; legacy `authMiddleware("user", "cookie:token")` is also supported.
|
|
205
|
-
|
|
206
|
-
```ts
|
|
207
|
-
router.get("/browser-account", accountController, {
|
|
208
|
-
middleware: [authMiddleware({ source: "cookie", key: "token" })],
|
|
209
|
-
});
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
A route is header-only or cookie-only, never both — `tokenFrom` accepts exactly one source, so there is no header/cookie precedence to define. A route that legitimately needs to accept either composes two `authMiddleware` instances against the app's own optional-auth pattern (above).
|
|
213
|
-
|
|
214
|
-
### Writing and clearing the cookie: `authService.setAuthCookie` / `clearAuthCookie`
|
|
215
|
-
|
|
216
|
-
Reading a cookie is opt-in per route (above); *writing* one is a separate, explicit step your login/logout controller calls — `authService.login`/`logout` never set cookies implicitly, so upgrading never starts emitting `Set-Cookie` for an existing bearer-only app.
|
|
217
|
-
|
|
218
|
-
```ts
|
|
219
|
-
import { type RequestHandler } from "@warlock.js/core";
|
|
220
|
-
import { authService } from "@warlock.js/auth";
|
|
221
|
-
import { User } from "app/users/models/user/user.model";
|
|
222
|
-
|
|
223
|
-
export const loginController: RequestHandler = async ({ request, response }) => {
|
|
224
|
-
const result = await authService.login(User, {
|
|
225
|
-
email: request.input("email"),
|
|
226
|
-
password: request.input("password"),
|
|
227
|
-
});
|
|
228
|
-
if (!result) return response.unauthorized({ error: "Invalid credentials" });
|
|
229
|
-
|
|
230
|
-
const { user, tokens } = result;
|
|
231
|
-
authService.setAuthCookie(response, tokens.accessToken); // Max-Age derived from tokens.accessToken.expiresAt
|
|
232
|
-
|
|
233
|
-
return response.success({ id: user.id });
|
|
234
|
-
};
|
|
235
|
-
|
|
236
|
-
export const logoutController: RequestHandler = async ({ request, response }) => {
|
|
237
|
-
const user = request.locals.user!;
|
|
238
|
-
await authService.logout(user, request.header("authorization"));
|
|
239
|
-
authService.clearAuthCookie(response);
|
|
240
|
-
|
|
241
|
-
return response.success({});
|
|
242
|
-
};
|
|
243
|
-
```
|
|
244
|
-
|
|
245
|
-
`setAuthCookie(response, token, options?)` accepts either the raw token string or an `AccessTokenOutput` (`{ token, expiresAt }`, e.g. `tokens.accessToken` from `login`) — passing the latter derives `Max-Age` from `expiresAt` automatically; pass `options.maxAge` (seconds) to override it, or a bare string with no `maxAge` for a session cookie. `clearAuthCookie(response, options?)` clears it.
|
|
246
|
-
|
|
247
|
-
Cookie **attribute flags are not configurable** — `HttpOnly`, `SameSite=Lax`, and `Secure` outside development come from core's `secureCookieDefaults()`, the same floor every `response.cookie()` call gets. Only the cookie's `name` (default `"access_token"`) and `path` (default `"/"`) can be set, via `options` or the `auth.cookie` config block:
|
|
248
|
-
|
|
249
|
-
```ts title="src/config/auth.ts"
|
|
250
|
-
import { User } from "app/users/models/user/user.model";
|
|
251
|
-
|
|
252
|
-
export default {
|
|
253
|
-
userType: { user: User },
|
|
254
|
-
cookie: {
|
|
255
|
-
name: "access_token", // must match the `cookie:<name>` a route reads
|
|
256
|
-
path: "/",
|
|
257
|
-
},
|
|
258
|
-
};
|
|
259
|
-
```
|
|
260
|
-
|
|
261
|
-
The cookie name `setAuthCookie`/`clearAuthCookie` write **must match** the name a `cookie:<name>` token source reads — they are not linked automatically.
|
|
262
|
-
|
|
263
|
-
### CSRF: an automatic Origin check on cookie-authenticated writes
|
|
264
|
-
|
|
265
|
-
Cookie auth changes the CSRF picture that bearer-token auth doesn't have: a browser attaches cookies to same-site requests it didn't originate. `SameSite=Lax` already blocks the classic cross-site `<form method="post">` case, but not a same-site GET redirect chain or a client that ignores `SameSite`.
|
|
266
|
-
|
|
267
|
-
To close that gap, `authMiddleware` automatically runs a CSRF Origin check on every request whose credential came from a `cookie:` source **and** whose method is unsafe (`POST`/`PUT`/`PATCH`/`DELETE`) — there is nothing to opt into, and nothing to configure to turn it on:
|
|
268
|
-
|
|
269
|
-
- **Allowed** when `Origin` equals the request's own origin, or is listed in `auth.csrf.allowedOrigins` (config, default `[]`).
|
|
270
|
-
- If `Origin` is absent, `Referer`'s origin is checked the same way.
|
|
271
|
-
- If **both** are absent, or the check fails, the request is rejected with `403` and `errorCode: AuthErrorCodes.CsrfOriginMismatch` ("EC006") — never silently passed through.
|
|
272
|
-
- **Header-token auth is completely unaffected**, as are safe methods (`GET`/`HEAD`/`OPTIONS`) under cookie auth.
|
|
273
|
-
|
|
274
|
-
```ts title="src/config/auth.ts"
|
|
275
|
-
import { User } from "app/users/models/user/user.model";
|
|
276
|
-
|
|
277
|
-
export default {
|
|
278
|
-
userType: { user: User },
|
|
279
|
-
csrf: {
|
|
280
|
-
allowedOrigins: ["https://admin.example.com"], // extra trusted origins, beyond same-origin
|
|
281
|
-
},
|
|
282
|
-
};
|
|
283
|
-
```
|
|
284
|
-
|
|
285
|
-
This is an Origin check, not a double-submit CSRF token — cheaper, and closes the gap `SameSite=Lax` leaves open, but it doesn't help a client whose privacy proxy strips both `Origin` and `Referer` (that request is rejected outright, fail-closed, rather than silently allowed). A double-submit token mechanism is deferred to a later release; track it if your threat model includes Origin-stripping clients.
|
|
286
|
-
|
|
287
|
-
## Things NOT to do
|
|
288
|
-
|
|
289
|
-
- Don't call `authMiddleware` outside route definition. It returns a function — the function is what runs per-request. Calling it once per request creates a fresh middleware on every hit (wasteful) and a fresh allowed-types Set (correctness if the input changes per call).
|
|
290
|
-
- Don't manually decode JWTs in the controller. The middleware already does it and exposes the decoded payload via `request.decodedAccessToken`.
|
|
291
|
-
- Don't trust `request.locals.user` set by client-supplied headers. The middleware is the only place that sets it on the server — client headers can't reach this slot.
|
|
292
|
-
- Don't pass an unknown user-type to `authMiddleware("typo")`. The middleware will reject every request because the lookup fails. Test the wire-up with a real token of each user type.
|
|
293
|
-
|
|
294
|
-
## See also
|
|
295
|
-
|
|
296
|
-
- [`@warlock.js/auth/customize-user-type/SKILL.md`](@warlock.js/auth/customize-user-type/SKILL.md) — config and multi-user-type semantics
|
|
297
|
-
- [`@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
|
|
298
|
-
- [`@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
|
|
299
|
-
- [`@warlock.js/access/overview/SKILL.md`](@warlock.js/access/overview/SKILL.md) — permissions, roles, and who-may-act-on-whom policies, when user-type gating isn't enough
|
|
1
|
+
---
|
|
2
|
+
name: protect-routes
|
|
3
|
+
description: '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.locals.user + request.decodedAccessToken on success, 401 on failure. Triggers: `authMiddleware`, `request.locals.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.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Gate routes with `authMiddleware`
|
|
7
|
+
|
|
8
|
+
`authMiddleware` returns a Warlock middleware. Attach it to routes or route groups. The legacy string overload is preserved; the object overload makes credential source, optional resolution, and a page-local redirect explicit.
|
|
9
|
+
|
|
10
|
+
```ts title="src/app/users/models/user/user.model.ts"
|
|
11
|
+
import { Auth } from "@warlock.js/auth";
|
|
12
|
+
import { RegisterModel } from "@warlock.js/cascade";
|
|
13
|
+
|
|
14
|
+
@RegisterModel()
|
|
15
|
+
export class User extends Auth {
|
|
16
|
+
public static table = "users";
|
|
17
|
+
|
|
18
|
+
public get userType(): string {
|
|
19
|
+
return "user";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Two modes
|
|
25
|
+
|
|
26
|
+
Middleware is attached via the route's `options.middleware` array (the third argument) — never as a positional argument.
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { router, type RequestHandler } from "@warlock.js/core";
|
|
30
|
+
import { authMiddleware } from "@warlock.js/auth";
|
|
31
|
+
|
|
32
|
+
const accountController: RequestHandler = async ({ response }) => response.success({});
|
|
33
|
+
const adminController: RequestHandler = async ({ response }) => response.success({});
|
|
34
|
+
const staffController: RequestHandler = async ({ response }) => response.success({});
|
|
35
|
+
|
|
36
|
+
// Required default type (`auth.defaultUserType`, or the sole configured type)
|
|
37
|
+
router.get("/account", accountController, {
|
|
38
|
+
middleware: [authMiddleware()],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Mode 2 — required, specific user type(s)
|
|
42
|
+
// Rejects missing/invalid credentials with 401 and a disallowed user type with 403.
|
|
43
|
+
router.get("/admin", adminController, {
|
|
44
|
+
middleware: [authMiddleware("admin")],
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
router.get("/staff", staffController, {
|
|
48
|
+
middleware: [authMiddleware(["admin", "staff"])],
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The `userType` slug must match a key in `config.auth.userType.<name>` — see [`@warlock.js/auth/customize-user-type/SKILL.md`](@warlock.js/auth/customize-user-type/SKILL.md).
|
|
53
|
+
|
|
54
|
+
## What the middleware does
|
|
55
|
+
|
|
56
|
+
On success, before your controller runs:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
request.locals.user = <hydrated user model instance>;
|
|
60
|
+
request.decodedAccessToken = <decoded JWT payload>;
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The user is loaded via `Model.find(decodedToken.id)` against the `config.auth.userType[userType]` class. If the user no longer exists (deleted), the access token row is destroyed and the request gets 401.
|
|
64
|
+
|
|
65
|
+
A token passes three separate checks, in order — a token can fail any one of them while satisfying the other two:
|
|
66
|
+
|
|
67
|
+
1. **The JWT verifies** — signature, algorithm, `tokenType`, and (since 4.12.0) the presence of an `exp` claim that has not passed. A token with *no* `exp` is rejected: there is no deadline to check, so it would otherwise verify indefinitely.
|
|
68
|
+
2. **The access-token row still exists** — deleting it (logout) invalidates the token immediately, before its JWT expiry.
|
|
69
|
+
3. **The row's own `expires_at` has not passed** (since 4.12.0). The database is the authority on the session: a row whose expiry has elapsed is rejected and deleted, even if the JWT itself is still within its lifetime. A row with a missing or unparseable `expires_at` is treated as expired, not as never-expiring.
|
|
70
|
+
|
|
71
|
+
Missing or invalid credentials return 401; an authenticated disallowed user type returns 403:
|
|
72
|
+
|
|
73
|
+
| Error code | When |
|
|
74
|
+
| --- | --- |
|
|
75
|
+
| `MissingAccessToken` | No credential at the configured source |
|
|
76
|
+
| `InvalidAccessToken` | Token doesn't verify (signature, missing/passed `exp`, wrong token type), has no DB row, or the row's `expires_at` has passed |
|
|
77
|
+
| `Unauthorized` | Token valid but user-type isn't in the allowed list (403) |
|
|
78
|
+
|
|
79
|
+
## Page routes: redirect to login instead of the JSON 401
|
|
80
|
+
|
|
81
|
+
By default every auth failure — API or page — returns the JSON `401` above. That's right for an API client, but a logged-out human who *navigates* to a guarded page route gets a raw JSON error blob instead of a login screen.
|
|
82
|
+
|
|
83
|
+
Opt into a redirect with `auth.pageAuth` (5.8+). Set `loginPath` and a guarded **page** route (`route.isPage`) that a logged-out browser hits redirects to `loginPath?returnUrl=<original path>` instead of returning the JSON 401:
|
|
84
|
+
|
|
85
|
+
```ts title="src/config/auth.ts"
|
|
86
|
+
import { User } from "app/users/models/user/user.model";
|
|
87
|
+
|
|
88
|
+
export default {
|
|
89
|
+
userType: { user: User },
|
|
90
|
+
pageAuth: {
|
|
91
|
+
loginPath: "/login", // set this to turn the redirect on
|
|
92
|
+
returnUrlParam: "returnUrl", // optional, this is the default
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Behavior, precisely:
|
|
98
|
+
|
|
99
|
+
- **Page route + `loginPath` set** → `302` redirect to `loginPath?returnUrl=<encoded request path>`. The login flow reads `returnUrl` to send the user back where they were headed. If `loginPath` already carries a query string, the param is appended with `&` instead of `?`.
|
|
100
|
+
- **Page route + `loginPath` unset** → the JSON `401` is unchanged. The redirect is **opt-in**, so an app that hasn't configured `pageAuth` keeps exactly its current behavior.
|
|
101
|
+
- **API route** → always the JSON `401`, regardless of `pageAuth`. The API contract is never altered.
|
|
102
|
+
|
|
103
|
+
The page-vs-API signal is `request.route.isPage`. Config is typed as `PageAuthConfig` on `AuthConfigurations` (`loginPath?: string`, `returnUrlParam?: string`, default `"returnUrl"`); read at runtime through `authConfig.pageAuth`.
|
|
104
|
+
|
|
105
|
+
> A local `redirect` object is preferred for a page that needs a different login destination; absent it, `auth.pageAuth` remains the compatibility fallback.
|
|
106
|
+
|
|
107
|
+
## Reading the user in a controller
|
|
108
|
+
|
|
109
|
+
`request.locals.user` types as `RequestUser` — an empty interface by default. Narrow it once, app-wide, via module augmentation so it carries your model's shape:
|
|
110
|
+
|
|
111
|
+
```ts title="src/app/users/request-user.ts"
|
|
112
|
+
import type { User } from "app/users/models/user/user.model";
|
|
113
|
+
|
|
114
|
+
declare module "@warlock.js/auth" {
|
|
115
|
+
interface RequestUser extends User {}
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import { type RequestHandler } from "@warlock.js/core";
|
|
121
|
+
|
|
122
|
+
export const accountController: RequestHandler = async ({ request, response }) => {
|
|
123
|
+
const user = request.locals.user!; // typed via the RequestUser augmentation above
|
|
124
|
+
return response.success({
|
|
125
|
+
id: user.id,
|
|
126
|
+
email: user.get("email"),
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Because the middleware always requires a valid token, `request.locals.user` is guaranteed present inside any gated controller (the middleware would have responded 401 otherwise). The `!` is safe here.
|
|
132
|
+
|
|
133
|
+
## Route-group protection
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
router.group({ prefix: "/admin", middleware: [authMiddleware("admin")] }, () => {
|
|
137
|
+
router.get("/users", listUsersController);
|
|
138
|
+
router.post("/users", createUserController);
|
|
139
|
+
});
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Every route inside the group is gated — the group's `middleware` array applies to each route in the callback. Cleaner than repeating the middleware on each route.
|
|
143
|
+
|
|
144
|
+
## Optional auth
|
|
145
|
+
|
|
146
|
+
Use `optional: true` in the object form when a public route should receive a verified user when one is present. Missing or invalid credentials continue anonymously; a hard gate remains the default.
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
middleware: [authMiddleware({ source: "header", optional: true })]
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Automatic cookie renewal
|
|
153
|
+
|
|
154
|
+
Add `refresh: { source: "cookie", key: "refresh_token", overlapMs?: 5000 }`
|
|
155
|
+
to a typed cookie middleware to renew an absent, invalid, or expired access
|
|
156
|
+
cookie before the handler. It writes the successor access and refresh cookies
|
|
157
|
+
through `authService.setAuthCookie`; it never replays a handler. `overlapMs` is
|
|
158
|
+
clamped to 0–10,000 ms (default 5,000). Only the exact immediate successor is
|
|
159
|
+
tolerated during that window. The legacy `refreshTokens` API stays strict; a
|
|
160
|
+
later old-token presentation revokes its family.
|
|
161
|
+
## Custom error responses
|
|
162
|
+
|
|
163
|
+
The middleware uses the framework's `response.unauthorized({...})` shape. To override the response globally, hook the framework's error transformer to remap `AuthErrorCodes.*` codes.
|
|
164
|
+
|
|
165
|
+
## When flat user types aren't enough — reach for `@warlock.js/access`
|
|
166
|
+
|
|
167
|
+
`authMiddleware` gates on **user type** by flat string membership. That answers "is this an admin?" and nothing more. It deliberately cannot express:
|
|
168
|
+
|
|
169
|
+
- a permission matrix (`articles.publish` rather than `"editor"`)
|
|
170
|
+
- role hierarchy or inheritance
|
|
171
|
+
- **who may act on whom** — "an `admin` may create a `teacher`, but only a `superAdmin` may create or promote an `admin`"
|
|
172
|
+
|
|
173
|
+
That last one is the difference between a role check and an authorization model: without it, an account-creation endpoint gated on `authMiddleware("admin")` is a privilege-escalation path.
|
|
174
|
+
|
|
175
|
+
Install [`@warlock.js/access`](@warlock.js/access/overview/SKILL.md) for that. It layers RBAC plus per-permission ABAC policies over the same authenticated user:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
import { router, type RequestHandler } from "@warlock.js/core";
|
|
179
|
+
import { authMiddleware } from "@warlock.js/auth";
|
|
180
|
+
import { gate, can, definePolicy } from "@warlock.js/access";
|
|
181
|
+
import { User } from "app/users/models/user/user.model";
|
|
182
|
+
|
|
183
|
+
const articlesController: RequestHandler = async ({ request, response }) => {
|
|
184
|
+
// Who-may-act-on-whom: an ABAC policy on top of the RBAC grant
|
|
185
|
+
definePolicy("users.create", (actor, target, ctx) =>
|
|
186
|
+
ctx.hasRole("superAdmin") || (target as User).userType === "teacher",
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
if (await can(request.locals.user!, "users.create", { resource: request.body })) {
|
|
190
|
+
/* ... */
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return response.success({});
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// Permission-based route gate, in place of a user-type gate
|
|
197
|
+
router.post("/articles", articlesController, { middleware: [authMiddleware([]), gate("articles.create")] });
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Use `authMiddleware` to establish *who the caller is*, and `access` to decide *what they may do*. They compose — `access` reads the user `authMiddleware` put on the request.
|
|
201
|
+
|
|
202
|
+
## Cookie credentials
|
|
203
|
+
|
|
204
|
+
Use `{ source: "cookie", key: "token" }` in the object form. The same checks and `request.locals.user` outcome apply; legacy `authMiddleware("user", "cookie:token")` is also supported.
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
router.get("/browser-account", accountController, {
|
|
208
|
+
middleware: [authMiddleware({ source: "cookie", key: "token" })],
|
|
209
|
+
});
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
A route is header-only or cookie-only, never both — `tokenFrom` accepts exactly one source, so there is no header/cookie precedence to define. A route that legitimately needs to accept either composes two `authMiddleware` instances against the app's own optional-auth pattern (above).
|
|
213
|
+
|
|
214
|
+
### Writing and clearing the cookie: `authService.setAuthCookie` / `clearAuthCookie`
|
|
215
|
+
|
|
216
|
+
Reading a cookie is opt-in per route (above); *writing* one is a separate, explicit step your login/logout controller calls — `authService.login`/`logout` never set cookies implicitly, so upgrading never starts emitting `Set-Cookie` for an existing bearer-only app.
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
import { type RequestHandler } from "@warlock.js/core";
|
|
220
|
+
import { authService } from "@warlock.js/auth";
|
|
221
|
+
import { User } from "app/users/models/user/user.model";
|
|
222
|
+
|
|
223
|
+
export const loginController: RequestHandler = async ({ request, response }) => {
|
|
224
|
+
const result = await authService.login(User, {
|
|
225
|
+
email: request.input("email"),
|
|
226
|
+
password: request.input("password"),
|
|
227
|
+
});
|
|
228
|
+
if (!result) return response.unauthorized({ error: "Invalid credentials" });
|
|
229
|
+
|
|
230
|
+
const { user, tokens } = result;
|
|
231
|
+
authService.setAuthCookie(response, tokens.accessToken); // Max-Age derived from tokens.accessToken.expiresAt
|
|
232
|
+
|
|
233
|
+
return response.success({ id: user.id });
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
export const logoutController: RequestHandler = async ({ request, response }) => {
|
|
237
|
+
const user = request.locals.user!;
|
|
238
|
+
await authService.logout(user, request.header("authorization"));
|
|
239
|
+
authService.clearAuthCookie(response);
|
|
240
|
+
|
|
241
|
+
return response.success({});
|
|
242
|
+
};
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
`setAuthCookie(response, token, options?)` accepts either the raw token string or an `AccessTokenOutput` (`{ token, expiresAt }`, e.g. `tokens.accessToken` from `login`) — passing the latter derives `Max-Age` from `expiresAt` automatically; pass `options.maxAge` (seconds) to override it, or a bare string with no `maxAge` for a session cookie. `clearAuthCookie(response, options?)` clears it.
|
|
246
|
+
|
|
247
|
+
Cookie **attribute flags are not configurable** — `HttpOnly`, `SameSite=Lax`, and `Secure` outside development come from core's `secureCookieDefaults()`, the same floor every `response.cookie()` call gets. Only the cookie's `name` (default `"access_token"`) and `path` (default `"/"`) can be set, via `options` or the `auth.cookie` config block:
|
|
248
|
+
|
|
249
|
+
```ts title="src/config/auth.ts"
|
|
250
|
+
import { User } from "app/users/models/user/user.model";
|
|
251
|
+
|
|
252
|
+
export default {
|
|
253
|
+
userType: { user: User },
|
|
254
|
+
cookie: {
|
|
255
|
+
name: "access_token", // must match the `cookie:<name>` a route reads
|
|
256
|
+
path: "/",
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
The cookie name `setAuthCookie`/`clearAuthCookie` write **must match** the name a `cookie:<name>` token source reads — they are not linked automatically.
|
|
262
|
+
|
|
263
|
+
### CSRF: an automatic Origin check on cookie-authenticated writes
|
|
264
|
+
|
|
265
|
+
Cookie auth changes the CSRF picture that bearer-token auth doesn't have: a browser attaches cookies to same-site requests it didn't originate. `SameSite=Lax` already blocks the classic cross-site `<form method="post">` case, but not a same-site GET redirect chain or a client that ignores `SameSite`.
|
|
266
|
+
|
|
267
|
+
To close that gap, `authMiddleware` automatically runs a CSRF Origin check on every request whose credential came from a `cookie:` source **and** whose method is unsafe (`POST`/`PUT`/`PATCH`/`DELETE`) — there is nothing to opt into, and nothing to configure to turn it on:
|
|
268
|
+
|
|
269
|
+
- **Allowed** when `Origin` equals the request's own origin, or is listed in `auth.csrf.allowedOrigins` (config, default `[]`).
|
|
270
|
+
- If `Origin` is absent, `Referer`'s origin is checked the same way.
|
|
271
|
+
- If **both** are absent, or the check fails, the request is rejected with `403` and `errorCode: AuthErrorCodes.CsrfOriginMismatch` ("EC006") — never silently passed through.
|
|
272
|
+
- **Header-token auth is completely unaffected**, as are safe methods (`GET`/`HEAD`/`OPTIONS`) under cookie auth.
|
|
273
|
+
|
|
274
|
+
```ts title="src/config/auth.ts"
|
|
275
|
+
import { User } from "app/users/models/user/user.model";
|
|
276
|
+
|
|
277
|
+
export default {
|
|
278
|
+
userType: { user: User },
|
|
279
|
+
csrf: {
|
|
280
|
+
allowedOrigins: ["https://admin.example.com"], // extra trusted origins, beyond same-origin
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
This is an Origin check, not a double-submit CSRF token — cheaper, and closes the gap `SameSite=Lax` leaves open, but it doesn't help a client whose privacy proxy strips both `Origin` and `Referer` (that request is rejected outright, fail-closed, rather than silently allowed). A double-submit token mechanism is deferred to a later release; track it if your threat model includes Origin-stripping clients.
|
|
286
|
+
|
|
287
|
+
## Things NOT to do
|
|
288
|
+
|
|
289
|
+
- Don't call `authMiddleware` outside route definition. It returns a function — the function is what runs per-request. Calling it once per request creates a fresh middleware on every hit (wasteful) and a fresh allowed-types Set (correctness if the input changes per call).
|
|
290
|
+
- Don't manually decode JWTs in the controller. The middleware already does it and exposes the decoded payload via `request.decodedAccessToken`.
|
|
291
|
+
- Don't trust `request.locals.user` set by client-supplied headers. The middleware is the only place that sets it on the server — client headers can't reach this slot.
|
|
292
|
+
- Don't pass an unknown user-type to `authMiddleware("typo")`. The middleware will reject every request because the lookup fails. Test the wire-up with a real token of each user type.
|
|
293
|
+
|
|
294
|
+
## See also
|
|
295
|
+
|
|
296
|
+
- [`@warlock.js/auth/customize-user-type/SKILL.md`](@warlock.js/auth/customize-user-type/SKILL.md) — config and multi-user-type semantics
|
|
297
|
+
- [`@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
|
|
298
|
+
- [`@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
|
|
299
|
+
- [`@warlock.js/access/overview/SKILL.md`](@warlock.js/access/overview/SKILL.md) — permissions, roles, and who-may-act-on-whom policies, when user-type gating isn't enough
|