@warlock.js/auth 5.30.0 → 5.31.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 +12 -1
- package/esm/commands/auth-purge-never-expiring-command.mjs.map +1 -1
- package/esm/commands/jwt-secret-generator-command.mjs.map +1 -1
- package/esm/middleware/auth.middleware.mjs +2 -2
- package/esm/middleware/auth.middleware.mjs.map +1 -1
- package/esm/middleware/login-throttle.middleware.mjs +1 -1
- package/esm/middleware/login-throttle.middleware.mjs.map +1 -1
- package/esm/models/access-token/access-token.model.d.mts +0 -24
- package/esm/models/access-token/access-token.model.d.mts.map +1 -1
- package/esm/models/auth-token-family/auth-token-family.model.d.mts +0 -24
- package/esm/models/auth-token-family/auth-token-family.model.d.mts.map +1 -1
- package/esm/models/one-time-token/one-time-token.model.d.mts +0 -32
- package/esm/models/one-time-token/one-time-token.model.d.mts.map +1 -1
- package/esm/models/passkey-credential/passkey-credential.model.d.mts +0 -28
- package/esm/models/passkey-credential/passkey-credential.model.d.mts.map +1 -1
- package/esm/models/provider-account/provider-account.model.d.mts +0 -24
- package/esm/models/provider-account/provider-account.model.d.mts.map +1 -1
- package/esm/models/refresh-token/refresh-token.model.d.mts +4 -48
- package/esm/models/refresh-token/refresh-token.model.d.mts.map +1 -1
- package/esm/services/resolve-request-user.d.mts.map +1 -1
- package/esm/services/resolve-request-user.mjs.map +1 -1
- package/llms-full.txt +189 -111
- package/package.json +8 -8
- package/skills/auth-basics/SKILL.md +10 -13
- package/skills/customize-token-storage/SKILL.md +24 -19
- package/skills/customize-user-type/SKILL.md +3 -1
- package/skills/index.md +4 -1
- package/skills/login-with-providers/SKILL.md +54 -29
- package/skills/manage-tokens/SKILL.md +2 -2
- package/skills/overview/SKILL.md +10 -1
- package/skills/protect-routes/SKILL.md +23 -14
- package/skills/register-user/SKILL.md +4 -4
- package/skills/run-auth-commands/SKILL.md +1 -1
- package/skills/throttle-login-attempts/SKILL.md +51 -22
- package/skills/verify-email-and-reset-password/SKILL.md +7 -5
package/llms-full.txt
CHANGED
|
@@ -59,10 +59,7 @@ import { defineConfig } from "@warlock.js/core";
|
|
|
59
59
|
|
|
60
60
|
export default defineConfig({
|
|
61
61
|
cli: {
|
|
62
|
-
commands: [
|
|
63
|
-
registerJWTSecretGeneratorCommand(),
|
|
64
|
-
registerAuthCleanupCommand(),
|
|
65
|
-
],
|
|
62
|
+
commands: [registerJWTSecretGeneratorCommand(), registerAuthCleanupCommand()],
|
|
66
63
|
},
|
|
67
64
|
database: {
|
|
68
65
|
migrations: authMigrations,
|
|
@@ -106,19 +103,19 @@ That is deliberate. `ms` answers `undefined` for a string it can't parse (`"30da
|
|
|
106
103
|
|
|
107
104
|
Two values that look right and are not:
|
|
108
105
|
|
|
109
|
-
- `expiresIn: 2592000` — a bare number. `ms`
|
|
106
|
+
- `expiresIn: 2592000` — a bare number. `ms` _formats_ numbers instead of parsing them, so this is rejected; write `"30d"`.
|
|
110
107
|
- `expiresIn: env("JWT_TTL")` where the variable is unset — resolves to `""` and is rejected. Give the env read a default: `env("JWT_TTL") || "1h"`.
|
|
111
108
|
|
|
112
109
|
## Pick a skill
|
|
113
110
|
|
|
114
|
-
| If the task is about…
|
|
115
|
-
|
|
|
116
|
-
| Gating routes with `authMiddleware(allowedUserType)`, any-authenticated vs typed access
|
|
117
|
-
| `authService.login(...)`, `attemptLogin`, full credentials-to-tokens flow + logout
|
|
118
|
-
| Token lifecycle — `generateAccessToken`, `createRefreshToken`, rotation, family revocation, max-per-user | The `manage-tokens` topic
|
|
119
|
-
| Register a new user + issue tokens in one flow
|
|
120
|
-
| Multi-user-type apps (`user`, `admin`, `client`), `config.auth.userType.<name>` mapping
|
|
121
|
-
| `warlock jwt.generate` + `warlock auth.cleanup` CLI commands
|
|
111
|
+
| If the task is about… | Load |
|
|
112
|
+
| -------------------------------------------------------------------------------------------------------- | ----------------------------------- |
|
|
113
|
+
| Gating routes with `authMiddleware(allowedUserType)`, any-authenticated vs typed access | The `protect-routes` topic |
|
|
114
|
+
| `authService.login(...)`, `attemptLogin`, full credentials-to-tokens flow + logout | The `handle-login-and-logout` topic |
|
|
115
|
+
| Token lifecycle — `generateAccessToken`, `createRefreshToken`, rotation, family revocation, max-per-user | The `manage-tokens` topic |
|
|
116
|
+
| Register a new user + issue tokens in one flow | The `register-user` topic |
|
|
117
|
+
| Multi-user-type apps (`user`, `admin`, `client`), `config.auth.userType.<name>` mapping | The `customize-user-type` topic |
|
|
118
|
+
| `warlock jwt.generate` + `warlock auth.cleanup` CLI commands | The `run-auth-commands` topic |
|
|
122
119
|
|
|
123
120
|
## Things NOT to do
|
|
124
121
|
|
|
@@ -137,7 +134,7 @@ description: 'Override the persisted AccessToken / RefreshToken models to add co
|
|
|
137
134
|
|
|
138
135
|
# Customize token storage
|
|
139
136
|
|
|
140
|
-
The package ships `AccessToken` and `RefreshToken` models and runs the whole token flow through their **named statics** — `issue`, `findByToken`, `activeFor`, `revokeAllFor`, etc. The auth service never references a column name directly; it resolves the
|
|
137
|
+
The package ships `AccessToken` and `RefreshToken` models and runs the whole token flow through their **named statics** — `issue`, `findByToken`, `activeFor`, `revokeAllFor`, etc. The auth service never references a column name directly; it resolves the _active_ model from config. So you change token storage by **registering a subclass**, not by forking.
|
|
141
138
|
|
|
142
139
|
Reach for this when you need an extra column on the token tables — the common case is a tenant key (`organization_id`) so tokens are partitioned per organization, or richer device metadata.
|
|
143
140
|
|
|
@@ -188,7 +185,12 @@ export class AppAccessToken extends AccessToken {
|
|
|
188
185
|
Refresh tokens follow the same shape — extend `refreshTokenSchema`, and override `issue(user, token, options)` (`options` is `{ familyId, expiresAt, deviceInfo? }`) the same way, copying the base fields plus your column.
|
|
189
186
|
|
|
190
187
|
```ts title="src/app/auth/models/app-refresh-token.ts"
|
|
191
|
-
import {
|
|
188
|
+
import {
|
|
189
|
+
RefreshToken,
|
|
190
|
+
refreshTokenSchema,
|
|
191
|
+
type Auth,
|
|
192
|
+
type RefreshTokenIssueOptions,
|
|
193
|
+
} from "@warlock.js/auth";
|
|
192
194
|
import { v } from "@warlock.js/seal";
|
|
193
195
|
|
|
194
196
|
export class AppRefreshToken extends RefreshToken {
|
|
@@ -220,12 +222,12 @@ import { AppRefreshToken } from "app/auth/models/app-refresh-token";
|
|
|
220
222
|
export default {
|
|
221
223
|
userType: { user: User },
|
|
222
224
|
accessToken: {
|
|
223
|
-
model: AppAccessToken,
|
|
225
|
+
model: AppAccessToken, // ← the override
|
|
224
226
|
secret: env("JWT_SECRET"),
|
|
225
227
|
expiresIn: "1h",
|
|
226
228
|
},
|
|
227
229
|
refreshToken: {
|
|
228
|
-
model: AppRefreshToken,
|
|
230
|
+
model: AppRefreshToken, // ← the override
|
|
229
231
|
secret: env("JWT_REFRESH_SECRET"),
|
|
230
232
|
expiresIn: "30d",
|
|
231
233
|
},
|
|
@@ -246,7 +248,7 @@ See the `write-migration` topic of the `warlock-js-cascade` skill for the migrat
|
|
|
246
248
|
|
|
247
249
|
## Why all three — the strict-mode trap
|
|
248
250
|
|
|
249
|
-
This is the failure people hit. Cascade's `strictMode: "strip"` **drops any field your schema doesn't declare** before the INSERT. So if you set `organization_id` in `issue()` but don't add it to the schema (step 1), the value is silently stripped, and a NOT-NULL `organization_id` column then **fails the INSERT**. The chain is: `issue()` sets it → the
|
|
251
|
+
This is the failure people hit. Cascade's `strictMode: "strip"` **drops any field your schema doesn't declare** before the INSERT. So if you set `organization_id` in `issue()` but don't add it to the schema (step 1), the value is silently stripped, and a NOT-NULL `organization_id` column then **fails the INSERT**. The chain is: `issue()` sets it → the _schema_ must declare it so it survives → the _migration_ must create the column. All three, or nothing.
|
|
250
252
|
|
|
251
253
|
## You do NOT need read-side scoping
|
|
252
254
|
|
|
@@ -256,23 +258,23 @@ Token lookups go by the unique `token` string (`findByToken`), which already uni
|
|
|
256
258
|
|
|
257
259
|
Your subclass inherits and may override these statics — the service calls them, never raw queries:
|
|
258
260
|
|
|
259
|
-
| Static
|
|
260
|
-
|
|
|
261
|
-
| `issue(user, token, …)`
|
|
262
|
-
| `findByToken(token)`
|
|
263
|
-
| `findForUser(user, token)`
|
|
264
|
-
| `activeFor(user)`
|
|
265
|
-
| `revokeAllFor(user)` / `revokeFamily(id)`
|
|
266
|
-
| `enforceMax(user, max)`
|
|
267
|
-
| `purgeExpired()`
|
|
261
|
+
| Static | Role |
|
|
262
|
+
| ---------------------------------------------- | -------------------------------------------------------- |
|
|
263
|
+
| `issue(user, token, …)` | persist a freshly-signed token (override to add columns) |
|
|
264
|
+
| `findByToken(token)` | look a row up by its token string |
|
|
265
|
+
| `findForUser(user, token)` | a user-scoped lookup (logout) |
|
|
266
|
+
| `activeFor(user)` | active, unexpired sessions, newest-first |
|
|
267
|
+
| `revokeAllFor(user)` / `revokeFamily(id)` | revoke a set, returning the revoked rows |
|
|
268
|
+
| `enforceMax(user, max)` | cap concurrent refresh tokens |
|
|
269
|
+
| `purgeExpired()` | delete expired rows (CLI cleanup) |
|
|
268
270
|
| `findNeverExpiring()` / `purgeNeverExpiring()` | find/delete rows that can never expire (CLI remediation) |
|
|
269
271
|
|
|
270
272
|
Plus two instance getters, both of which the route gate consults on every request:
|
|
271
273
|
|
|
272
|
-
| Getter
|
|
273
|
-
|
|
|
274
|
-
| `isExpired`
|
|
275
|
-
| `neverExpires` | whether nothing can ever retire the row (unusable `expires_at`, or a token with no `exp` claim)
|
|
274
|
+
| Getter | Role |
|
|
275
|
+
| -------------- | ---------------------------------------------------------------------------------------------------------- |
|
|
276
|
+
| `isExpired` | whether the row's persisted expiry has passed — **the middleware rejects the request when this is `true`** |
|
|
277
|
+
| `neverExpires` | whether nothing can ever retire the row (unusable `expires_at`, or a token with no `exp` claim) |
|
|
276
278
|
|
|
277
279
|
If you rename a column, override the statics **and these getters** so they map to your name — the service depends on the method, not the column.
|
|
278
280
|
|
|
@@ -287,7 +289,7 @@ If you rename a column, override the statics **and these getters** so they map t
|
|
|
287
289
|
|
|
288
290
|
## See also
|
|
289
291
|
|
|
290
|
-
- The `customize-user-type` topic — multiple user
|
|
292
|
+
- The `customize-user-type` topic — multiple user _types_ (a different axis from token _storage_).
|
|
291
293
|
- The `manage-tokens` topic — the token lifecycle your statics power.
|
|
292
294
|
- The `define-model` topic of the `warlock-js-cascade` skill — extending models and schemas.
|
|
293
295
|
|
|
@@ -395,7 +397,9 @@ const dashboardController: RequestHandler = async ({ response }) => response.suc
|
|
|
395
397
|
|
|
396
398
|
router.get("/account", userAccountController, { middleware: [authMiddleware("user")] });
|
|
397
399
|
router.get("/admin/users", listUsersController, { middleware: [authMiddleware("admin")] });
|
|
398
|
-
router.get("/back-office", backOfficeController, {
|
|
400
|
+
router.get("/back-office", backOfficeController, {
|
|
401
|
+
middleware: [authMiddleware(["admin", "staff"])],
|
|
402
|
+
});
|
|
399
403
|
router.get("/dashboard", dashboardController, { middleware: [authMiddleware([])] }); // any logged-in
|
|
400
404
|
```
|
|
401
405
|
|
|
@@ -686,13 +690,13 @@ authService.setAuthCookie(response, tokens.accessToken); // cookie session, or r
|
|
|
686
690
|
|
|
687
691
|
## Install
|
|
688
692
|
|
|
689
|
-
| Method
|
|
690
|
-
|
|
|
691
|
-
| Google
|
|
692
|
-
| Apple, LinkedIn
|
|
693
|
-
| GitHub, Discord, Facebook, X | nothing — plain OAuth 2 over `fetch`
|
|
694
|
-
| Passkeys
|
|
695
|
-
| Phone code
|
|
693
|
+
| Method | Command | Installs |
|
|
694
|
+
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
|
|
695
|
+
| Google | `warlock add auth-google` | `jose` |
|
|
696
|
+
| Apple, LinkedIn | `warlock add auth-google` (or `npm install jose`) — there is no separate `auth-apple` / `auth-linkedin` feature yet | `jose` |
|
|
697
|
+
| GitHub, Discord, Facebook, X | nothing — plain OAuth 2 over `fetch` | nothing |
|
|
698
|
+
| Passkeys | `warlock add auth-passkeys` | `@simplewebauthn/server` (add `@simplewebauthn/browser` to your client bundle) |
|
|
699
|
+
| Phone code | `warlock add notifications` + your own `sms`/`whatsapp` channel | nothing else. Auth ships no SMS/WhatsApp driver |
|
|
696
700
|
|
|
697
701
|
The SDKs are optional peers, loaded with `import()` only when a method runs. If one is missing, the call throws `AuthProviderSdkMissingError` (500), and the message names the `warlock add` command. Run the migrations: `authMigrations` now includes `provider_accounts` and `passkey_credentials`, and `one_time_tokens` gained an `attempts` column.
|
|
698
702
|
|
|
@@ -870,17 +874,25 @@ passkeys: { rpID: "example.com", rpName: "Example", origin: "https://example.com
|
|
|
870
874
|
|
|
871
875
|
```ts
|
|
872
876
|
// Register (logged in)
|
|
873
|
-
router.post(
|
|
874
|
-
|
|
875
|
-
{
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
877
|
+
router.post(
|
|
878
|
+
"/auth/passkeys/register/options",
|
|
879
|
+
async ({ request, response }) =>
|
|
880
|
+
response.success(await generatePasskeyRegistrationOptions(request.locals.user)),
|
|
881
|
+
{ middleware: [authMiddleware("user")] },
|
|
882
|
+
);
|
|
883
|
+
router.post(
|
|
884
|
+
"/auth/passkeys/register",
|
|
885
|
+
async ({ request, response }) => {
|
|
886
|
+
await verifyPasskeyRegistration(request, request.locals.user, request.input("credential"));
|
|
887
|
+
return response.success({ registered: true });
|
|
888
|
+
},
|
|
889
|
+
{ middleware: [authMiddleware("user")] },
|
|
890
|
+
);
|
|
880
891
|
|
|
881
892
|
// Log in
|
|
882
893
|
router.post("/auth/passkeys/login/options", async ({ response }) =>
|
|
883
|
-
response.success(await generatePasskeyAuthenticationOptions())
|
|
894
|
+
response.success(await generatePasskeyAuthenticationOptions()),
|
|
895
|
+
);
|
|
884
896
|
router.post("/auth/passkeys/login", async ({ request, response }) => {
|
|
885
897
|
const { tokens } = await verifyPasskeyAuthentication(request, request.input("credential"));
|
|
886
898
|
authService.setAuthCookie(response, tokens.accessToken);
|
|
@@ -894,16 +906,23 @@ Browser:
|
|
|
894
906
|
import { startAuthentication, startRegistration } from "@simplewebauthn/browser";
|
|
895
907
|
|
|
896
908
|
const post = (url: string, body?: unknown) =>
|
|
897
|
-
fetch(url, {
|
|
898
|
-
|
|
909
|
+
fetch(url, {
|
|
910
|
+
method: "POST",
|
|
911
|
+
headers: { "content-type": "application/json" },
|
|
912
|
+
body: JSON.stringify(body),
|
|
913
|
+
}).then((r) => r.json());
|
|
899
914
|
|
|
900
915
|
// register
|
|
901
916
|
const regOptions = await post("/auth/passkeys/register/options");
|
|
902
|
-
await post("/auth/passkeys/register", {
|
|
917
|
+
await post("/auth/passkeys/register", {
|
|
918
|
+
credential: await startRegistration({ optionsJSON: regOptions }),
|
|
919
|
+
});
|
|
903
920
|
|
|
904
921
|
// log in
|
|
905
922
|
const authOptions = await post("/auth/passkeys/login/options");
|
|
906
|
-
await post("/auth/passkeys/login", {
|
|
923
|
+
await post("/auth/passkeys/login", {
|
|
924
|
+
credential: await startAuthentication({ optionsJSON: authOptions }),
|
|
925
|
+
});
|
|
907
926
|
```
|
|
908
927
|
|
|
909
928
|
(Unwrap `response.success`'s envelope if your app wraps payloads.)
|
|
@@ -920,16 +939,26 @@ otp: { channel: "sms" /* or "whatsapp" */, phoneField: "phone", expiresIn: "5m",
|
|
|
920
939
|
```
|
|
921
940
|
|
|
922
941
|
```ts
|
|
923
|
-
router.post(
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
942
|
+
router.post(
|
|
943
|
+
"/auth/otp/request",
|
|
944
|
+
async ({ request, response }) => {
|
|
945
|
+
await requestOtp(User, request.input("phone"), {
|
|
946
|
+
channel: request.input("via") === "whatsapp" ? "whatsapp" : "sms",
|
|
947
|
+
});
|
|
948
|
+
return response.success({ message: "If that number is registered, a code is on its way." });
|
|
949
|
+
},
|
|
950
|
+
{ middleware: [otpRequestThrottleMiddleware()] },
|
|
951
|
+
); // 3 / 1h per phone + IP
|
|
952
|
+
|
|
953
|
+
router.post(
|
|
954
|
+
"/auth/otp/verify",
|
|
955
|
+
async ({ request, response }) => {
|
|
956
|
+
const { tokens } = await verifyOtp(User, request.input("phone"), request.input("code"));
|
|
957
|
+
authService.setAuthCookie(response, tokens.accessToken);
|
|
958
|
+
return response.success({ ok: true });
|
|
959
|
+
},
|
|
960
|
+
{ middleware: [otpVerifyThrottleMiddleware()] },
|
|
961
|
+
); // 5 failures / 15m per phone + IP
|
|
933
962
|
```
|
|
934
963
|
|
|
935
964
|
- Codes have 6 digits and are stored in `one_time_tokens` (purpose `otp`) as a salted HMAC keyed from your access-token secret, never as plain SHA-256, which is trivially reversible for 6 digits. A new request invalidates the previous code.
|
|
@@ -972,7 +1001,7 @@ type RefreshTokenOutput = { token: string; expiresAt: string };
|
|
|
972
1001
|
|
|
973
1002
|
type TokenPair = {
|
|
974
1003
|
accessToken: AccessTokenOutput;
|
|
975
|
-
refreshToken?: RefreshTokenOutput;
|
|
1004
|
+
refreshToken?: RefreshTokenOutput; // omitted if config.auth.refreshToken.enabled = false
|
|
976
1005
|
};
|
|
977
1006
|
```
|
|
978
1007
|
|
|
@@ -1047,7 +1076,7 @@ The family ties together "successive rotations of one session." In 5.19 it is al
|
|
|
1047
1076
|
const sessions = await authService.getActiveSessions(user);
|
|
1048
1077
|
|
|
1049
1078
|
for (const session of sessions) {
|
|
1050
|
-
session.get("device_info");
|
|
1079
|
+
session.get("device_info"); // { userAgent, ip, deviceId? } if provided at login
|
|
1051
1080
|
session.get("created_at");
|
|
1052
1081
|
session.get("expires_at");
|
|
1053
1082
|
}
|
|
@@ -1187,41 +1216,50 @@ Nine task skills. Most apps need `auth-basics` + `protect-routes` + `handle-logi
|
|
|
1187
1216
|
### Foundations
|
|
1188
1217
|
|
|
1189
1218
|
#### `auth-basics`
|
|
1219
|
+
|
|
1190
1220
|
Start here. The `Auth` base model, `authMiddleware` gate, `authService` (login/logout/refresh), AccessToken + RefreshToken persistence, multi-user-type support.
|
|
1191
1221
|
|
|
1192
1222
|
### The flows
|
|
1193
1223
|
|
|
1194
1224
|
#### `handle-login-and-logout`
|
|
1225
|
+
|
|
1195
1226
|
`authService.login(Model, credentials, deviceInfo?)` — verify password, issue the token pair, fire events. `authService.logout(user, accessToken?, refreshToken?)` — revoke tokens. For your `POST /login` and `POST /logout` controllers.
|
|
1196
1227
|
|
|
1197
1228
|
#### `register-user`
|
|
1229
|
+
|
|
1198
1230
|
Sign up a new user and issue the first token pair — `User.create({ ...password: await hashPassword(plain) })` then `authService.createTokenPair(user)`. For `POST /register`.
|
|
1199
1231
|
|
|
1200
1232
|
#### `protect-routes`
|
|
1233
|
+
|
|
1201
1234
|
`authMiddleware` object and legacy overloads: typed/default hard gates, optional resolution, credential source, and page-local redirects. Sets `request.locals.user` + `request.decodedAccessToken` when a user resolves.
|
|
1202
1235
|
|
|
1203
1236
|
### Going deeper
|
|
1204
1237
|
|
|
1205
1238
|
#### `manage-tokens`
|
|
1239
|
+
|
|
1206
1240
|
The token lifecycle — `generateAccessToken`, `createRefreshToken`, `createTokenPair`, `refreshTokens` (rotation + replay detection), `revokeAllTokens`, `revokeTokenFamily`, `cleanupExpiredTokens`, `getActiveSessions`. For custom login/registration, token revocation, "logout everywhere", and scheduled cleanup.
|
|
1207
1241
|
|
|
1208
1242
|
#### `customize-user-type`
|
|
1243
|
+
|
|
1209
1244
|
Support multiple user types in one system — each `Auth` subclass overrides `userType`, `config.auth.userType.<slug>` maps the slug to a model class, `authMiddleware("admin")` / `authMiddleware(["admin", "staff"])` gates per type.
|
|
1210
1245
|
|
|
1211
1246
|
#### `customize-token-storage`
|
|
1247
|
+
|
|
1212
1248
|
Override the persisted `AccessToken` / `RefreshToken` models to add columns (multi-tenant `organization_id`), rename, or change storage — extend the model + schema and register it under `config.auth.accessToken.model` / `config.auth.refreshToken.model`.
|
|
1213
1249
|
|
|
1214
1250
|
#### `throttle-login-attempts`
|
|
1251
|
+
|
|
1215
1252
|
Brute-force / credential-stuffing protection — `loginThrottleMiddleware()` counts only failed logins, resets on success, locks per-account + per-IP after a threshold, and rejects pre-controller with 429. Cache-backed, fails open on a cache outage.
|
|
1216
1253
|
|
|
1217
1254
|
#### `run-auth-commands`
|
|
1255
|
+
|
|
1218
1256
|
Two CLI commands — `warlock jwt.generate` (strong JWT secret → `.env`) and `warlock auth.cleanup` (remove expired refresh tokens). Register via `registerJWTSecretGeneratorCommand()` and `registerAuthCleanupCommand()`.
|
|
1219
1257
|
|
|
1220
1258
|
## What this package deliberately doesn't do
|
|
1221
1259
|
|
|
1222
1260
|
- **Session-cookie auth.** It's JWT/token-based. If you need server-side sessions, this isn't it.
|
|
1223
1261
|
- **OAuth / social login / SSO.** No provider adapters here — wire those at the controller layer and create the user through this package's models.
|
|
1224
|
-
- **Authorization / roles / permissions (RBAC).** It authenticates (who you are) and gates by user
|
|
1262
|
+
- **Authorization / roles / permissions (RBAC).** It authenticates (who you are) and gates by user _type_, not fine-grained permissions. Build RBAC on top.
|
|
1225
1263
|
- **Standalone use.** It depends on `@warlock.js/core` for routing, models (Cascade), and config.
|
|
1226
1264
|
|
|
1227
1265
|
## See also
|
|
@@ -1299,21 +1337,21 @@ The user is loaded via `Model.find(decodedToken.id)` against the `config.auth.us
|
|
|
1299
1337
|
|
|
1300
1338
|
A token passes three separate checks, in order — a token can fail any one of them while satisfying the other two:
|
|
1301
1339
|
|
|
1302
|
-
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
|
|
1340
|
+
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.
|
|
1303
1341
|
2. **The access-token row still exists** — deleting it (logout) invalidates the token immediately, before its JWT expiry.
|
|
1304
1342
|
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.
|
|
1305
1343
|
|
|
1306
1344
|
Missing or invalid credentials return 401; an authenticated disallowed user type returns 403:
|
|
1307
1345
|
|
|
1308
|
-
| Error code
|
|
1309
|
-
|
|
|
1310
|
-
| `MissingAccessToken` | No credential at the configured source
|
|
1346
|
+
| Error code | When |
|
|
1347
|
+
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
|
1348
|
+
| `MissingAccessToken` | No credential at the configured source |
|
|
1311
1349
|
| `InvalidAccessToken` | Token doesn't verify (signature, missing/passed `exp`, wrong token type), has no DB row, or the row's `expires_at` has passed |
|
|
1312
|
-
| `Unauthorized`
|
|
1350
|
+
| `Unauthorized` | Token valid but user-type isn't in the allowed list (403) |
|
|
1313
1351
|
|
|
1314
1352
|
## Page routes: redirect to login instead of the JSON 401
|
|
1315
1353
|
|
|
1316
|
-
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
|
|
1354
|
+
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.
|
|
1317
1355
|
|
|
1318
1356
|
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:
|
|
1319
1357
|
|
|
@@ -1323,7 +1361,7 @@ import { User } from "app/users/models/user/user.model";
|
|
|
1323
1361
|
export default {
|
|
1324
1362
|
userType: { user: User },
|
|
1325
1363
|
pageAuth: {
|
|
1326
|
-
loginPath: "/login",
|
|
1364
|
+
loginPath: "/login", // set this to turn the redirect on
|
|
1327
1365
|
returnUrlParam: "returnUrl", // optional, this is the default
|
|
1328
1366
|
},
|
|
1329
1367
|
};
|
|
@@ -1355,7 +1393,7 @@ declare module "@warlock.js/auth" {
|
|
|
1355
1393
|
import { type RequestHandler } from "@warlock.js/core";
|
|
1356
1394
|
|
|
1357
1395
|
export const accountController: RequestHandler = async ({ request, response }) => {
|
|
1358
|
-
const user = request.locals.user!;
|
|
1396
|
+
const user = request.locals.user!; // typed via the RequestUser augmentation above
|
|
1359
1397
|
return response.success({
|
|
1360
1398
|
id: user.id,
|
|
1361
1399
|
email: user.get("email"),
|
|
@@ -1381,9 +1419,14 @@ Every route inside the group is gated — the group's `middleware` array applies
|
|
|
1381
1419
|
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.
|
|
1382
1420
|
|
|
1383
1421
|
```ts
|
|
1384
|
-
middleware: [authMiddleware({ source: "header", optional: true })]
|
|
1422
|
+
middleware: [authMiddleware({ source: "header", optional: true })];
|
|
1385
1423
|
```
|
|
1386
1424
|
|
|
1425
|
+
`authMiddleware()` marks itself as an auth guard for `warlock dev`'s
|
|
1426
|
+
unguarded-route warning (`isAuthGuardMiddleware()` from `@warlock.js/core`);
|
|
1427
|
+
the optional form does not count, so a mutating route behind only optional
|
|
1428
|
+
auth still needs `public: true` to stay quiet.
|
|
1429
|
+
|
|
1387
1430
|
## Automatic cookie renewal
|
|
1388
1431
|
|
|
1389
1432
|
Add `refresh: { source: "cookie", key: "refresh_token", overlapMs?: 5000 }`
|
|
@@ -1393,6 +1436,7 @@ through `authService.setAuthCookie`; it never replays a handler. `overlapMs` is
|
|
|
1393
1436
|
clamped to 0–10,000 ms (default 5,000). Only the exact immediate successor is
|
|
1394
1437
|
tolerated during that window. The legacy `refreshTokens` API stays strict; a
|
|
1395
1438
|
later old-token presentation revokes its family.
|
|
1439
|
+
|
|
1396
1440
|
## Custom error responses
|
|
1397
1441
|
|
|
1398
1442
|
The middleware uses the framework's `response.unauthorized({...})` shape. To override the response globally, hook the framework's error transformer to remap `AuthErrorCodes.*` codes.
|
|
@@ -1417,8 +1461,9 @@ import { User } from "app/users/models/user/user.model";
|
|
|
1417
1461
|
|
|
1418
1462
|
const articlesController: RequestHandler = async ({ request, response }) => {
|
|
1419
1463
|
// Who-may-act-on-whom: an ABAC policy on top of the RBAC grant
|
|
1420
|
-
definePolicy(
|
|
1421
|
-
|
|
1464
|
+
definePolicy(
|
|
1465
|
+
"users.create",
|
|
1466
|
+
(actor, target, ctx) => ctx.hasRole("superAdmin") || (target as User).userType === "teacher",
|
|
1422
1467
|
);
|
|
1423
1468
|
|
|
1424
1469
|
if (await can(request.locals.user!, "users.create", { resource: request.body })) {
|
|
@@ -1429,10 +1474,12 @@ const articlesController: RequestHandler = async ({ request, response }) => {
|
|
|
1429
1474
|
};
|
|
1430
1475
|
|
|
1431
1476
|
// Permission-based route gate, in place of a user-type gate
|
|
1432
|
-
router.post("/articles", articlesController, {
|
|
1477
|
+
router.post("/articles", articlesController, {
|
|
1478
|
+
middleware: [authMiddleware([]), gate("articles.create")],
|
|
1479
|
+
});
|
|
1433
1480
|
```
|
|
1434
1481
|
|
|
1435
|
-
Use `authMiddleware` to establish
|
|
1482
|
+
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.
|
|
1436
1483
|
|
|
1437
1484
|
## Cookie credentials
|
|
1438
1485
|
|
|
@@ -1448,7 +1495,7 @@ A route is header-only or cookie-only, never both — `tokenFrom` accepts exactl
|
|
|
1448
1495
|
|
|
1449
1496
|
### Writing and clearing the cookie: `authService.setAuthCookie` / `clearAuthCookie`
|
|
1450
1497
|
|
|
1451
|
-
Reading a cookie is opt-in per route (above);
|
|
1498
|
+
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.
|
|
1452
1499
|
|
|
1453
1500
|
```ts
|
|
1454
1501
|
import { type RequestHandler } from "@warlock.js/core";
|
|
@@ -1594,7 +1641,7 @@ export const registerController: RequestHandler = async ({ request, response })
|
|
|
1594
1641
|
|
|
1595
1642
|
// 4. Respond
|
|
1596
1643
|
return response.successCreate({
|
|
1597
|
-
user,
|
|
1644
|
+
user, // shape via static toJsonColumns / static resource
|
|
1598
1645
|
tokens,
|
|
1599
1646
|
});
|
|
1600
1647
|
};
|
|
@@ -1609,7 +1656,7 @@ Always pass `hashPassword(plain)` — never store the plain password. The `hashP
|
|
|
1609
1656
|
```ts
|
|
1610
1657
|
import { hashPassword, verifyPassword } from "@warlock.js/core";
|
|
1611
1658
|
|
|
1612
|
-
const hash = await hashPassword("plaintext");
|
|
1659
|
+
const hash = await hashPassword("plaintext"); // store this
|
|
1613
1660
|
const ok = await verifyPassword("plaintext", hash); // compare on login
|
|
1614
1661
|
```
|
|
1615
1662
|
|
|
@@ -1623,7 +1670,7 @@ Define the password as `v.string().strongPassword(12)` (or similar) in your User
|
|
|
1623
1670
|
const userSchema = v.object({
|
|
1624
1671
|
email: v.string().email(),
|
|
1625
1672
|
name: v.string().min(2).max(120),
|
|
1626
|
-
password: v.string().strongPassword(12),
|
|
1673
|
+
password: v.string().strongPassword(12), // 12+ chars, upper/lower/digit/symbol
|
|
1627
1674
|
// status, role, etc.
|
|
1628
1675
|
});
|
|
1629
1676
|
```
|
|
@@ -1635,7 +1682,7 @@ But **don't return the password in the public output**:
|
|
|
1635
1682
|
export class User extends Model<UserSchema> {
|
|
1636
1683
|
public static table = "users";
|
|
1637
1684
|
public static schema = userSchema;
|
|
1638
|
-
public static toJsonColumns = ["id", "email", "name", "created_at"];
|
|
1685
|
+
public static toJsonColumns = ["id", "email", "name", "created_at"]; // omit password
|
|
1639
1686
|
}
|
|
1640
1687
|
```
|
|
1641
1688
|
|
|
@@ -1786,7 +1833,7 @@ Register with `registerAuthPurgeNeverExpiringCommand()`.
|
|
|
1786
1833
|
|
|
1787
1834
|
Finds every token row that **can never retire itself**, on two independent signals: an `expires_at` that is missing or unparseable, and a stored token carrying no `exp` claim. It reports `id` / `user_id` / `user_type` / `expires_at` per row — never the token string, which is a live credential until the row is deleted — and then deletes them. Affected users must log in again.
|
|
1788
1835
|
|
|
1789
|
-
**This is not a substitute for `auth.cleanup`, and `auth.cleanup` is not a substitute for it.** Cleanup selects `expires_at < now`; an `Invalid Date` compares `false` against
|
|
1836
|
+
**This is not a substitute for `auth.cleanup`, and `auth.cleanup` is not a substitute for it.** Cleanup selects `expires_at < now`; an `Invalid Date` compares `false` against _every_ date, so such a row satisfies neither `< now` nor `> now` and no date predicate can ever reach it. The "no `exp` claim" signal is not in a column at all — it is inside the JWT.
|
|
1790
1837
|
|
|
1791
1838
|
Run it once after upgrading to 4.12.0 **if any deployment ever ran an `expiresIn` that `ms` could not parse** (`"30dayz"`, `"0d"`, `""`, a bare number). Those configurations minted tokens that verify forever. It is safe to run when unaffected — it reports nothing and deletes nothing.
|
|
1792
1839
|
|
|
@@ -1863,7 +1910,7 @@ description: 'Brute-force / credential-stuffing protection via `loginThrottleMid
|
|
|
1863
1910
|
|
|
1864
1911
|
`loginThrottleMiddleware(options?)` returns a Warlock middleware that defends the login (and refresh / password-reset) routes against brute-force and credential-stuffing — without punishing a user who fat-fingers a password then gets it right.
|
|
1865
1912
|
|
|
1866
|
-
The trick that makes it different from a plain rate limiter: it's **failure-aware**. It hooks the response
|
|
1913
|
+
The trick that makes it different from a plain rate limiter: it's **failure-aware**. It hooks the response _after_ your controller runs (`response.onSent`), counts only the attempts that actually failed, and **clears the counter the moment a login succeeds**. A generic request limiter can't do that — it runs before the controller and counts every hit, success or not.
|
|
1867
1914
|
|
|
1868
1915
|
## The shortest version
|
|
1869
1916
|
|
|
@@ -1879,6 +1926,35 @@ That's the whole feature for most apps. Defaults: **5** failed attempts within a
|
|
|
1879
1926
|
|
|
1880
1927
|
> **Heads-up — account-lockout is a DoS surface.** Because the default tracks per-`email`, an attacker who knows a victim's address can lock them out by spamming failed logins. The per-IP counter mitigates it (the attacker's own IP trips too); for purely anonymous endpoints prefer `by: ["ip"]`, and for high-value accounts consider a CAPTCHA step over a hard lock. See [Gotchas](#gotchas).
|
|
1881
1928
|
|
|
1929
|
+
## What new apps get by default
|
|
1930
|
+
|
|
1931
|
+
The auth package registers no routes, so throttles apply where you attach them.
|
|
1932
|
+
A 5.31 `create-warlock` app already attaches `loginThrottleMiddleware()` to
|
|
1933
|
+
`/auth/login`, `tokenIssueThrottleMiddleware()` to `/auth/forgot-password`,
|
|
1934
|
+
`tokenConsumeThrottleMiddleware()` to `/auth/reset-password` and a 30/minute
|
|
1935
|
+
route `rateLimit` to `/auth/refresh-token`, all marked `public: true`. Every
|
|
1936
|
+
route also sits under core's global limit (60 requests/minute per IP). Older
|
|
1937
|
+
apps add the middleware to their own `src/app/auth/routes.ts`:
|
|
1938
|
+
|
|
1939
|
+
```ts
|
|
1940
|
+
import { tokenConsumeThrottleMiddleware, tokenIssueThrottleMiddleware } from "@warlock.js/auth";
|
|
1941
|
+
import { router, type RequestHandler } from "@warlock.js/core";
|
|
1942
|
+
|
|
1943
|
+
const forgotPasswordController: RequestHandler = async ({ response }) =>
|
|
1944
|
+
response.success({ sent: true });
|
|
1945
|
+
const resetPasswordController: RequestHandler = async ({ response }) =>
|
|
1946
|
+
response.success({ reset: true });
|
|
1947
|
+
|
|
1948
|
+
router.post("/auth/forgot-password", forgotPasswordController, {
|
|
1949
|
+
public: true,
|
|
1950
|
+
middleware: [tokenIssueThrottleMiddleware()],
|
|
1951
|
+
});
|
|
1952
|
+
router.post("/auth/reset-password", resetPasswordController, {
|
|
1953
|
+
public: true,
|
|
1954
|
+
middleware: [tokenConsumeThrottleMiddleware()],
|
|
1955
|
+
});
|
|
1956
|
+
```
|
|
1957
|
+
|
|
1882
1958
|
## How it behaves
|
|
1883
1959
|
|
|
1884
1960
|
1. **Before the controller** — if the account or the IP is currently locked, it short-circuits with `429` and never touches the database or the bcrypt verify (this is also what neutralises the CPU-DoS angle of brute-forcing). The body carries `AuthErrorCodes.TooManyAttempts` (`EC004`).
|
|
@@ -1890,10 +1966,10 @@ Per-account tracking stops a password-spray against one user from many IPs; per-
|
|
|
1890
1966
|
|
|
1891
1967
|
```ts
|
|
1892
1968
|
loginThrottleMiddleware({
|
|
1893
|
-
max: 5,
|
|
1894
|
-
window: "15m",
|
|
1969
|
+
max: 5, // failures allowed in the window before lockout
|
|
1970
|
+
window: "15m", // counting window — ms-format string or seconds (number)
|
|
1895
1971
|
lockoutDuration: "15m", // how long the lock lasts once tripped
|
|
1896
|
-
by: ["email", "ip"],
|
|
1972
|
+
by: ["email", "ip"], // identifiers tracked, each independently
|
|
1897
1973
|
identifierKey: "email", // which credential field is the account key
|
|
1898
1974
|
errorMessage: "Too many attempts. Try again later.",
|
|
1899
1975
|
});
|
|
@@ -1911,16 +1987,16 @@ loginThrottleMiddleware({
|
|
|
1911
1987
|
});
|
|
1912
1988
|
```
|
|
1913
1989
|
|
|
1914
|
-
| Option
|
|
1915
|
-
|
|
|
1916
|
-
| `max`
|
|
1917
|
-
| `window`
|
|
1918
|
-
| `lockoutDuration` | `"15m"`
|
|
1919
|
-
| `by`
|
|
1920
|
-
| `identifierKey`
|
|
1921
|
-
| `errorMessage`
|
|
1922
|
-
| `isFailure`
|
|
1923
|
-
| `identify`
|
|
1990
|
+
| Option | Default | Purpose |
|
|
1991
|
+
| ----------------- | ---------------------------------- | ----------------------------------------- |
|
|
1992
|
+
| `max` | `5` | Failures within the window before lockout |
|
|
1993
|
+
| `window` | `"15m"` | Counting window (`ms`-string or seconds) |
|
|
1994
|
+
| `lockoutDuration` | `"15m"` | Lock TTL once tripped |
|
|
1995
|
+
| `by` | `["email", "ip"]` | Identifiers tracked independently |
|
|
1996
|
+
| `identifierKey` | `"email"` | Credential field used as the account key |
|
|
1997
|
+
| `errorMessage` | i18n `auth.errors.tooManyAttempts` | 429 message override |
|
|
1998
|
+
| `isFailure` | `(res) => !res.isOk` | What counts as a failed attempt |
|
|
1999
|
+
| `identify` | built-in email + ip extraction | Custom identifier list |
|
|
1924
2000
|
|
|
1925
2001
|
## The 429 response
|
|
1926
2002
|
|
|
@@ -1948,20 +2024,20 @@ router.post("/auth/forgot-password", forgotController, {
|
|
|
1948
2024
|
|
|
1949
2025
|
## When to use this vs core's `rateLimit`
|
|
1950
2026
|
|
|
1951
|
-
|
|
|
1952
|
-
|
|
|
1953
|
-
| Counts
|
|
1954
|
-
| Resets on a successful login | yes
|
|
1955
|
-
| Locks per account / per IP
|
|
1956
|
-
| Storage
|
|
2027
|
+
| | `loginThrottleMiddleware` (auth) | `middleware.rateLimit` (core) |
|
|
2028
|
+
| ---------------------------- | -------------------------------------------- | ----------------------------- |
|
|
2029
|
+
| Counts | only **failed** logins | **every** request |
|
|
2030
|
+
| Resets on a successful login | yes | no |
|
|
2031
|
+
| Locks per account / per IP | yes | per key (default IP) |
|
|
2032
|
+
| Storage | `@warlock.js/cache` (shared across replicas) | in-process map |
|
|
1957
2033
|
|
|
1958
|
-
They compose. For a hard ceiling on request volume
|
|
2034
|
+
They compose. For a hard ceiling on request volume _and_ failure-aware account lockout, stack both: `middleware: [middleware.rateLimit({ max: 30, duration: 60_000 }), loginThrottleMiddleware()]`.
|
|
1959
2035
|
|
|
1960
2036
|
## Gotchas
|
|
1961
2037
|
|
|
1962
2038
|
- **Needs an initialised cache driver.** Storage is `@warlock.js/cache` (a peer dep, transitively present via core). If the driver isn't configured at runtime, the middleware **fails open** — it logs and lets the request through, because a throttle outage must never become an auth outage. That also means: no cache, no protection. Verify your cache config in production.
|
|
1963
2039
|
- **Account lockout can be weaponised.** An attacker who knows a victim's email can lock them out by spamming failures. The per-IP counter mitigates this (the attacker's IP locks too), but for purely anonymous endpoints prefer `by: ["ip"]`. For high-value accounts, consider a CAPTCHA step instead of a hard lock.
|
|
1964
|
-
- **Counts are atomic and exact across servers (redis/pg).** Each failure does a create-only `cache.set(key, 0, { ttl: window })` (only the first failure opens the window and sets the TTL) followed by an atomic `cache.increment(key)`, so concurrent failures on different servers are never lost. The memory cache driver is per-process — counts and locks are then per-process, so use redis/pg when running more than one instance.
|
|
2040
|
+
- **Counts are atomic and exact across servers (redis/pg).** Each failure does a create-only `cache.set(key, 0, { ttl: window })` (only the first failure opens the window and sets the TTL) followed by an atomic `cache.increment(key, 1, { ttl: window })`, so concurrent failures on different servers are never lost. Passing the TTL on the increment also expires a counter recreated after its seed expires, without extending an existing window. The memory cache driver is per-process — counts and locks are then per-process, so use redis/pg when running more than one instance.
|
|
1965
2041
|
- **Account and IP keys are hashed.** The built-in extractor keys both by an HMAC of the trimmed, lower-cased value (`email.<hmac>`, `ip.<hmac>`), keyed from `auth.accessToken.secret`, so cache logs never show the address or IP and a list of known emails cannot reverse the key. Without a configured secret it falls back to SHA-256. A custom `identify` is used as is — hash personal values yourself (`throttleIdentifierHash(kind, value)` is exported).
|
|
1966
2042
|
- **Fixed window, not sliding.** The window is anchored at the first failure and does not extend on each subsequent one — predictable lockout timing. The lock key is independent and always lasts `lockoutDuration`: the lock stores its own deadline, so even a sliding-expiry driver (`memoryExtended`) cannot extend it.
|
|
1967
2043
|
- **Needs a real cache driver.** The `null` driver stores nothing, so the throttle never locks; it warns once when that happens. On Redis, any version from 6 works.
|
|
@@ -2073,10 +2149,10 @@ router.post("/auth/reset-password", resetPasswordController, {
|
|
|
2073
2149
|
|
|
2074
2150
|
Both presets wrap `loginThrottleMiddleware` and accept its options:
|
|
2075
2151
|
|
|
2076
|
-
| Preset
|
|
2077
|
-
|
|
|
2078
|
-
| `tokenIssueThrottleMiddleware`
|
|
2079
|
-
| `tokenConsumeThrottleMiddleware` | IP
|
|
2152
|
+
| Preset | Tracks | Counts | Default |
|
|
2153
|
+
| -------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------ |
|
|
2154
|
+
| `tokenIssueThrottleMiddleware` | email + IP | **every** request (a reset request's success is the anti-enumeration answer, so failure-aware counting would never trip) | 3 / 1h, 1h lock |
|
|
2155
|
+
| `tokenConsumeThrottleMiddleware` | IP | failures only, cleared on success | 10 / 15m, 15m lock |
|
|
2080
2156
|
|
|
2081
2157
|
## Configuration (`src/config/auth.ts`)
|
|
2082
2158
|
|
|
@@ -2094,7 +2170,9 @@ const authConfigurations: AuthConfigurations = {
|
|
|
2094
2170
|
identifierField: "email",
|
|
2095
2171
|
url: (token) => `${env("APP_URL")}/reset-password?token=${token}`,
|
|
2096
2172
|
notification: myResetNotification,
|
|
2097
|
-
setPassword: async (user, plain) => {
|
|
2173
|
+
setPassword: async (user, plain) => {
|
|
2174
|
+
/* optional */
|
|
2175
|
+
},
|
|
2098
2176
|
},
|
|
2099
2177
|
oneTimeToken: { model: AppOneTimeToken }, // optional model override
|
|
2100
2178
|
};
|