@warlock.js/auth 5.10.0 → 5.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/esm/contracts/index.d.mts +3 -2
- package/esm/contracts/request-locals.d.mts +43 -0
- package/esm/contracts/request-locals.d.mts.map +1 -0
- package/esm/contracts/types.d.mts +66 -1
- package/esm/contracts/types.d.mts.map +1 -1
- package/esm/contracts/types.mjs.map +1 -1
- package/esm/index.d.mts +7 -3
- package/esm/index.mjs +3 -1
- package/esm/middleware/auth.middleware.d.mts.map +1 -1
- package/esm/middleware/auth.middleware.mjs +13 -2
- package/esm/middleware/auth.middleware.mjs.map +1 -1
- package/esm/middleware/csrf-origin-check.d.mts +40 -0
- package/esm/middleware/csrf-origin-check.d.mts.map +1 -0
- package/esm/middleware/csrf-origin-check.mjs +105 -0
- package/esm/middleware/csrf-origin-check.mjs.map +1 -0
- package/esm/middleware/index.d.mts +1 -0
- package/esm/middleware/index.mjs +1 -0
- package/esm/services/auth-config.mjs +12 -0
- package/esm/services/auth-config.mjs.map +1 -1
- package/esm/services/auth.service.d.mts +41 -1
- package/esm/services/auth.service.d.mts.map +1 -1
- package/esm/services/auth.service.mjs +58 -0
- package/esm/services/auth.service.mjs.map +1 -1
- package/esm/services/current-user.d.mts +21 -0
- package/esm/services/current-user.d.mts.map +1 -0
- package/esm/services/current-user.mjs +24 -0
- package/esm/services/current-user.mjs.map +1 -0
- package/esm/services/index.d.mts +1 -0
- package/esm/services/index.mjs +1 -0
- package/esm/utils/auth-error-codes.d.mts +10 -1
- package/esm/utils/auth-error-codes.d.mts.map +1 -1
- package/esm/utils/auth-error-codes.mjs +8 -0
- package/esm/utils/auth-error-codes.mjs.map +1 -1
- package/llms-full.txt +89 -19
- package/package.json +7 -7
- package/skills/handle-login-and-logout/SKILL.md +1 -1
- package/skills/overview/SKILL.md +1 -1
- package/skills/protect-routes/SKILL.md +71 -13
package/llms-full.txt
CHANGED
|
@@ -475,7 +475,7 @@ import { type RequestHandler } from "@warlock.js/core";
|
|
|
475
475
|
|
|
476
476
|
export const logoutController: RequestHandler = async ({ request, response }) => {
|
|
477
477
|
await authService.logout(
|
|
478
|
-
request.user!,
|
|
478
|
+
request.locals.user!,
|
|
479
479
|
request.authorizationValue, // access token from the Authorization header
|
|
480
480
|
request.input("refreshToken"), // refresh token from the request body
|
|
481
481
|
);
|
|
@@ -779,7 +779,7 @@ Start here. The `Auth` base model, `authMiddleware` gate, `authService` (login/l
|
|
|
779
779
|
Sign up a new user and issue the first token pair — `User.create({ ...password: await hashPassword(plain) })` then `authService.createTokenPair(user)`. For `POST /register`.
|
|
780
780
|
|
|
781
781
|
#### [`protect-routes`](@warlock.js/auth/protect-routes/SKILL.md)
|
|
782
|
-
`authMiddleware(allowedUserType)` — the argument is required and always requires a valid token: `[]` allows any authenticated user, a user-type argument restricts to those types. Sets `request.user` + `request.decodedAccessToken`, responds 401 on failure.
|
|
782
|
+
`authMiddleware(allowedUserType)` — the argument is required and always requires a valid token: `[]` allows any authenticated user, a user-type argument restricts to those types. Sets `request.locals.user` + `request.decodedAccessToken`, responds 401 on failure.
|
|
783
783
|
|
|
784
784
|
### Going deeper
|
|
785
785
|
|
|
@@ -816,7 +816,7 @@ Two CLI commands — `warlock jwt.generate` (strong JWT secret → `.env`) and `
|
|
|
816
816
|
|
|
817
817
|
---
|
|
818
818
|
name: protect-routes
|
|
819
|
-
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.user + request.decodedAccessToken on success, 401 on failure. Triggers: `authMiddleware`, `request.user`, `request.decodedAccessToken`, `AuthErrorCodes`, `MissingAccessToken`, `InvalidAccessToken`; "how do I protect a route", "restrict route by user type", "require any logged-in user"; typical import `import { authMiddleware } from "@warlock.js/auth"`. Skip: multi-user-type config — `@warlock.js/auth/customize-user-type/SKILL.md`; issuing the token — `@warlock.js/auth/handle-login-and-logout/SKILL.md`; competing libs `passport`, `express-jwt`, `next-auth` middleware.'
|
|
819
|
+
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.'
|
|
820
820
|
---
|
|
821
821
|
|
|
822
822
|
# Gate routes with `authMiddleware`
|
|
@@ -854,7 +854,7 @@ The `userType` slug must match a key in `config.auth.userType.<name>` — see [`
|
|
|
854
854
|
On success, before your controller runs:
|
|
855
855
|
|
|
856
856
|
```ts
|
|
857
|
-
request.user = <hydrated user model instance>;
|
|
857
|
+
request.locals.user = <hydrated user model instance>;
|
|
858
858
|
request.decodedAccessToken = <decoded JWT payload>;
|
|
859
859
|
```
|
|
860
860
|
|
|
@@ -898,7 +898,7 @@ Behavior, precisely:
|
|
|
898
898
|
|
|
899
899
|
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`.
|
|
900
900
|
|
|
901
|
-
>
|
|
901
|
+
> `auth.pageAuth` is **not a second auth mechanism** — it is a convenience *adapter* over the same "not authenticated" outcome the gate already produces. It only changes the **representation** of that outcome on a page route (a browser-friendly login redirect) instead of the JSON `401`; the decision that the request is unauthenticated is unchanged. The sanctioned *generic* way to make authentication soft is the app-owned optional-auth middleware below — `pageAuth` is the built-in adapter for the common "redirect a logged-out human to /login" case, so most apps never need to hand-roll it.
|
|
902
902
|
|
|
903
903
|
## Reading the user in a controller
|
|
904
904
|
|
|
@@ -906,7 +906,7 @@ The page-vs-API signal is `request.route.isPage`. Config is typed as `PageAuthCo
|
|
|
906
906
|
import { type RequestHandler } from "@warlock.js/core";
|
|
907
907
|
|
|
908
908
|
export const accountController: RequestHandler = async ({ request, response }) => {
|
|
909
|
-
const user = request.user!; // typed via your Auth subclass
|
|
909
|
+
const user = request.locals.user!; // typed via your Auth subclass
|
|
910
910
|
return response.success({
|
|
911
911
|
id: user.id,
|
|
912
912
|
email: user.get("email"),
|
|
@@ -914,7 +914,7 @@ export const accountController: RequestHandler = async ({ request, response }) =
|
|
|
914
914
|
};
|
|
915
915
|
```
|
|
916
916
|
|
|
917
|
-
Because the middleware always requires a valid token, `request.user` is guaranteed present inside any gated controller (the middleware would have responded 401 otherwise). The `!` is safe here.
|
|
917
|
+
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.
|
|
918
918
|
|
|
919
919
|
## Route-group protection
|
|
920
920
|
|
|
@@ -927,19 +927,31 @@ router.group({ prefix: "/admin", middleware: [authMiddleware("admin")] }, () =>
|
|
|
927
927
|
|
|
928
928
|
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.
|
|
929
929
|
|
|
930
|
-
##
|
|
930
|
+
## Optional auth is an app-owned middleware (the sanctioned pattern)
|
|
931
931
|
|
|
932
|
-
There is no "hydrate `request.user` if a token is present, otherwise continue" mode
|
|
932
|
+
`authMiddleware` is a **hard gate** — it always requires a valid token and rejects when one is absent. There is deliberately no built-in "hydrate `request.locals.user` if a token is present, otherwise continue" mode, because the *sanctioned generic pattern* for soft/optional auth is a small **app-owned optional-auth middleware**: resolve the user when a valid token is present, and otherwise leave the route to decide. The app owns it because the "what to do when absent" policy is the app's, not the framework's.
|
|
933
933
|
|
|
934
|
-
```ts
|
|
935
|
-
import { type
|
|
934
|
+
```ts title="src/app/middleware/optional-auth.middleware.ts"
|
|
935
|
+
import { type Middleware } from "@warlock.js/core";
|
|
936
|
+
import { jwt } from "@warlock.js/auth";
|
|
936
937
|
|
|
937
|
-
|
|
938
|
+
// The one sanctioned optional-auth shape: resolve if present, never reject.
|
|
939
|
+
// Middleware receives the context object ({ request, response }), like a handler.
|
|
940
|
+
export const optionalAuth: Middleware = async ({ request }) => {
|
|
938
941
|
const token = request.authorizationValue;
|
|
939
|
-
//
|
|
942
|
+
if (!token) return; // absent → continue anonymously; the route decides
|
|
943
|
+
try {
|
|
944
|
+
const decoded = await jwt.verify(token);
|
|
945
|
+
// hydrate request.locals.user from your token/user model when valid
|
|
946
|
+
} catch {
|
|
947
|
+
// invalid token on an optional route → treat as anonymous, don't reject
|
|
948
|
+
}
|
|
949
|
+
// returning nothing (undefined) === continue to the handler
|
|
940
950
|
};
|
|
941
951
|
```
|
|
942
952
|
|
|
953
|
+
Wire it like any middleware (`{ middleware: [optionalAuth] }`), then branch on `request.locals.user` in the controller. Use `authMiddleware` when the route must be gated; use this when the route is public but personalizes for a signed-in user. `auth.pageAuth` (above) is the built-in **adapter** over this same "unauthenticated" outcome for the common page-redirect case — it is not a competing mechanism.
|
|
954
|
+
|
|
943
955
|
## Custom error responses
|
|
944
956
|
|
|
945
957
|
The middleware uses the framework's `response.unauthorized({...})` shape. To override the response globally, hook the framework's error transformer to remap `AuthErrorCodes.*` codes.
|
|
@@ -967,24 +979,82 @@ definePolicy("users.create", (actor, target, ctx) =>
|
|
|
967
979
|
ctx.hasRole("superAdmin") || (target as User).userType === "teacher",
|
|
968
980
|
);
|
|
969
981
|
|
|
970
|
-
if (await can(request.user, "users.create", { resource: payload })) { /* ... */ }
|
|
982
|
+
if (await can(request.locals.user, "users.create", { resource: payload })) { /* ... */ }
|
|
971
983
|
```
|
|
972
984
|
|
|
973
985
|
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.
|
|
974
986
|
|
|
975
|
-
##
|
|
987
|
+
## Cookie sessions: `authMiddleware([], "cookie:<name>")`
|
|
988
|
+
|
|
989
|
+
`authMiddleware`'s second argument, `tokenFrom`, selects the credential source and defaults to `"header"`. Pass `` `cookie:<name>` `` to read the credential from a named cookie instead of the `Authorization` header — the same gate, the same three checks, the same `request.locals.user` / `request.decodedAccessToken` outcome, just a different place to find the token:
|
|
990
|
+
|
|
991
|
+
```ts
|
|
992
|
+
router.get("/browser-account", accountController, {
|
|
993
|
+
middleware: [authMiddleware([], "cookie:token")],
|
|
994
|
+
});
|
|
995
|
+
```
|
|
996
|
+
|
|
997
|
+
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).
|
|
998
|
+
|
|
999
|
+
### Writing and clearing the cookie: `authService.setAuthCookie` / `clearAuthCookie`
|
|
1000
|
+
|
|
1001
|
+
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.
|
|
1002
|
+
|
|
1003
|
+
```ts
|
|
1004
|
+
import { authService } from "@warlock.js/auth";
|
|
1005
|
+
|
|
1006
|
+
// after issuing tokens
|
|
1007
|
+
const { user, tokens } = await authService.login(User, credentials);
|
|
1008
|
+
authService.setAuthCookie(response, tokens.accessToken); // Max-Age derived from tokens.accessToken.expiresAt
|
|
1009
|
+
|
|
1010
|
+
// after revoking tokens
|
|
1011
|
+
await authService.logout(user, accessToken, refreshToken);
|
|
1012
|
+
authService.clearAuthCookie(response);
|
|
1013
|
+
```
|
|
1014
|
+
|
|
1015
|
+
`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.
|
|
1016
|
+
|
|
1017
|
+
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:
|
|
1018
|
+
|
|
1019
|
+
```ts title="src/config/auth.ts"
|
|
1020
|
+
export default {
|
|
1021
|
+
userType: { user: User },
|
|
1022
|
+
cookie: {
|
|
1023
|
+
name: "access_token", // must match the `cookie:<name>` a route reads
|
|
1024
|
+
path: "/",
|
|
1025
|
+
},
|
|
1026
|
+
};
|
|
1027
|
+
```
|
|
1028
|
+
|
|
1029
|
+
The cookie name `setAuthCookie`/`clearAuthCookie` write **must match** the name a `cookie:<name>` token source reads — they are not linked automatically.
|
|
976
1030
|
|
|
977
|
-
|
|
1031
|
+
### CSRF: an automatic Origin check on cookie-authenticated writes
|
|
978
1032
|
|
|
979
|
-
|
|
1033
|
+
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`.
|
|
1034
|
+
|
|
1035
|
+
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:
|
|
1036
|
+
|
|
1037
|
+
- **Allowed** when `Origin` equals the request's own origin, or is listed in `auth.csrf.allowedOrigins` (config, default `[]`).
|
|
1038
|
+
- If `Origin` is absent, `Referer`'s origin is checked the same way.
|
|
1039
|
+
- If **both** are absent, or the check fails, the request is rejected with `403` and `errorCode: AuthErrorCodes.CsrfOriginMismatch` ("EC006") — never silently passed through.
|
|
1040
|
+
- **Header-token auth is completely unaffected**, as are safe methods (`GET`/`HEAD`/`OPTIONS`) under cookie auth.
|
|
1041
|
+
|
|
1042
|
+
```ts title="src/config/auth.ts"
|
|
1043
|
+
export default {
|
|
1044
|
+
userType: { user: User },
|
|
1045
|
+
csrf: {
|
|
1046
|
+
allowedOrigins: ["https://admin.example.com"], // extra trusted origins, beyond same-origin
|
|
1047
|
+
},
|
|
1048
|
+
};
|
|
1049
|
+
```
|
|
980
1050
|
|
|
981
|
-
|
|
1051
|
+
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.
|
|
982
1052
|
|
|
983
1053
|
## Things NOT to do
|
|
984
1054
|
|
|
985
1055
|
- 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).
|
|
986
1056
|
- Don't manually decode JWTs in the controller. The middleware already does it and exposes the decoded payload via `request.decodedAccessToken`.
|
|
987
|
-
- Don't trust `request.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.
|
|
1057
|
+
- 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.
|
|
988
1058
|
- 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.
|
|
989
1059
|
|
|
990
1060
|
## See also
|
package/package.json
CHANGED
|
@@ -12,12 +12,12 @@
|
|
|
12
12
|
"ms": "^2.1.3"
|
|
13
13
|
},
|
|
14
14
|
"peerDependencies": {
|
|
15
|
-
"@warlock.js/fs": "5.
|
|
16
|
-
"@warlock.js/cache": "5.
|
|
17
|
-
"@warlock.js/cascade": "5.
|
|
18
|
-
"@warlock.js/core": "5.
|
|
19
|
-
"@warlock.js/logger": "5.
|
|
20
|
-
"@warlock.js/seal": "5.
|
|
15
|
+
"@warlock.js/fs": "5.12.0",
|
|
16
|
+
"@warlock.js/cache": "5.12.0",
|
|
17
|
+
"@warlock.js/cascade": "5.12.0",
|
|
18
|
+
"@warlock.js/core": "5.12.0",
|
|
19
|
+
"@warlock.js/logger": "5.12.0",
|
|
20
|
+
"@warlock.js/seal": "5.12.0"
|
|
21
21
|
},
|
|
22
22
|
"repository": {
|
|
23
23
|
"type": "git",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
],
|
|
34
34
|
"author": "hassanzohdy",
|
|
35
35
|
"license": "MIT",
|
|
36
|
-
"version": "5.
|
|
36
|
+
"version": "5.12.0",
|
|
37
37
|
"type": "module",
|
|
38
38
|
"main": "./esm/index.mjs",
|
|
39
39
|
"module": "./esm/index.mjs",
|
|
@@ -87,7 +87,7 @@ import { type RequestHandler } from "@warlock.js/core";
|
|
|
87
87
|
|
|
88
88
|
export const logoutController: RequestHandler = async ({ request, response }) => {
|
|
89
89
|
await authService.logout(
|
|
90
|
-
request.user!,
|
|
90
|
+
request.locals.user!,
|
|
91
91
|
request.authorizationValue, // access token from the Authorization header
|
|
92
92
|
request.input("refreshToken"), // refresh token from the request body
|
|
93
93
|
);
|
package/skills/overview/SKILL.md
CHANGED
|
@@ -41,7 +41,7 @@ Start here. The `Auth` base model, `authMiddleware` gate, `authService` (login/l
|
|
|
41
41
|
Sign up a new user and issue the first token pair — `User.create({ ...password: await hashPassword(plain) })` then `authService.createTokenPair(user)`. For `POST /register`.
|
|
42
42
|
|
|
43
43
|
#### [`protect-routes`](@warlock.js/auth/protect-routes/SKILL.md)
|
|
44
|
-
`authMiddleware(allowedUserType)` — the argument is required and always requires a valid token: `[]` allows any authenticated user, a user-type argument restricts to those types. Sets `request.user` + `request.decodedAccessToken`, responds 401 on failure.
|
|
44
|
+
`authMiddleware(allowedUserType)` — the argument is required and always requires a valid token: `[]` allows any authenticated user, a user-type argument restricts to those types. Sets `request.locals.user` + `request.decodedAccessToken`, responds 401 on failure.
|
|
45
45
|
|
|
46
46
|
### Going deeper
|
|
47
47
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
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.user + request.decodedAccessToken on success, 401 on failure. Triggers: `authMiddleware`, `request.user`, `request.decodedAccessToken`, `AuthErrorCodes`, `MissingAccessToken`, `InvalidAccessToken`; "how do I protect a route", "restrict route by user type", "require any logged-in user"; typical import `import { authMiddleware } from "@warlock.js/auth"`. Skip: multi-user-type config — `@warlock.js/auth/customize-user-type/SKILL.md`; issuing the token — `@warlock.js/auth/handle-login-and-logout/SKILL.md`; competing libs `passport`, `express-jwt`, `next-auth` middleware.'
|
|
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
4
|
---
|
|
5
5
|
|
|
6
6
|
# Gate routes with `authMiddleware`
|
|
@@ -38,7 +38,7 @@ The `userType` slug must match a key in `config.auth.userType.<name>` — see [`
|
|
|
38
38
|
On success, before your controller runs:
|
|
39
39
|
|
|
40
40
|
```ts
|
|
41
|
-
request.user = <hydrated user model instance>;
|
|
41
|
+
request.locals.user = <hydrated user model instance>;
|
|
42
42
|
request.decodedAccessToken = <decoded JWT payload>;
|
|
43
43
|
```
|
|
44
44
|
|
|
@@ -90,7 +90,7 @@ The page-vs-API signal is `request.route.isPage`. Config is typed as `PageAuthCo
|
|
|
90
90
|
import { type RequestHandler } from "@warlock.js/core";
|
|
91
91
|
|
|
92
92
|
export const accountController: RequestHandler = async ({ request, response }) => {
|
|
93
|
-
const user = request.user!; // typed via your Auth subclass
|
|
93
|
+
const user = request.locals.user!; // typed via your Auth subclass
|
|
94
94
|
return response.success({
|
|
95
95
|
id: user.id,
|
|
96
96
|
email: user.get("email"),
|
|
@@ -98,7 +98,7 @@ export const accountController: RequestHandler = async ({ request, response }) =
|
|
|
98
98
|
};
|
|
99
99
|
```
|
|
100
100
|
|
|
101
|
-
Because the middleware always requires a valid token, `request.user` is guaranteed present inside any gated controller (the middleware would have responded 401 otherwise). The `!` is safe here.
|
|
101
|
+
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.
|
|
102
102
|
|
|
103
103
|
## Route-group protection
|
|
104
104
|
|
|
@@ -113,7 +113,7 @@ Every route inside the group is gated — the group's `middleware` array applies
|
|
|
113
113
|
|
|
114
114
|
## Optional auth is an app-owned middleware (the sanctioned pattern)
|
|
115
115
|
|
|
116
|
-
`authMiddleware` is a **hard gate** — it always requires a valid token and rejects when one is absent. There is deliberately no built-in "hydrate `request.user` if a token is present, otherwise continue" mode, because the *sanctioned generic pattern* for soft/optional auth is a small **app-owned optional-auth middleware**: resolve the user when a valid token is present, and otherwise leave the route to decide. The app owns it because the "what to do when absent" policy is the app's, not the framework's.
|
|
116
|
+
`authMiddleware` is a **hard gate** — it always requires a valid token and rejects when one is absent. There is deliberately no built-in "hydrate `request.locals.user` if a token is present, otherwise continue" mode, because the *sanctioned generic pattern* for soft/optional auth is a small **app-owned optional-auth middleware**: resolve the user when a valid token is present, and otherwise leave the route to decide. The app owns it because the "what to do when absent" policy is the app's, not the framework's.
|
|
117
117
|
|
|
118
118
|
```ts title="src/app/middleware/optional-auth.middleware.ts"
|
|
119
119
|
import { type Middleware } from "@warlock.js/core";
|
|
@@ -126,7 +126,7 @@ export const optionalAuth: Middleware = async ({ request }) => {
|
|
|
126
126
|
if (!token) return; // absent → continue anonymously; the route decides
|
|
127
127
|
try {
|
|
128
128
|
const decoded = await jwt.verify(token);
|
|
129
|
-
// hydrate request.user from your token/user model when valid
|
|
129
|
+
// hydrate request.locals.user from your token/user model when valid
|
|
130
130
|
} catch {
|
|
131
131
|
// invalid token on an optional route → treat as anonymous, don't reject
|
|
132
132
|
}
|
|
@@ -134,7 +134,7 @@ export const optionalAuth: Middleware = async ({ request }) => {
|
|
|
134
134
|
};
|
|
135
135
|
```
|
|
136
136
|
|
|
137
|
-
Wire it like any middleware (`{ middleware: [optionalAuth] }`), then branch on `request.user` in the controller. Use `authMiddleware` when the route must be gated; use this when the route is public but personalizes for a signed-in user. `auth.pageAuth` (above) is the built-in **adapter** over this same "unauthenticated" outcome for the common page-redirect case — it is not a competing mechanism.
|
|
137
|
+
Wire it like any middleware (`{ middleware: [optionalAuth] }`), then branch on `request.locals.user` in the controller. Use `authMiddleware` when the route must be gated; use this when the route is public but personalizes for a signed-in user. `auth.pageAuth` (above) is the built-in **adapter** over this same "unauthenticated" outcome for the common page-redirect case — it is not a competing mechanism.
|
|
138
138
|
|
|
139
139
|
## Custom error responses
|
|
140
140
|
|
|
@@ -163,24 +163,82 @@ definePolicy("users.create", (actor, target, ctx) =>
|
|
|
163
163
|
ctx.hasRole("superAdmin") || (target as User).userType === "teacher",
|
|
164
164
|
);
|
|
165
165
|
|
|
166
|
-
if (await can(request.user, "users.create", { resource: payload })) { /* ... */ }
|
|
166
|
+
if (await can(request.locals.user, "users.create", { resource: payload })) { /* ... */ }
|
|
167
167
|
```
|
|
168
168
|
|
|
169
169
|
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.
|
|
170
170
|
|
|
171
|
-
##
|
|
171
|
+
## Cookie sessions: `authMiddleware([], "cookie:<name>")`
|
|
172
172
|
|
|
173
|
-
|
|
173
|
+
`authMiddleware`'s second argument, `tokenFrom`, selects the credential source and defaults to `"header"`. Pass `` `cookie:<name>` `` to read the credential from a named cookie instead of the `Authorization` header — the same gate, the same three checks, the same `request.locals.user` / `request.decodedAccessToken` outcome, just a different place to find the token:
|
|
174
174
|
|
|
175
|
-
|
|
175
|
+
```ts
|
|
176
|
+
router.get("/browser-account", accountController, {
|
|
177
|
+
middleware: [authMiddleware([], "cookie:token")],
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
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).
|
|
182
|
+
|
|
183
|
+
### Writing and clearing the cookie: `authService.setAuthCookie` / `clearAuthCookie`
|
|
184
|
+
|
|
185
|
+
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.
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
import { authService } from "@warlock.js/auth";
|
|
189
|
+
|
|
190
|
+
// after issuing tokens
|
|
191
|
+
const { user, tokens } = await authService.login(User, credentials);
|
|
192
|
+
authService.setAuthCookie(response, tokens.accessToken); // Max-Age derived from tokens.accessToken.expiresAt
|
|
193
|
+
|
|
194
|
+
// after revoking tokens
|
|
195
|
+
await authService.logout(user, accessToken, refreshToken);
|
|
196
|
+
authService.clearAuthCookie(response);
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`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.
|
|
200
|
+
|
|
201
|
+
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:
|
|
202
|
+
|
|
203
|
+
```ts title="src/config/auth.ts"
|
|
204
|
+
export default {
|
|
205
|
+
userType: { user: User },
|
|
206
|
+
cookie: {
|
|
207
|
+
name: "access_token", // must match the `cookie:<name>` a route reads
|
|
208
|
+
path: "/",
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
The cookie name `setAuthCookie`/`clearAuthCookie` write **must match** the name a `cookie:<name>` token source reads — they are not linked automatically.
|
|
214
|
+
|
|
215
|
+
### CSRF: an automatic Origin check on cookie-authenticated writes
|
|
216
|
+
|
|
217
|
+
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`.
|
|
218
|
+
|
|
219
|
+
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:
|
|
220
|
+
|
|
221
|
+
- **Allowed** when `Origin` equals the request's own origin, or is listed in `auth.csrf.allowedOrigins` (config, default `[]`).
|
|
222
|
+
- If `Origin` is absent, `Referer`'s origin is checked the same way.
|
|
223
|
+
- If **both** are absent, or the check fails, the request is rejected with `403` and `errorCode: AuthErrorCodes.CsrfOriginMismatch` ("EC006") — never silently passed through.
|
|
224
|
+
- **Header-token auth is completely unaffected**, as are safe methods (`GET`/`HEAD`/`OPTIONS`) under cookie auth.
|
|
225
|
+
|
|
226
|
+
```ts title="src/config/auth.ts"
|
|
227
|
+
export default {
|
|
228
|
+
userType: { user: User },
|
|
229
|
+
csrf: {
|
|
230
|
+
allowedOrigins: ["https://admin.example.com"], // extra trusted origins, beyond same-origin
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
```
|
|
176
234
|
|
|
177
|
-
|
|
235
|
+
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.
|
|
178
236
|
|
|
179
237
|
## Things NOT to do
|
|
180
238
|
|
|
181
239
|
- 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).
|
|
182
240
|
- Don't manually decode JWTs in the controller. The middleware already does it and exposes the decoded payload via `request.decodedAccessToken`.
|
|
183
|
-
- Don't trust `request.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.
|
|
241
|
+
- 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.
|
|
184
242
|
- 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.
|
|
185
243
|
|
|
186
244
|
## See also
|