@warlock.js/auth 5.19.1 → 5.20.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/llms-full.txt CHANGED
@@ -130,864 +130,864 @@ Two values that look right and are not:
130
130
 
131
131
  ## customize-token-storage `@warlock.js/auth/customize-token-storage/SKILL.md`
132
132
 
133
- ---
134
- name: customize-token-storage
135
- description: "Customize token storage in @warlock.js/auth; use when you need to customize token storage."
136
- ---
137
-
138
- # Customize token storage
139
-
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 *active* model from config. So you change token storage by **registering a subclass**, not by forking.
141
-
142
- 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
-
144
- ```ts title="src/app/users/models/user/user.model.ts"
145
- import { Auth } from "@warlock.js/auth";
146
- import { RegisterModel } from "@warlock.js/cascade";
147
-
148
- @RegisterModel()
149
- export class User extends Auth {
150
- public static table = "users";
151
-
152
- public get userType(): string {
153
- return "user";
154
- }
155
- }
156
- ```
157
-
158
- ## The three pieces
159
-
160
- A storage override is always three coordinated steps. Miss any one and it breaks — see the strict-mode note below.
161
-
162
- ### 1. Extend the model + its schema
163
-
164
- `accessTokenSchema` / `refreshTokenSchema` are exported so you compose them with `.extend(...)` instead of re-declaring the base shape. Override `issue()` to populate your new column (the user is in hand there).
165
-
166
- ```ts title="src/app/auth/models/app-access-token.ts"
167
- import { AccessToken, accessTokenSchema, type Auth } from "@warlock.js/auth";
168
- import { v } from "@warlock.js/seal";
169
-
170
- export class AppAccessToken extends AccessToken {
171
- public static schema = accessTokenSchema.extend({
172
- organization_id: v.string().exists("Organization", { column: "id" }),
173
- });
174
-
175
- // populate the tenant key on issue — read it off the authenticating user
176
- public static issue(user: Auth, token: string, expiresAt: Date) {
177
- return this.create({
178
- token,
179
- user_id: user.id,
180
- user_type: user.userType,
181
- expires_at: expiresAt,
182
- organization_id: user.get("organization_id"),
183
- });
184
- }
185
- }
186
- ```
187
-
188
- 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
-
190
- ```ts title="src/app/auth/models/app-refresh-token.ts"
191
- import { RefreshToken, refreshTokenSchema, type Auth, type RefreshTokenIssueOptions } from "@warlock.js/auth";
192
- import { v } from "@warlock.js/seal";
193
-
194
- export class AppRefreshToken extends RefreshToken {
195
- public static schema = refreshTokenSchema.extend({
196
- organization_id: v.string().exists("Organization", { column: "id" }),
197
- });
198
-
199
- public static issue(user: Auth, token: string, options: RefreshTokenIssueOptions) {
200
- return this.create({
201
- token,
202
- user_id: user.id,
203
- user_type: user.userType,
204
- family_id: options.familyId,
205
- expires_at: options.expiresAt,
206
- organization_id: user.get("organization_id"),
207
- });
208
- }
209
- }
210
- ```
211
-
212
- ### 2. Register the subclass in config
213
-
214
- ```ts title="src/config/auth.ts"
215
- import { env } from "@warlock.js/core";
216
- import { User } from "app/users/models/user/user.model";
217
- import { AppAccessToken } from "app/auth/models/app-access-token";
218
- import { AppRefreshToken } from "app/auth/models/app-refresh-token";
219
-
220
- export default {
221
- userType: { user: User },
222
- accessToken: {
223
- model: AppAccessToken, // ← the override
224
- secret: env("JWT_SECRET"),
225
- expiresIn: "1h",
226
- },
227
- refreshToken: {
228
- model: AppRefreshToken, // ← the override
229
- secret: env("JWT_REFRESH_SECRET"),
230
- expiresIn: "30d",
231
- },
232
- };
233
- ```
234
-
235
- From here the service, the middleware, and every `authService` helper transparently use your model — `findByToken`, `revokeAllFor`, `enforceMax`, and the rest all run against your columns.
236
-
237
- ### 3. Add the column to the migration
238
-
239
- The new column needs a real database column. Add it to your token-table migration (the FK + index match your `User` model's tenant convention):
240
-
241
- ```ts
242
- this.uuid("organization_id").references("organizations").onDelete("cascade").index();
243
- ```
244
-
245
- See [`@warlock.js/cascade/write-migration/SKILL.md`](@warlock.js/cascade/write-migration/SKILL.md) for the migration mechanics.
246
-
247
- ## Why all three — the strict-mode trap
248
-
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 *schema* must declare it so it survives → the *migration* must create the column. All three, or nothing.
250
-
251
- ## You do NOT need read-side scoping
252
-
253
- Token lookups go by the unique `token` string (`findByToken`), which already uniquely identifies a row regardless of tenant. So you don't need a tenant-scoped global query scope on the token model — `organization_id` is there for FK cascade-cleanup when an org is deleted, plus partitioning and analytics, not for lookup safety.
254
-
255
- ## What you're overriding (the contract)
256
-
257
- Your subclass inherits and may override these statics — the service calls them, never raw queries:
258
-
259
- | Static | Role |
260
- | --- | --- |
261
- | `issue(user, token, …)` | persist a freshly-signed token (override to add columns) |
262
- | `findByToken(token)` | look a row up by its token string |
263
- | `findForUser(user, token)` | a user-scoped lookup (logout) |
264
- | `activeFor(user)` | active, unexpired sessions, newest-first |
265
- | `revokeAllFor(user)` / `revokeFamily(id)` | revoke a set, returning the revoked rows |
266
- | `enforceMax(user, max)` | cap concurrent refresh tokens |
267
- | `purgeExpired()` | delete expired rows (CLI cleanup) |
268
- | `findNeverExpiring()` / `purgeNeverExpiring()` | find/delete rows that can never expire (CLI remediation) |
269
-
270
- Plus two instance getters, both of which the route gate consults on every request:
271
-
272
- | Getter | Role |
273
- | --- | --- |
274
- | `isExpired` | whether the row's persisted expiry has passed — **the middleware rejects the request when this is `true`** |
275
- | `neverExpires` | whether nothing can ever retire the row (unusable `expires_at`, or a token with no `exp` claim) |
276
-
277
- 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
-
279
- ⚠️ **Do not drop `isExpired`.** The middleware treats a row that cannot answer as **expired** and rejects the request, so removing the getter locks every user out rather than letting them through. That direction is deliberate: the fail-open reading is what let expired sessions stay usable before 4.12.0.
280
-
281
- ## Things NOT to do
282
-
283
- - **Don't set a column without declaring it in `schema.extend(...)`.** `strictMode: "strip"` removes it; a NOT-NULL column then fails the INSERT.
284
- - **Don't add a tenant-scoped global scope to the token model.** Lookups are by unique token; a leaky scope is a cross-tenant exposure risk for no lookup benefit.
285
- - **Don't fork the package to add a column.** Extend + register — you keep receiving package fixes.
286
- - **Don't forget the migration.** The schema declares the field; only the migration creates the database column.
287
-
288
- ## See also
289
-
290
- - [`@warlock.js/auth/customize-user-type/SKILL.md`](@warlock.js/auth/customize-user-type/SKILL.md) — multiple user *types* (a different axis from token *storage*).
291
- - [`@warlock.js/auth/manage-tokens/SKILL.md`](@warlock.js/auth/manage-tokens/SKILL.md) — the token lifecycle your statics power.
292
- - [`@warlock.js/cascade/define-model/SKILL.md`](@warlock.js/cascade/define-model/SKILL.md) — extending models and schemas.
133
+ ---
134
+ name: customize-token-storage
135
+ description: 'Override the persisted AccessToken / RefreshToken models to add columns (multi-tenant `organization_id`, device metadata), rename, or change storage — without forking the package. Extend the model + `schema.extend(...)`, register it under `config.auth.accessToken.model` / `config.auth.refreshToken.model`, override `issue()` to populate the new column, and add a migration. Triggers: `accessToken.model`, `refreshToken.model`, `AccessToken.issue`, `RefreshToken.issue`, `accessTokenSchema`, `refreshTokenSchema`, "add a column to the token table", "multi-tenant tokens", "organization_id on access token", "override the token model", "custom token storage"; typical import `import { AccessToken, accessTokenSchema } from "@warlock.js/auth"`. Skip: multiple user TYPES (not token storage) — `@warlock.js/auth/customize-user-type/SKILL.md`; the token lifecycle API — `@warlock.js/auth/manage-tokens/SKILL.md`; the config blocks themselves — `@warlock.js/auth/auth-basics/SKILL.md`.'
136
+ ---
137
+
138
+ # Customize token storage
139
+
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 *active* model from config. So you change token storage by **registering a subclass**, not by forking.
141
+
142
+ 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
+
144
+ ```ts title="src/app/users/models/user/user.model.ts"
145
+ import { Auth } from "@warlock.js/auth";
146
+ import { RegisterModel } from "@warlock.js/cascade";
147
+
148
+ @RegisterModel()
149
+ export class User extends Auth {
150
+ public static table = "users";
151
+
152
+ public get userType(): string {
153
+ return "user";
154
+ }
155
+ }
156
+ ```
157
+
158
+ ## The three pieces
159
+
160
+ A storage override is always three coordinated steps. Miss any one and it breaks — see the strict-mode note below.
161
+
162
+ ### 1. Extend the model + its schema
163
+
164
+ `accessTokenSchema` / `refreshTokenSchema` are exported so you compose them with `.extend(...)` instead of re-declaring the base shape. Override `issue()` to populate your new column (the user is in hand there).
165
+
166
+ ```ts title="src/app/auth/models/app-access-token.ts"
167
+ import { AccessToken, accessTokenSchema, type Auth } from "@warlock.js/auth";
168
+ import { v } from "@warlock.js/seal";
169
+
170
+ export class AppAccessToken extends AccessToken {
171
+ public static schema = accessTokenSchema.extend({
172
+ organization_id: v.string().exists("Organization", { column: "id" }),
173
+ });
174
+
175
+ // populate the tenant key on issue — read it off the authenticating user
176
+ public static issue(user: Auth, token: string, expiresAt: Date) {
177
+ return this.create({
178
+ token,
179
+ user_id: user.id,
180
+ user_type: user.userType,
181
+ expires_at: expiresAt,
182
+ organization_id: user.get("organization_id"),
183
+ });
184
+ }
185
+ }
186
+ ```
187
+
188
+ 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
+
190
+ ```ts title="src/app/auth/models/app-refresh-token.ts"
191
+ import { RefreshToken, refreshTokenSchema, type Auth, type RefreshTokenIssueOptions } from "@warlock.js/auth";
192
+ import { v } from "@warlock.js/seal";
193
+
194
+ export class AppRefreshToken extends RefreshToken {
195
+ public static schema = refreshTokenSchema.extend({
196
+ organization_id: v.string().exists("Organization", { column: "id" }),
197
+ });
198
+
199
+ public static issue(user: Auth, token: string, options: RefreshTokenIssueOptions) {
200
+ return this.create({
201
+ token,
202
+ user_id: user.id,
203
+ user_type: user.userType,
204
+ family_id: options.familyId,
205
+ expires_at: options.expiresAt,
206
+ organization_id: user.get("organization_id"),
207
+ });
208
+ }
209
+ }
210
+ ```
211
+
212
+ ### 2. Register the subclass in config
213
+
214
+ ```ts title="src/config/auth.ts"
215
+ import { env } from "@warlock.js/core";
216
+ import { User } from "app/users/models/user/user.model";
217
+ import { AppAccessToken } from "app/auth/models/app-access-token";
218
+ import { AppRefreshToken } from "app/auth/models/app-refresh-token";
219
+
220
+ export default {
221
+ userType: { user: User },
222
+ accessToken: {
223
+ model: AppAccessToken, // ← the override
224
+ secret: env("JWT_SECRET"),
225
+ expiresIn: "1h",
226
+ },
227
+ refreshToken: {
228
+ model: AppRefreshToken, // ← the override
229
+ secret: env("JWT_REFRESH_SECRET"),
230
+ expiresIn: "30d",
231
+ },
232
+ };
233
+ ```
234
+
235
+ From here the service, the middleware, and every `authService` helper transparently use your model — `findByToken`, `revokeAllFor`, `enforceMax`, and the rest all run against your columns.
236
+
237
+ ### 3. Add the column to the migration
238
+
239
+ The new column needs a real database column. Add it to your token-table migration (the FK + index match your `User` model's tenant convention):
240
+
241
+ ```ts
242
+ this.uuid("organization_id").references("organizations").onDelete("cascade").index();
243
+ ```
244
+
245
+ See [`@warlock.js/cascade/write-migration/SKILL.md`](@warlock.js/cascade/write-migration/SKILL.md) for the migration mechanics.
246
+
247
+ ## Why all three — the strict-mode trap
248
+
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 *schema* must declare it so it survives → the *migration* must create the column. All three, or nothing.
250
+
251
+ ## You do NOT need read-side scoping
252
+
253
+ Token lookups go by the unique `token` string (`findByToken`), which already uniquely identifies a row regardless of tenant. So you don't need a tenant-scoped global query scope on the token model — `organization_id` is there for FK cascade-cleanup when an org is deleted, plus partitioning and analytics, not for lookup safety.
254
+
255
+ ## What you're overriding (the contract)
256
+
257
+ Your subclass inherits and may override these statics — the service calls them, never raw queries:
258
+
259
+ | Static | Role |
260
+ | --- | --- |
261
+ | `issue(user, token, …)` | persist a freshly-signed token (override to add columns) |
262
+ | `findByToken(token)` | look a row up by its token string |
263
+ | `findForUser(user, token)` | a user-scoped lookup (logout) |
264
+ | `activeFor(user)` | active, unexpired sessions, newest-first |
265
+ | `revokeAllFor(user)` / `revokeFamily(id)` | revoke a set, returning the revoked rows |
266
+ | `enforceMax(user, max)` | cap concurrent refresh tokens |
267
+ | `purgeExpired()` | delete expired rows (CLI cleanup) |
268
+ | `findNeverExpiring()` / `purgeNeverExpiring()` | find/delete rows that can never expire (CLI remediation) |
269
+
270
+ Plus two instance getters, both of which the route gate consults on every request:
271
+
272
+ | Getter | Role |
273
+ | --- | --- |
274
+ | `isExpired` | whether the row's persisted expiry has passed — **the middleware rejects the request when this is `true`** |
275
+ | `neverExpires` | whether nothing can ever retire the row (unusable `expires_at`, or a token with no `exp` claim) |
276
+
277
+ 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
+
279
+ ⚠️ **Do not drop `isExpired`.** The middleware treats a row that cannot answer as **expired** and rejects the request, so removing the getter locks every user out rather than letting them through. That direction is deliberate: the fail-open reading is what let expired sessions stay usable before 4.12.0.
280
+
281
+ ## Things NOT to do
282
+
283
+ - **Don't set a column without declaring it in `schema.extend(...)`.** `strictMode: "strip"` removes it; a NOT-NULL column then fails the INSERT.
284
+ - **Don't add a tenant-scoped global scope to the token model.** Lookups are by unique token; a leaky scope is a cross-tenant exposure risk for no lookup benefit.
285
+ - **Don't fork the package to add a column.** Extend + register — you keep receiving package fixes.
286
+ - **Don't forget the migration.** The schema declares the field; only the migration creates the database column.
287
+
288
+ ## See also
289
+
290
+ - [`@warlock.js/auth/customize-user-type/SKILL.md`](@warlock.js/auth/customize-user-type/SKILL.md) — multiple user *types* (a different axis from token *storage*).
291
+ - [`@warlock.js/auth/manage-tokens/SKILL.md`](@warlock.js/auth/manage-tokens/SKILL.md) — the token lifecycle your statics power.
292
+ - [`@warlock.js/cascade/define-model/SKILL.md`](@warlock.js/cascade/define-model/SKILL.md) — extending models and schemas.
293
293
 
294
294
 
295
295
  ## customize-user-type `@warlock.js/auth/customize-user-type/SKILL.md`
296
296
 
297
- ---
298
- name: customize-user-type
299
- description: "Customize user type (multi-user-type auth) in @warlock.js/auth; use when you need to customize user type."
300
- ---
297
+ ---
298
+ name: customize-user-type
299
+ description: 'Support multiple user types (user / admin / client / staff) in one auth system — each Auth subclass overrides userType, config.auth.userType.<slug> maps slug to model class, authMiddleware(''admin'') gates per type. Triggers: `Auth`, `userType`, `config.auth.userType`, `Authenticable`, `@RegisterModel`, `confirmPassword`; "add admins and users", "multiple user types", "separate client and vendor personas", "per-type login"; typical import `import { Auth } from "@warlock.js/auth"`. Skip: `authMiddleware` semantics — `@warlock.js/auth/protect-routes/SKILL.md`; login flow — `@warlock.js/auth/handle-login-and-logout/SKILL.md`; RBAC libs `casl`, `accesscontrol`, `rbac`.'
300
+ ---
301
+
302
+ # Customize user type (multi-user-type auth)
303
+
304
+ The `Auth` base class has a `userType` slot. Subclass it once per type, register each class under `config.auth.userType.<slug>`, and the auth flow handles the rest.
305
+
306
+ ## Define a model per user type
307
+
308
+ ```ts title="src/app/users/models/user/user.model.ts"
309
+ import { Auth } from "@warlock.js/auth";
310
+ import { RegisterModel } from "@warlock.js/cascade";
311
+ import { v, type Infer } from "@warlock.js/seal";
312
+
313
+ export const userSchema = v.object({
314
+ email: v.string().email(),
315
+ password: v.string(),
316
+ });
317
+
318
+ export type UserSchema = Infer<typeof userSchema>;
319
+
320
+ @RegisterModel()
321
+ export class User extends Auth<UserSchema> {
322
+ public static table = "users";
323
+ public static schema = userSchema;
324
+
325
+ public get userType(): string {
326
+ return "user";
327
+ }
328
+ }
329
+ ```
330
+
331
+ ```ts title="src/app/admins/models/admin.model.ts"
332
+ import { Auth } from "@warlock.js/auth";
333
+ import { RegisterModel } from "@warlock.js/cascade";
334
+ import { v, type Infer } from "@warlock.js/seal";
335
+
336
+ export const adminSchema = v.object({
337
+ email: v.string().email(),
338
+ password: v.string(),
339
+ });
340
+
341
+ export type AdminSchema = Infer<typeof adminSchema>;
342
+
343
+ @RegisterModel()
344
+ export class Admin extends Auth<AdminSchema> {
345
+ public static table = "admins";
346
+ public static schema = adminSchema;
347
+
348
+ public get userType(): string {
349
+ return "admin";
350
+ }
351
+ }
352
+ ```
353
+
354
+ Each gets its own table, its own schema, its own `userType` slug. They DON'T share table — they're separate models.
355
+
356
+ ## Register them in `config.auth`
357
+
358
+ ```ts title="src/config/auth.ts"
359
+ import { env } from "@warlock.js/core";
360
+ import { User } from "app/users/models/user/user.model";
361
+ import { Admin } from "app/admins/models/admin.model";
362
+
363
+ export default {
364
+ userType: {
365
+ user: User,
366
+ admin: Admin,
367
+ // staff: Staff,
368
+ // client: Client,
369
+ },
370
+ accessToken: {
371
+ secret: env("JWT_SECRET"),
372
+ expiresIn: "1h",
373
+ },
374
+ refreshToken: {
375
+ secret: env("JWT_REFRESH_SECRET"),
376
+ enabled: true,
377
+ expiresIn: "30d",
378
+ rotation: true,
379
+ },
380
+ };
381
+ ```
382
+
383
+ The keys (`"user"`, `"admin"`) are the **userType slugs** that flow through every token, middleware call, and event payload.
384
+
385
+ ## Gate routes per user type
386
+
387
+ ```ts
388
+ import { router, type RequestHandler } from "@warlock.js/core";
389
+ import { authMiddleware } from "@warlock.js/auth";
390
+
391
+ const userAccountController: RequestHandler = async ({ response }) => response.success({});
392
+ const listUsersController: RequestHandler = async ({ response }) => response.success({});
393
+ const backOfficeController: RequestHandler = async ({ response }) => response.success({});
394
+ const dashboardController: RequestHandler = async ({ response }) => response.success({});
395
+
396
+ router.get("/account", userAccountController, { middleware: [authMiddleware("user")] });
397
+ router.get("/admin/users", listUsersController, { middleware: [authMiddleware("admin")] });
398
+ router.get("/back-office", backOfficeController, { middleware: [authMiddleware(["admin", "staff"])] });
399
+ router.get("/dashboard", dashboardController, { middleware: [authMiddleware([])] }); // any logged-in
400
+ ```
401
+
402
+ See [`@warlock.js/auth/protect-routes/SKILL.md`](@warlock.js/auth/protect-routes/SKILL.md).
403
+
404
+ ## Login per user type — pass the right Model
405
+
406
+ `authService.login(Model, credentials, deviceInfo?)` is keyed off the model you pass:
407
+
408
+ ```ts
409
+ // User login endpoint
410
+ const result = await authService.login(User, credentials);
411
+ // Issues tokens with userType "user"; middleware will route them to User model.
412
+
413
+ // Admin login endpoint
414
+ const result = await authService.login(Admin, credentials);
415
+ // Issues tokens with userType "admin"; middleware will route them to Admin model.
416
+ ```
417
+
418
+ The middleware then uses `config.auth.userType[token.userType]` to know which model to hydrate.
419
+
420
+ ## Cross-type behavior
421
+
422
+ - **Tokens are scoped to their issuing user-type.** A user-type token doesn't unlock admin-type routes.
423
+ - **AccessToken / RefreshToken rows carry the `user_type` column.** Same model classes, different rows per type.
424
+ - **`authMiddleware(["admin", "user"])`** allows either — useful for endpoints shared between roles.
425
+
426
+ ## When NOT to use multi-user-type
427
+
428
+ If the distinction is **permissions/roles within one user shape**, use a `role` column on a single User model instead. Multi-user-type is right when:
429
+
430
+ - Different tables / schemas (admins have an `admin_level`; users have a `subscription_tier`).
431
+ - Separate registration flows (admins are created via an admin panel; users self-register).
432
+ - Truly separate concepts at the data layer (clients vs vendors in a marketplace).
433
+
434
+ If users and admins differ only in a `role` field, stick with one `User` model + a role check at the controller layer.
435
+
436
+ ## `Auth` base — what your subclass inherits
437
+
438
+ ```ts
439
+ abstract class Auth<TSchema> extends Model<TSchema> implements Authenticable {
440
+ // ...all the Model<> methods
441
+ public abstract get userType(): string;
442
+ public generateAccessToken(payload?: Record<string, unknown>): Promise<AccessTokenOutput>;
443
+ public generateRefreshToken(deviceInfo?: DeviceInfo): Promise<RefreshToken | undefined>;
444
+ public createTokenPair(deviceInfo?: DeviceInfo): Promise<TokenPair>;
445
+ public confirmPassword(password: string): Promise<boolean>;
446
+ }
447
+ ```
448
+
449
+ `userType` is the only required override (an abstract getter — return the type slug). Override `generateAccessToken` if you need a non-default payload.
450
+
451
+ `Auth` implements the `Authenticable` contract — that interface mirrors exactly these methods (`userType`, `generateAccessToken`, `generateRefreshToken`, `createTokenPair`, `confirmPassword`), so the class fails to compile if it drifts from the contract. Use `confirmPassword(plaintext)` to check a password against the stored hash (e.g. a "confirm current password" step).
452
+
453
+ ## Things NOT to do
454
+
455
+ - Don't use multi-user-type for what's really role-based access control. Use a `role` column on a single User model when the data shape is shared.
456
+ - Don't forget the `public get userType(): string` override. It's an abstract getter on `Auth` — a subclass without it won't compile, and middleware lookups key off its return value.
457
+ - Don't reuse the same `userType` slug across two models — the `config.auth.userType` map can only point one slug at one model.
458
+ - Don't put admins and users in the same table differentiated by a flag. Separate tables means migrations don't coupling, queries don't accidentally cross, and audit logs are cleaner.
459
+
460
+ ## See also
461
+
462
+ - [`@warlock.js/auth/protect-routes/SKILL.md`](@warlock.js/auth/protect-routes/SKILL.md) — `authMiddleware` semantics
463
+ - [`@warlock.js/auth/handle-login-and-logout/SKILL.md`](@warlock.js/auth/handle-login-and-logout/SKILL.md) — passing the right Model to `login`
464
+ - [`@warlock.js/cascade/define-model/SKILL.md`](@warlock.js/cascade/define-model/SKILL.md) — `@RegisterModel`, models in general
301
465
 
302
- # Customize user type (multi-user-type auth)
303
466
 
304
- The `Auth` base class has a `userType` slot. Subclass it once per type, register each class under `config.auth.userType.<slug>`, and the auth flow handles the rest.
467
+ ## handle-login-and-logout `@warlock.js/auth/handle-login-and-logout/SKILL.md`
305
468
 
306
- ## Define a model per user type
307
-
308
- ```ts title="src/app/users/models/user/user.model.ts"
309
- import { Auth } from "@warlock.js/auth";
310
- import { RegisterModel } from "@warlock.js/cascade";
311
- import { v, type Infer } from "@warlock.js/seal";
312
-
313
- export const userSchema = v.object({
314
- email: v.string().email(),
315
- password: v.string(),
316
- });
317
-
318
- export type UserSchema = Infer<typeof userSchema>;
319
-
320
- @RegisterModel()
321
- export class User extends Auth<UserSchema> {
322
- public static table = "users";
323
- public static schema = userSchema;
324
-
325
- public get userType(): string {
326
- return "user";
327
- }
328
- }
329
- ```
330
-
331
- ```ts title="src/app/admins/models/admin.model.ts"
332
- import { Auth } from "@warlock.js/auth";
333
- import { RegisterModel } from "@warlock.js/cascade";
334
- import { v, type Infer } from "@warlock.js/seal";
335
-
336
- export const adminSchema = v.object({
337
- email: v.string().email(),
338
- password: v.string(),
339
- });
340
-
341
- export type AdminSchema = Infer<typeof adminSchema>;
342
-
343
- @RegisterModel()
344
- export class Admin extends Auth<AdminSchema> {
345
- public static table = "admins";
346
- public static schema = adminSchema;
347
-
348
- public get userType(): string {
349
- return "admin";
350
- }
351
- }
352
- ```
353
-
354
- Each gets its own table, its own schema, its own `userType` slug. They DON'T share table — they're separate models.
355
-
356
- ## Register them in `config.auth`
357
-
358
- ```ts title="src/config/auth.ts"
359
- import { env } from "@warlock.js/core";
360
- import { User } from "app/users/models/user/user.model";
361
- import { Admin } from "app/admins/models/admin.model";
362
-
363
- export default {
364
- userType: {
365
- user: User,
366
- admin: Admin,
367
- // staff: Staff,
368
- // client: Client,
369
- },
370
- accessToken: {
371
- secret: env("JWT_SECRET"),
372
- expiresIn: "1h",
373
- },
374
- refreshToken: {
375
- secret: env("JWT_REFRESH_SECRET"),
376
- enabled: true,
377
- expiresIn: "30d",
378
- rotation: true,
379
- },
380
- };
381
- ```
382
-
383
- The keys (`"user"`, `"admin"`) are the **userType slugs** that flow through every token, middleware call, and event payload.
384
-
385
- ## Gate routes per user type
386
-
387
- ```ts
388
- import { router, type RequestHandler } from "@warlock.js/core";
389
- import { authMiddleware } from "@warlock.js/auth";
390
-
391
- const userAccountController: RequestHandler = async ({ response }) => response.success({});
392
- const listUsersController: RequestHandler = async ({ response }) => response.success({});
393
- const backOfficeController: RequestHandler = async ({ response }) => response.success({});
394
- const dashboardController: RequestHandler = async ({ response }) => response.success({});
395
-
396
- router.get("/account", userAccountController, { middleware: [authMiddleware("user")] });
397
- router.get("/admin/users", listUsersController, { middleware: [authMiddleware("admin")] });
398
- router.get("/back-office", backOfficeController, { middleware: [authMiddleware(["admin", "staff"])] });
399
- router.get("/dashboard", dashboardController, { middleware: [authMiddleware([])] }); // any logged-in
400
- ```
401
-
402
- See [`@warlock.js/auth/protect-routes/SKILL.md`](@warlock.js/auth/protect-routes/SKILL.md).
403
-
404
- ## Login per user type — pass the right Model
405
-
406
- `authService.login(Model, credentials, deviceInfo?)` is keyed off the model you pass:
407
-
408
- ```ts
409
- // User login endpoint
410
- const result = await authService.login(User, credentials);
411
- // Issues tokens with userType "user"; middleware will route them to User model.
412
-
413
- // Admin login endpoint
414
- const result = await authService.login(Admin, credentials);
415
- // Issues tokens with userType "admin"; middleware will route them to Admin model.
416
- ```
417
-
418
- The middleware then uses `config.auth.userType[token.userType]` to know which model to hydrate.
419
-
420
- ## Cross-type behavior
421
-
422
- - **Tokens are scoped to their issuing user-type.** A user-type token doesn't unlock admin-type routes.
423
- - **AccessToken / RefreshToken rows carry the `user_type` column.** Same model classes, different rows per type.
424
- - **`authMiddleware(["admin", "user"])`** allows either — useful for endpoints shared between roles.
425
-
426
- ## When NOT to use multi-user-type
427
-
428
- If the distinction is **permissions/roles within one user shape**, use a `role` column on a single User model instead. Multi-user-type is right when:
429
-
430
- - Different tables / schemas (admins have an `admin_level`; users have a `subscription_tier`).
431
- - Separate registration flows (admins are created via an admin panel; users self-register).
432
- - Truly separate concepts at the data layer (clients vs vendors in a marketplace).
433
-
434
- If users and admins differ only in a `role` field, stick with one `User` model + a role check at the controller layer.
435
-
436
- ## `Auth` base — what your subclass inherits
437
-
438
- ```ts
439
- abstract class Auth<TSchema> extends Model<TSchema> implements Authenticable {
440
- // ...all the Model<> methods
441
- public abstract get userType(): string;
442
- public generateAccessToken(payload?: Record<string, unknown>): Promise<AccessTokenOutput>;
443
- public generateRefreshToken(deviceInfo?: DeviceInfo): Promise<RefreshToken | undefined>;
444
- public createTokenPair(deviceInfo?: DeviceInfo): Promise<TokenPair>;
445
- public confirmPassword(password: string): Promise<boolean>;
446
- }
447
- ```
448
-
449
- `userType` is the only required override (an abstract getter — return the type slug). Override `generateAccessToken` if you need a non-default payload.
450
-
451
- `Auth` implements the `Authenticable` contract — that interface mirrors exactly these methods (`userType`, `generateAccessToken`, `generateRefreshToken`, `createTokenPair`, `confirmPassword`), so the class fails to compile if it drifts from the contract. Use `confirmPassword(plaintext)` to check a password against the stored hash (e.g. a "confirm current password" step).
452
-
453
- ## Things NOT to do
454
-
455
- - Don't use multi-user-type for what's really role-based access control. Use a `role` column on a single User model when the data shape is shared.
456
- - Don't forget the `public get userType(): string` override. It's an abstract getter on `Auth` — a subclass without it won't compile, and middleware lookups key off its return value.
457
- - Don't reuse the same `userType` slug across two models — the `config.auth.userType` map can only point one slug at one model.
458
- - Don't put admins and users in the same table differentiated by a flag. Separate tables means migrations don't coupling, queries don't accidentally cross, and audit logs are cleaner.
459
-
460
- ## See also
461
-
462
- - [`@warlock.js/auth/protect-routes/SKILL.md`](@warlock.js/auth/protect-routes/SKILL.md) — `authMiddleware` semantics
463
- - [`@warlock.js/auth/handle-login-and-logout/SKILL.md`](@warlock.js/auth/handle-login-and-logout/SKILL.md) — passing the right Model to `login`
464
- - [`@warlock.js/cascade/define-model/SKILL.md`](@warlock.js/cascade/define-model/SKILL.md) — `@RegisterModel`, models in general
465
-
466
-
467
- ## handle-login-and-logout `@warlock.js/auth/handle-login-and-logout/SKILL.md`
468
-
469
- ---
470
- name: handle-login-and-logout
471
- description: "Login + logout in @warlock.js/auth; use when you need to handle login and logout."
472
- ---
473
-
474
- # Login + logout
475
-
476
- `authService` exposes the full flow. Pass the model class so the service knows which user-type to look up.
477
-
478
- ```ts title="src/app/users/models/user/user.model.ts"
479
- import { Auth } from "@warlock.js/auth";
480
- import { RegisterModel } from "@warlock.js/cascade";
481
-
482
- @RegisterModel()
483
- export class User extends Auth {
484
- public static table = "users";
485
-
486
- public get userType(): string {
487
- return "user";
488
- }
489
- }
490
- ```
491
-
492
- ## Login — `authService.login(Model, credentials, deviceInfo?)`
493
-
494
- ```ts
495
- import { authService } from "@warlock.js/auth";
496
- import { type RequestHandler } from "@warlock.js/core";
497
- import { User } from "app/users/models/user/user.model";
498
-
499
- export const loginController: RequestHandler = async ({ request, response }) => {
500
- const result = await authService.login(User, {
501
- email: request.input("email"),
502
- password: request.input("password"),
503
- }, {
504
- userAgent: request.header("user-agent"),
505
- ip: request.ip,
506
- });
507
-
508
- if (!result) {
509
- return response.unauthorized({ error: "Invalid credentials" });
510
- }
511
-
512
- return response.success({
513
- user: result.user,
514
- tokens: result.tokens,
515
- });
516
- };
517
- ```
518
-
519
- The returned shape:
520
-
521
- ```ts
522
- {
523
- user: T, // your User subclass, hydrated
524
- tokens: {
525
- accessToken: { token: string, expiresAt: string },
526
- refreshToken?: { token: string, expiresAt: string }, // omitted if refresh tokens disabled
527
- },
528
- }
529
- ```
530
-
531
- Returns `null` on failure (wrong password, user not found). The service emits `login.attempt` → `login.success` or `login.failed` events as it goes — subscribe via the auth event bus if you need an audit trail.
532
-
533
- ## What `credentials` looks like
534
-
535
- The shape is **arbitrary** — every key except `password` is used as a `where(...)` filter against the model. The password is verified separately via bcrypt.
536
-
537
- ```ts
538
- // Email + password
539
- authService.login(User, { email: "ada@example.com", password: "..." });
540
-
541
- // Username + password
542
- authService.login(User, { username: "ada", password: "..." });
543
-
544
- // Phone-based OTP (where password is the OTP hash)
545
- authService.login(User, { phone: "+1...", password: hashedOTP });
546
- ```
547
-
548
- For lower-level credential verification (just check, don't issue tokens), use `authService.attemptLogin(Model, credentials)` — returns the user or null without creating tokens.
549
-
550
- ## Device info
551
-
552
- The optional `deviceInfo` carries metadata into the refresh token row:
553
-
554
- ```ts
555
- authService.login(User, credentials, {
556
- userAgent: request.header("user-agent"),
557
- ip: request.ip,
558
- deviceId: "...", // your client-side device fingerprint
559
- familyId: "...", // pre-existing family for token rotation, usually omitted
560
- });
561
- ```
562
-
563
- Useful for "show active sessions" UIs — see `authService.getActiveSessions(user)`.
564
-
565
- ## Logout — `authService.logout(user, accessToken?, refreshToken?)`
566
-
567
- ```ts
568
- import { type RequestHandler } from "@warlock.js/core";
569
-
570
- export const logoutController: RequestHandler = async ({ request, response }) => {
571
- await authService.logout(
572
- request.locals.user!,
573
- request.authorizationValue, // access token from the Authorization header
574
- request.input("refreshToken"), // refresh token from the request body
575
- );
576
-
577
- return response.success({ message: "Logged out" });
578
- };
579
- ```
580
-
581
- The contract:
582
- - **Pass the access token** → that specific access-token row is deleted.
583
- - **Pass the refresh token** → that specific refresh-token row is revoked.
584
- - **Omit refresh token** → behavior depends on `config.auth.refreshToken.logoutWithoutToken`:
585
- - `"revoke-all"` (default) — every refresh token for this user is revoked. Fail-safe.
586
- - `"error"` — throws. Force the client to send the refresh token.
587
-
588
- The `revoke-all` default is the right call for most apps. If a client loses track of the refresh token, logout still works and the user has to log in fresh on every device.
589
-
590
- ## Logout-everywhere
591
-
592
- ```ts
593
- await authService.revokeAllTokens(user);
594
- // Revokes every refresh token + deletes every access token for this user.
595
- ```
596
-
597
- Useful for "logout from all devices" buttons. Fires `token.revoked` per token + `logout.all` once.
598
-
599
- ## Refresh tokens — `authService.refreshTokens(refreshTokenString, deviceInfo?)`
600
-
601
- ```ts
602
- import { type RequestHandler } from "@warlock.js/core";
603
-
604
- export const refreshController: RequestHandler = async ({ request, response }) => {
605
- const tokens = await authService.refreshTokens(
606
- request.input("refreshToken"),
607
- { userAgent: request.header("user-agent"), ip: request.ip },
608
- );
609
-
610
- if (!tokens) {
611
- return response.unauthorized({ error: "Invalid refresh token" });
612
- }
613
-
614
- return response.success({ tokens });
615
- };
616
- ```
617
-
618
- Returns a new token pair or `null` (token expired, revoked, or replay-detected). With rotation enabled (default), the old refresh token is consumed; the new pair stays in the same "family." Replay → revoke the whole family. See [`@warlock.js/auth/manage-tokens/SKILL.md`](@warlock.js/auth/manage-tokens/SKILL.md).
619
-
620
- ## Auth events
621
-
622
- `authEvents` is a type-safe event bus (over `@mongez/events`) that fires on every meaningful auth moment. Subscribe with `on` / `subscribe`, unsubscribe with `off` / `unsubscribeAll`:
623
-
624
- ```ts
625
- import { authEvents } from "@warlock.js/auth";
626
-
627
- authEvents.on("login.success", (user, tokens, deviceInfo) => { /* audit */ });
628
- authEvents.on("login.failed", (credentials, reason) => { /* alert on brute force */ });
629
- authEvents.on("logout", (user) => { /* clear server-side session, if any */ });
630
- authEvents.on("token.refreshed", (user, newPair, oldToken) => { /* track rotation */ });
631
- authEvents.on("cleanup.completed", (count) => { /* metrics */ });
632
- ```
633
-
634
- Full event list: `login.attempt`, `login.success`, `login.failed`, `logout`, `logout.all`, `logout.failsafe`, `token.created`, `token.refreshed`, `token.revoked`, `token.expired`, `token.familyRevoked`, `session.created`, `session.destroyed`, `cleanup.completed`.
635
-
636
- ## Things NOT to do
637
-
638
- - Don't `authService.login(User, { password })` without other credentials — the password is the secret; the other fields are the lookup. A login with only a password is a logic bug.
639
- - Don't return the password hash in the response. `static toJsonColumns` on the User model should explicitly exclude it.
640
- - Don't store the refresh token in localStorage. Use an httpOnly secure cookie for refresh tokens; the access token can sit in memory.
641
- - Don't issue a new token pair without revoking the old one when rotation is enabled. `refreshTokens` does this for you — don't bypass it.
642
-
643
- ## See also
644
-
645
- - [`@warlock.js/auth/manage-tokens/SKILL.md`](@warlock.js/auth/manage-tokens/SKILL.md) — token lifecycle, rotation, family revocation
646
- - [`@warlock.js/auth/register-user/SKILL.md`](@warlock.js/auth/register-user/SKILL.md) — sign-up that issues tokens after creation
647
- - [`@warlock.js/auth/protect-routes/SKILL.md`](@warlock.js/auth/protect-routes/SKILL.md) — where the access token gets consumed
469
+ ---
470
+ name: handle-login-and-logout
471
+ description: 'Run the full login flow via authService.login(Model, credentials, deviceInfo?) — verify password, create access + refresh token pair, fire events. Logout via authService.logout(user, accessToken?, refreshToken?) revokes tokens. Triggers: `authService.login`, `authService.logout`, `authService.attemptLogin`, `authService.refreshTokens`, `authService.revokeAllTokens`, `authEvents`; "build a login endpoint", "POST /login controller", "logout from all devices", "verify credentials and issue tokens"; typical import `import { authService, authEvents } from "@warlock.js/auth"`. Skip: token internals — `@warlock.js/auth/manage-tokens/SKILL.md`; sign-up — `@warlock.js/auth/register-user/SKILL.md`; competing libs `passport-local`, `next-auth` credentials.'
472
+ ---
473
+
474
+ # Login + logout
475
+
476
+ `authService` exposes the full flow. Pass the model class so the service knows which user-type to look up.
477
+
478
+ ```ts title="src/app/users/models/user/user.model.ts"
479
+ import { Auth } from "@warlock.js/auth";
480
+ import { RegisterModel } from "@warlock.js/cascade";
481
+
482
+ @RegisterModel()
483
+ export class User extends Auth {
484
+ public static table = "users";
485
+
486
+ public get userType(): string {
487
+ return "user";
488
+ }
489
+ }
490
+ ```
491
+
492
+ ## Login — `authService.login(Model, credentials, deviceInfo?)`
493
+
494
+ ```ts
495
+ import { authService } from "@warlock.js/auth";
496
+ import { type RequestHandler } from "@warlock.js/core";
497
+ import { User } from "app/users/models/user/user.model";
498
+
499
+ export const loginController: RequestHandler = async ({ request, response }) => {
500
+ const result = await authService.login(User, {
501
+ email: request.input("email"),
502
+ password: request.input("password"),
503
+ }, {
504
+ userAgent: request.header("user-agent"),
505
+ ip: request.ip,
506
+ });
507
+
508
+ if (!result) {
509
+ return response.unauthorized({ error: "Invalid credentials" });
510
+ }
511
+
512
+ return response.success({
513
+ user: result.user,
514
+ tokens: result.tokens,
515
+ });
516
+ };
517
+ ```
518
+
519
+ The returned shape:
520
+
521
+ ```ts
522
+ {
523
+ user: T, // your User subclass, hydrated
524
+ tokens: {
525
+ accessToken: { token: string, expiresAt: string },
526
+ refreshToken?: { token: string, expiresAt: string }, // omitted if refresh tokens disabled
527
+ },
528
+ }
529
+ ```
530
+
531
+ Returns `null` on failure (wrong password, user not found). The service emits `login.attempt` → `login.success` or `login.failed` events as it goes — subscribe via the auth event bus if you need an audit trail.
532
+
533
+ ## What `credentials` looks like
534
+
535
+ The shape is **arbitrary** — every key except `password` is used as a `where(...)` filter against the model. The password is verified separately via bcrypt.
536
+
537
+ ```ts
538
+ // Email + password
539
+ authService.login(User, { email: "ada@example.com", password: "..." });
540
+
541
+ // Username + password
542
+ authService.login(User, { username: "ada", password: "..." });
543
+
544
+ // Phone-based OTP (where password is the OTP hash)
545
+ authService.login(User, { phone: "+1...", password: hashedOTP });
546
+ ```
547
+
548
+ For lower-level credential verification (just check, don't issue tokens), use `authService.attemptLogin(Model, credentials)` — returns the user or null without creating tokens.
549
+
550
+ ## Device info
551
+
552
+ The optional `deviceInfo` carries metadata into the refresh token row:
553
+
554
+ ```ts
555
+ authService.login(User, credentials, {
556
+ userAgent: request.header("user-agent"),
557
+ ip: request.ip,
558
+ deviceId: "...", // your client-side device fingerprint
559
+ familyId: "...", // pre-existing family for token rotation, usually omitted
560
+ });
561
+ ```
562
+
563
+ Useful for "show active sessions" UIs — see `authService.getActiveSessions(user)`.
564
+
565
+ ## Logout — `authService.logout(user, accessToken?, refreshToken?)`
566
+
567
+ ```ts
568
+ import { type RequestHandler } from "@warlock.js/core";
569
+
570
+ export const logoutController: RequestHandler = async ({ request, response }) => {
571
+ await authService.logout(
572
+ request.locals.user!,
573
+ request.authorizationValue, // access token from the Authorization header
574
+ request.input("refreshToken"), // refresh token from the request body
575
+ );
576
+
577
+ return response.success({ message: "Logged out" });
578
+ };
579
+ ```
580
+
581
+ The contract:
582
+ - **Pass the access token** → that specific access-token row is deleted.
583
+ - **Pass the refresh token** → that specific refresh-token row is revoked.
584
+ - **Omit refresh token** → behavior depends on `config.auth.refreshToken.logoutWithoutToken`:
585
+ - `"revoke-all"` (default) — every refresh token for this user is revoked. Fail-safe.
586
+ - `"error"` — throws. Force the client to send the refresh token.
587
+
588
+ The `revoke-all` default is the right call for most apps. If a client loses track of the refresh token, logout still works and the user has to log in fresh on every device.
589
+
590
+ ## Logout-everywhere
591
+
592
+ ```ts
593
+ await authService.revokeAllTokens(user);
594
+ // Revokes every refresh token + deletes every access token for this user.
595
+ ```
596
+
597
+ Useful for "logout from all devices" buttons. Fires `token.revoked` per token + `logout.all` once.
598
+
599
+ ## Refresh tokens — `authService.refreshTokens(refreshTokenString, deviceInfo?)`
600
+
601
+ ```ts
602
+ import { type RequestHandler } from "@warlock.js/core";
603
+
604
+ export const refreshController: RequestHandler = async ({ request, response }) => {
605
+ const tokens = await authService.refreshTokens(
606
+ request.input("refreshToken"),
607
+ { userAgent: request.header("user-agent"), ip: request.ip },
608
+ );
609
+
610
+ if (!tokens) {
611
+ return response.unauthorized({ error: "Invalid refresh token" });
612
+ }
613
+
614
+ return response.success({ tokens });
615
+ };
616
+ ```
617
+
618
+ Returns a new token pair or `null` (token expired, revoked, or replay-detected). With rotation enabled (default), the old refresh token is consumed; the new pair stays in the same "family." Replay → revoke the whole family. See [`@warlock.js/auth/manage-tokens/SKILL.md`](@warlock.js/auth/manage-tokens/SKILL.md).
619
+
620
+ ## Auth events
621
+
622
+ `authEvents` is a type-safe event bus (over `@mongez/events`) that fires on every meaningful auth moment. Subscribe with `on` / `subscribe`, unsubscribe with `off` / `unsubscribeAll`:
623
+
624
+ ```ts
625
+ import { authEvents } from "@warlock.js/auth";
626
+
627
+ authEvents.on("login.success", (user, tokens, deviceInfo) => { /* audit */ });
628
+ authEvents.on("login.failed", (credentials, reason) => { /* alert on brute force */ });
629
+ authEvents.on("logout", (user) => { /* clear server-side session, if any */ });
630
+ authEvents.on("token.refreshed", (user, newPair, oldToken) => { /* track rotation */ });
631
+ authEvents.on("cleanup.completed", (count) => { /* metrics */ });
632
+ ```
633
+
634
+ Full event list: `login.attempt`, `login.success`, `login.failed`, `logout`, `logout.all`, `logout.failsafe`, `token.created`, `token.refreshed`, `token.revoked`, `token.expired`, `token.familyRevoked`, `session.created`, `session.destroyed`, `cleanup.completed`.
635
+
636
+ ## Things NOT to do
637
+
638
+ - Don't `authService.login(User, { password })` without other credentials — the password is the secret; the other fields are the lookup. A login with only a password is a logic bug.
639
+ - Don't return the password hash in the response. `static toJsonColumns` on the User model should explicitly exclude it.
640
+ - Don't store the refresh token in localStorage. Use an httpOnly secure cookie for refresh tokens; the access token can sit in memory.
641
+ - Don't issue a new token pair without revoking the old one when rotation is enabled. `refreshTokens` does this for you — don't bypass it.
642
+
643
+ ## See also
644
+
645
+ - [`@warlock.js/auth/manage-tokens/SKILL.md`](@warlock.js/auth/manage-tokens/SKILL.md) — token lifecycle, rotation, family revocation
646
+ - [`@warlock.js/auth/register-user/SKILL.md`](@warlock.js/auth/register-user/SKILL.md) — sign-up that issues tokens after creation
647
+ - [`@warlock.js/auth/protect-routes/SKILL.md`](@warlock.js/auth/protect-routes/SKILL.md) — where the access token gets consumed
648
648
 
649
649
 
650
650
  ## login-with-providers `@warlock.js/auth/login-with-providers/SKILL.md`
651
651
 
652
- ---
653
- name: login-with-providers
654
- description: "Login with an OAuth provider, passkeys, or a phone code in @warlock.js/auth; use when you need to login with providers."
655
- ---
656
-
657
- # Login with an OAuth provider, passkeys, or a phone code
658
-
659
- Services only, as with password login: you write the routes. Each `verify`/`complete` call returns the same `LoginResult` as `authService.login`, so the next step is the same too:
660
-
661
- ```ts
662
- const { user, tokens } = await completeProviderLogin(User, "google", request, response);
663
- authService.setAuthCookie(response, tokens.accessToken); // cookie session, or return tokens as JSON
664
- ```
665
-
666
- `authService.completeLogin(user, deviceInfo?)` is that shared ending. It applies `auth.canAuthenticate` (403) and issues tokens. Call it only for a user you have already authenticated.
667
-
668
- ## Install
669
-
670
- | Method | Command | Installs |
671
- | --- | --- | --- |
672
- | Google | `warlock add auth-google` | `jose` |
673
- | Apple, LinkedIn | `warlock add auth-google` (or `npm install jose`) — there is no separate `auth-apple` / `auth-linkedin` feature yet | `jose` |
674
- | GitHub, Discord, Facebook, X | nothing — plain OAuth 2 over `fetch` | nothing |
675
- | Passkeys | `warlock add auth-passkeys` | `@simplewebauthn/server` (add `@simplewebauthn/browser` to your client bundle) |
676
- | Phone code | `warlock add notifications` + your own `sms`/`whatsapp` channel | nothing else. Auth ships no SMS/WhatsApp driver |
677
-
678
- 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.
679
-
680
- ## Google
681
-
682
- ```ts
683
- // src/config/auth.ts
684
- providers: {
685
- google: {
686
- clientId: env("GOOGLE_CLIENT_ID"),
687
- clientSecret: env("GOOGLE_CLIENT_SECRET"),
688
- redirectUri: `${env("APP_URL")}/auth/google/callback`, // exactly as registered at Google
689
- // scopes: ["openid", "email", "profile"],
690
- },
691
- // emailField: "email",
692
- // createUser: async (profile, Model) => Model.create({ ... }),
693
- },
694
- ```
695
-
696
- ```ts
697
- router.get("/auth/google", async ({ response }) =>
698
- response.redirect(await startProviderLogin(response, "google")),
699
- );
700
-
701
- router.get("/auth/google/callback", async ({ request, response }) => {
702
- const { tokens } = await completeProviderLogin(User, "google", request, response);
703
- authService.setAuthCookie(response, tokens.accessToken);
704
- return response.redirect("/");
705
- });
706
- ```
707
-
708
- Client side, the button is just a link. It must be a top-level navigation, not a `fetch`, so the state cookie goes out and comes back:
709
-
710
- ```tsx
711
- <a href="/auth/google">Continue with Google</a>
712
- ```
713
-
714
- - `state`, `nonce` and the PKCE verifier go into a signed, 10-minute, HttpOnly `auth_provider_state` cookie (SameSite=Lax, so it survives Google's redirect back). The callback clears the cookie, so a started login can complete only once.
715
- - The callback rejects, with `InvalidProviderCallbackError` (400, `EC009`): a missing, forged or expired cookie, a `state` mismatch, a `?error=` from Google, a failed code exchange, and an id_token with a bad signature, `iss`, `aud`, `exp` or `nonce`. `error.reason` says which. The response message is generic.
716
- - **Linking.** An existing `provider_accounts` row (provider plus Google `sub`) always decides the user. Without one, the Google email must be verified (`email_verified: true`), or the call throws `ProviderEmailNotVerifiedError` (403, `EC010`) and nothing is linked or created. A verified email links the matching user, or creates one with `{ email, name, emailVerifiedAt: now }`.
717
- - Other OIDC providers: implement `AuthProvider` (`authorizationUrl(state)`, `handleCallback({ query, expected })`) and register it under `auth.providers.custom.<name>`.
718
-
719
- ## GitHub, Discord, LinkedIn
720
-
721
- Same shape as Google: a config block under `auth.providers.<name>`, a GET
722
- route that redirects to `startProviderLogin(response, "<name>")`, and a GET
723
- callback that calls `completeProviderLogin(User, "<name>", request, response)`.
724
-
725
- ```ts
726
- // src/config/auth.ts
727
- providers: {
728
- github: {
729
- clientId: env("GITHUB_CLIENT_ID"),
730
- clientSecret: env("GITHUB_CLIENT_SECRET"),
731
- redirectUri: `${env("APP_URL")}/auth/github/callback`,
732
- // scopes: ["read:user", "user:email"],
733
- },
734
- discord: {
735
- clientId: env("DISCORD_CLIENT_ID"),
736
- clientSecret: env("DISCORD_CLIENT_SECRET"),
737
- redirectUri: `${env("APP_URL")}/auth/discord/callback`,
738
- // scopes: ["identify", "email"],
739
- },
740
- linkedin: {
741
- clientId: env("LINKEDIN_CLIENT_ID"),
742
- clientSecret: env("LINKEDIN_CLIENT_SECRET"),
743
- redirectUri: `${env("APP_URL")}/auth/linkedin/callback`,
744
- // scopes: ["openid", "profile", "email"],
745
- },
746
- },
747
- ```
748
-
749
- - **GitHub** — OAuth 2 + PKCE. `/user.email` is `null` unless public, so the email is read from `/user/emails`: only the address that is both **primary and verified** is used; otherwise the profile has no email and the unverified-email rule applies.
750
- - **Discord** — OAuth 2 + PKCE; `email` comes from `/users/@me` and counts as verified only when Discord's `verified` flag is `true`.
751
- - **LinkedIn** — OpenID Connect; the id_token is verified with `jose` against LinkedIn's JWKS (signature, issuer, audience, expiry, nonce). `email_verified` must be the boolean `true`.
752
- - Provider names resolve by own key only — `auth.providers` inherited keys such as `toString` never resolve as a provider.
753
-
754
- ## Apple
755
-
756
- ```ts
757
- // src/config/auth.ts
758
- providers: {
759
- apple: {
760
- clientId: env("APPLE_CLIENT_ID"), // the Services ID
761
- teamId: env("APPLE_TEAM_ID"),
762
- keyId: env("APPLE_KEY_ID"),
763
- privateKey: env("APPLE_PRIVATE_KEY"), // the .p8 file's PKCS8 PEM contents
764
- redirectUri: `${env("APP_URL")}/auth/apple/callback`,
765
- // scopes: ["name", "email"],
766
- },
767
- },
768
- ```
769
-
770
- ```ts
771
- router.get("/auth/apple", async ({ response }) =>
772
- response.redirect(await startProviderLogin(response, "apple")),
773
- );
774
-
775
- // Apple POSTs the callback (form_post) when name/email scopes are requested —
776
- // this route must accept a POST body, not just a query string.
777
- router.post("/auth/apple/callback", async ({ request, response }) => {
778
- const { tokens } = await completeProviderLogin(User, "apple", request, response);
779
- authService.setAuthCookie(response, tokens.accessToken);
780
- return response.redirect("/");
781
- });
782
- ```
783
-
784
- - Apple has no static client secret: a fresh ES256 JWT (`iss` = team id, `sub` = client id, `aud` = Apple, `kid` = key id) is signed with your `.p8` private key on every callback.
785
- - The account name arrives only on the **first** authorization, as a `user` form field (`{"name":{"firstName","lastName"}}`) — save it then, since later logins never send it again.
786
- - The email may be a private-relay address (`@privaterelay.appleid.com`); it is still a real, working address and is accepted like any other.
787
- - Same rejections and linking rules as Google, through `InvalidProviderCallbackError` / `ProviderEmailNotVerifiedError`.
788
- - Apple's `form_post` callback is a cross-site POST, so its state cookie is written `SameSite=None; Secure` instead of the usual `Lax` — a browser drops a `Lax` cookie there. `Secure` needs HTTPS: Apple requires it in production anyway, and for local dev you need `https://` or `http://localhost` (browsers treat `localhost` as a secure context even over plain http).
789
-
790
- ## Facebook
791
-
792
- ```ts
793
- providers: {
794
- facebook: {
795
- clientId: env("FACEBOOK_CLIENT_ID"),
796
- clientSecret: env("FACEBOOK_CLIENT_SECRET"),
797
- redirectUri: `${env("APP_URL")}/auth/facebook/callback`,
798
- // scopes: ["email", "public_profile"],
799
- },
800
- },
801
- ```
802
-
803
- ```ts
804
- router.get("/auth/facebook", async ({ response }) =>
805
- response.redirect(await startProviderLogin(response, "facebook")),
806
- );
807
-
808
- router.get("/auth/facebook/callback", async ({ request, response }) => {
809
- const { tokens } = await completeProviderLogin(User, "facebook", request, response);
810
- authService.setAuthCookie(response, tokens.accessToken);
811
- return response.redirect("/");
812
- });
813
- ```
814
-
815
- - Plain OAuth 2 (no `id_token`); the profile comes from Graph `/me?fields=id,name,email,picture`. Facebook only ever returns a confirmed address, so a present `email` counts as verified.
816
- - An app without the `email` permission granted (or a user with none on file) gets no email — the same missing/unverified path as every other provider, never an invented address.
817
-
818
- ## X (Twitter)
819
-
820
- ```ts
821
- providers: {
822
- x: {
823
- clientId: env("X_CLIENT_ID"),
824
- clientSecret: env("X_CLIENT_SECRET"),
825
- redirectUri: `${env("APP_URL")}/auth/x/callback`,
826
- // scopes: ["tweet.read", "users.read"],
827
- },
828
- },
829
- ```
830
-
831
- ```ts
832
- router.get("/auth/x", async ({ response }) =>
833
- response.redirect(await startProviderLogin(response, "x")),
834
- );
835
-
836
- router.get("/auth/x/callback", async ({ request, response }) => {
837
- const { tokens } = await completeProviderLogin(User, "x", request, response);
838
- authService.setAuthCookie(response, tokens.accessToken);
839
- return response.redirect("/");
840
- });
841
- ```
842
-
843
- - OAuth 2 with PKCE, plus HTTP Basic auth (`client_id:client_secret`) at the token endpoint for the confidential client — X requires both together.
844
- - X's `/2/users/me` never returns an email address at all. `email` is always `undefined`; a first login therefore always needs an existing `provider_accounts` link (create one another way first) or it is rejected the same as any missing email — X gives auth no way to invent or infer one.
845
-
846
- ## Passkeys
847
-
848
- ```ts
849
- passkeys: { rpID: "example.com", rpName: "Example", origin: "https://example.com" },
850
- ```
851
-
852
- ```ts
853
- // Register (logged in)
854
- router.post("/auth/passkeys/register/options", async ({ request, response }) =>
855
- response.success(await generatePasskeyRegistrationOptions(request.locals.user)),
856
- { middleware: [authMiddleware("user")] });
857
- router.post("/auth/passkeys/register", async ({ request, response }) => {
858
- await verifyPasskeyRegistration(request, request.locals.user, request.input("credential"));
859
- return response.success({ registered: true });
860
- }, { middleware: [authMiddleware("user")] });
861
-
862
- // Log in
863
- router.post("/auth/passkeys/login/options", async ({ response }) =>
864
- response.success(await generatePasskeyAuthenticationOptions()));
865
- router.post("/auth/passkeys/login", async ({ request, response }) => {
866
- const { tokens } = await verifyPasskeyAuthentication(request, request.input("credential"));
867
- authService.setAuthCookie(response, tokens.accessToken);
868
- return response.success({ ok: true });
869
- });
870
- ```
871
-
872
- Browser:
873
-
874
- ```ts
875
- import { startAuthentication, startRegistration } from "@simplewebauthn/browser";
876
-
877
- const post = (url: string, body?: unknown) =>
878
- fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) })
879
- .then((r) => r.json());
880
-
881
- // register
882
- const regOptions = await post("/auth/passkeys/register/options");
883
- await post("/auth/passkeys/register", { credential: await startRegistration({ optionsJSON: regOptions }) });
884
-
885
- // log in
886
- const authOptions = await post("/auth/passkeys/login/options");
887
- await post("/auth/passkeys/login", { credential: await startAuthentication({ optionsJSON: authOptions }) });
888
- ```
889
-
890
- (Unwrap `response.success`'s envelope if your app wraps payloads.)
891
-
892
- - Challenges live in `one_time_tokens` as SHA-256 hashes, expire after 5m (`auth.passkeys.challengeExpiresIn`), and are **consumed before verification**. A replayed challenge fails, and so does one whose first verification failed.
893
- - The request `Origin` must be one of `auth.passkeys.origin`. The same list is checked against the signed `clientDataJSON`.
894
- - The signature counter must advance. A counter that did not move past the stored value (when either is non-zero) is rejected as a cloned authenticator. The new counter is saved with a compare-and-set.
895
- - Every rejection throws `InvalidPasskeyError` (400, `EC011`) with a `reason`.
896
-
897
- ## Phone code (OTP)
898
-
899
- ```ts
900
- otp: { channel: "sms" /* or "whatsapp" */, phoneField: "phone", expiresIn: "5m", maxAttempts: 5 },
901
- ```
902
-
903
- ```ts
904
- router.post("/auth/otp/request", async ({ request, response }) => {
905
- await requestOtp(User, request.input("phone"), { channel: request.input("via") === "whatsapp" ? "whatsapp" : "sms" });
906
- return response.success({ message: "If that number is registered, a code is on its way." });
907
- }, { middleware: [otpRequestThrottleMiddleware()] }); // 3 / 1h per phone + IP
908
-
909
- router.post("/auth/otp/verify", async ({ request, response }) => {
910
- const { tokens } = await verifyOtp(User, request.input("phone"), request.input("code"));
911
- authService.setAuthCookie(response, tokens.accessToken);
912
- return response.success({ ok: true });
913
- }, { middleware: [otpVerifyThrottleMiddleware()] }); // 5 failures / 15m per phone + IP
914
- ```
915
-
916
- - 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.
917
- - Every verify counts an attempt atomically first. After `maxAttempts` the code is invalidated, even if the right code comes next, and even under concurrent guesses.
918
- - Delivery: `notify.channel(channel).send(phone, { body, code, expiresAt })`. Register that channel in `config/notifications.ts`, or set `auth.otp.send(phone, message, channel)`. `auth.otp.message(code)` changes the text.
919
- - **Anti-enumeration:** an unknown phone gets the same `requestOtp` result (nothing sent) and the same `InvalidOneTimeTokenError` (400, `EC008`) from `verifyOtp`.
920
- - CSRF: `verifyOtp` is a POST. If the route uses cookie auth, the 5.12 Origin check applies. Otherwise there is no ambient credential to ride.
921
-
922
- ## Gotchas
923
-
924
- - `completeProviderLogin` needs the **same** response object to clear the state cookie. Do not pass a different one.
925
- - One provider identity links to one account, of one user type.
926
- - `auth.cleanup` hard-deletes expired and consumed `one_time_tokens` rows (challenges and codes included).
927
- - The passkey specs run the real `@simplewebauthn/server` against a software ES256 authenticator, and the consume-once, OTP attempt cap, counter compare-and-set and provider-link uniqueness are proven on real MongoDB and Postgres. Google's token endpoint is still mocked, so test a real Google client before you ship.
928
- - A counter the SDK rejects is reported with `reason: "counter-regression"`, the same as auth's own check.
929
-
930
- ## See also
931
-
932
- - [`@warlock.js/auth/handle-login-and-logout/SKILL.md`](@warlock.js/auth/handle-login-and-logout/SKILL.md): password login and `setAuthCookie`
933
- - [`@warlock.js/auth/throttle-login-attempts/SKILL.md`](@warlock.js/auth/throttle-login-attempts/SKILL.md): the throttle the OTP presets wrap
934
- - [`@warlock.js/notifications/define-channel/SKILL.md`](@warlock.js/notifications/define-channel/SKILL.md): an SMS/WhatsApp channel
935
-
936
-
937
- ## manage-tokens `@warlock.js/auth/manage-tokens/SKILL.md`
938
-
939
652
  ---
940
- name: manage-tokens
941
- description: "Manage tokens in @warlock.js/auth; use when you need to manage tokens."
653
+ name: login-with-providers
654
+ description: 'Log in without a password — Google (OIDC code + PKCE + state + nonce, id_token verified with `jose`), GitHub, Discord, LinkedIn, Apple (`form_post` callback), Facebook and X, passkeys (WebAuthn via `@simplewebauthn/server`) and phone one-time codes (delivered through a `@warlock.js/notifications` channel). Every method ends in `authService.completeLogin(user)`, the same tokens, rows and events as password login. `startProviderLogin`, `completeProviderLogin`, `generatePasskeyRegistrationOptions`, `verifyPasskeyRegistration`, `generatePasskeyAuthenticationOptions`, `verifyPasskeyAuthentication`, `requestOtp`, `verifyOtp`, `otpRequestThrottleMiddleware`, `otpVerifyThrottleMiddleware`, `AuthProvider`. Triggers: "login with Google", "sign in with Google", "login with GitHub", "Sign in with Apple", "login with Discord", "login with LinkedIn", "login with Facebook", "login with X", `auth.providers.github`, "OAuth", "OIDC", "passkeys", "WebAuthn", "passwordless", "SMS code login", "WhatsApp OTP", "phone login", `warlock add auth-google`, `warlock add auth-passkeys`, `AuthProviderSdkMissingError`, `InvalidProviderCallbackError`, `ProviderEmailNotVerifiedError`, `InvalidPasskeyError`, `EC009`, `EC010`, `EC011`, `auth.providers`, `auth.passkeys`, `auth.otp`; typical import `import { completeProviderLogin, verifyOtp } from "@warlock.js/auth"`. Skip: password login — `@warlock.js/auth/handle-login-and-logout/SKILL.md`; email tokens — `@warlock.js/auth/verify-email-and-reset-password/SKILL.md`; defining an SMS channel — `@warlock.js/notifications/define-channel/SKILL.md`.'
942
655
  ---
943
656
 
944
- # Manage tokens
945
-
946
- Tokens are persisted Cascade models. Issuing a token writes a row. Verification checks the row exists. Revocation deletes / marks-revoked. This gives you JWT's stateless verification + statelful revocation.
657
+ # Login with an OAuth provider, passkeys, or a phone code
947
658
 
948
- ## Token shapes
659
+ Services only, as with password login: you write the routes. Each `verify`/`complete` call returns the same `LoginResult` as `authService.login`, so the next step is the same too:
949
660
 
950
661
  ```ts
951
- type AccessTokenOutput = { token: string; expiresAt: string };
952
- type RefreshTokenOutput = { token: string; expiresAt: string };
953
-
954
- type TokenPair = {
955
- accessToken: AccessTokenOutput;
956
- refreshToken?: RefreshTokenOutput; // omitted if config.auth.refreshToken.enabled = false
957
- };
662
+ const { user, tokens } = await completeProviderLogin(User, "google", request, response);
663
+ authService.setAuthCookie(response, tokens.accessToken); // cookie session, or return tokens as JSON
958
664
  ```
959
665
 
960
- ## Issuing tokens
666
+ `authService.completeLogin(user, deviceInfo?)` is that shared ending. It applies `auth.canAuthenticate` (403) and issues tokens. Call it only for a user you have already authenticated.
961
667
 
962
- ```ts
963
- import { authService } from "@warlock.js/auth";
668
+ ## Install
964
669
 
965
- // Just an access token (rare — usually use createTokenPair)
966
- const access = await authService.generateAccessToken(user);
670
+ | Method | Command | Installs |
671
+ | --- | --- | --- |
672
+ | Google | `warlock add auth-google` | `jose` |
673
+ | Apple, LinkedIn | `warlock add auth-google` (or `npm install jose`) — there is no separate `auth-apple` / `auth-linkedin` feature yet | `jose` |
674
+ | GitHub, Discord, Facebook, X | nothing — plain OAuth 2 over `fetch` | nothing |
675
+ | Passkeys | `warlock add auth-passkeys` | `@simplewebauthn/server` (add `@simplewebauthn/browser` to your client bundle) |
676
+ | Phone code | `warlock add notifications` + your own `sms`/`whatsapp` channel | nothing else. Auth ships no SMS/WhatsApp driver |
967
677
 
968
- // Just a refresh token
969
- const refresh = await authService.createRefreshToken(user, deviceInfo);
678
+ 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.
970
679
 
971
- // Both — the everyday case
972
- const pair = await authService.createTokenPair(user, deviceInfo);
680
+ ## Google
681
+
682
+ ```ts
683
+ // src/config/auth.ts
684
+ providers: {
685
+ google: {
686
+ clientId: env("GOOGLE_CLIENT_ID"),
687
+ clientSecret: env("GOOGLE_CLIENT_SECRET"),
688
+ redirectUri: `${env("APP_URL")}/auth/google/callback`, // exactly as registered at Google
689
+ // scopes: ["openid", "email", "profile"],
690
+ },
691
+ // emailField: "email",
692
+ // createUser: async (profile, Model) => Model.create({ ... }),
693
+ },
973
694
  ```
974
695
 
975
- `createTokenPair` is the typical issuance path. It respects `config.auth.refreshToken.enabled` — if disabled, returns only `accessToken`.
696
+ ```ts
697
+ router.get("/auth/google", async ({ response }) =>
698
+ response.redirect(await startProviderLogin(response, "google")),
699
+ );
700
+
701
+ router.get("/auth/google/callback", async ({ request, response }) => {
702
+ const { tokens } = await completeProviderLogin(User, "google", request, response);
703
+ authService.setAuthCookie(response, tokens.accessToken);
704
+ return response.redirect("/");
705
+ });
706
+ ```
707
+
708
+ Client side, the button is just a link. It must be a top-level navigation, not a `fetch`, so the state cookie goes out and comes back:
709
+
710
+ ```tsx
711
+ <a href="/auth/google">Continue with Google</a>
712
+ ```
713
+
714
+ - `state`, `nonce` and the PKCE verifier go into a signed, 10-minute, HttpOnly `auth_provider_state` cookie (SameSite=Lax, so it survives Google's redirect back). The callback clears the cookie, so a started login can complete only once.
715
+ - The callback rejects, with `InvalidProviderCallbackError` (400, `EC009`): a missing, forged or expired cookie, a `state` mismatch, a `?error=` from Google, a failed code exchange, and an id_token with a bad signature, `iss`, `aud`, `exp` or `nonce`. `error.reason` says which. The response message is generic.
716
+ - **Linking.** An existing `provider_accounts` row (provider plus Google `sub`) always decides the user. Without one, the Google email must be verified (`email_verified: true`), or the call throws `ProviderEmailNotVerifiedError` (403, `EC010`) and nothing is linked or created. A verified email links the matching user, or creates one with `{ email, name, emailVerifiedAt: now }`.
717
+ - Other OIDC providers: implement `AuthProvider` (`authorizationUrl(state)`, `handleCallback({ query, expected })`) and register it under `auth.providers.custom.<name>`.
718
+
719
+ ## GitHub, Discord, LinkedIn
720
+
721
+ Same shape as Google: a config block under `auth.providers.<name>`, a GET
722
+ route that redirects to `startProviderLogin(response, "<name>")`, and a GET
723
+ callback that calls `completeProviderLogin(User, "<name>", request, response)`.
724
+
725
+ ```ts
726
+ // src/config/auth.ts
727
+ providers: {
728
+ github: {
729
+ clientId: env("GITHUB_CLIENT_ID"),
730
+ clientSecret: env("GITHUB_CLIENT_SECRET"),
731
+ redirectUri: `${env("APP_URL")}/auth/github/callback`,
732
+ // scopes: ["read:user", "user:email"],
733
+ },
734
+ discord: {
735
+ clientId: env("DISCORD_CLIENT_ID"),
736
+ clientSecret: env("DISCORD_CLIENT_SECRET"),
737
+ redirectUri: `${env("APP_URL")}/auth/discord/callback`,
738
+ // scopes: ["identify", "email"],
739
+ },
740
+ linkedin: {
741
+ clientId: env("LINKEDIN_CLIENT_ID"),
742
+ clientSecret: env("LINKEDIN_CLIENT_SECRET"),
743
+ redirectUri: `${env("APP_URL")}/auth/linkedin/callback`,
744
+ // scopes: ["openid", "profile", "email"],
745
+ },
746
+ },
747
+ ```
748
+
749
+ - **GitHub** — OAuth 2 + PKCE. `/user.email` is `null` unless public, so the email is read from `/user/emails`: only the address that is both **primary and verified** is used; otherwise the profile has no email and the unverified-email rule applies.
750
+ - **Discord** — OAuth 2 + PKCE; `email` comes from `/users/@me` and counts as verified only when Discord's `verified` flag is `true`.
751
+ - **LinkedIn** — OpenID Connect; the id_token is verified with `jose` against LinkedIn's JWKS (signature, issuer, audience, expiry, nonce). `email_verified` must be the boolean `true`.
752
+ - Provider names resolve by own key only — `auth.providers` inherited keys such as `toString` never resolve as a provider.
753
+
754
+ ## Apple
755
+
756
+ ```ts
757
+ // src/config/auth.ts
758
+ providers: {
759
+ apple: {
760
+ clientId: env("APPLE_CLIENT_ID"), // the Services ID
761
+ teamId: env("APPLE_TEAM_ID"),
762
+ keyId: env("APPLE_KEY_ID"),
763
+ privateKey: env("APPLE_PRIVATE_KEY"), // the .p8 file's PKCS8 PEM contents
764
+ redirectUri: `${env("APP_URL")}/auth/apple/callback`,
765
+ // scopes: ["name", "email"],
766
+ },
767
+ },
768
+ ```
769
+
770
+ ```ts
771
+ router.get("/auth/apple", async ({ response }) =>
772
+ response.redirect(await startProviderLogin(response, "apple")),
773
+ );
774
+
775
+ // Apple POSTs the callback (form_post) when name/email scopes are requested —
776
+ // this route must accept a POST body, not just a query string.
777
+ router.post("/auth/apple/callback", async ({ request, response }) => {
778
+ const { tokens } = await completeProviderLogin(User, "apple", request, response);
779
+ authService.setAuthCookie(response, tokens.accessToken);
780
+ return response.redirect("/");
781
+ });
782
+ ```
783
+
784
+ - Apple has no static client secret: a fresh ES256 JWT (`iss` = team id, `sub` = client id, `aud` = Apple, `kid` = key id) is signed with your `.p8` private key on every callback.
785
+ - The account name arrives only on the **first** authorization, as a `user` form field (`{"name":{"firstName","lastName"}}`) — save it then, since later logins never send it again.
786
+ - The email may be a private-relay address (`@privaterelay.appleid.com`); it is still a real, working address and is accepted like any other.
787
+ - Same rejections and linking rules as Google, through `InvalidProviderCallbackError` / `ProviderEmailNotVerifiedError`.
788
+ - Apple's `form_post` callback is a cross-site POST, so its state cookie is written `SameSite=None; Secure` instead of the usual `Lax` — a browser drops a `Lax` cookie there. `Secure` needs HTTPS: Apple requires it in production anyway, and for local dev you need `https://` or `http://localhost` (browsers treat `localhost` as a secure context even over plain http).
789
+
790
+ ## Facebook
791
+
792
+ ```ts
793
+ providers: {
794
+ facebook: {
795
+ clientId: env("FACEBOOK_CLIENT_ID"),
796
+ clientSecret: env("FACEBOOK_CLIENT_SECRET"),
797
+ redirectUri: `${env("APP_URL")}/auth/facebook/callback`,
798
+ // scopes: ["email", "public_profile"],
799
+ },
800
+ },
801
+ ```
802
+
803
+ ```ts
804
+ router.get("/auth/facebook", async ({ response }) =>
805
+ response.redirect(await startProviderLogin(response, "facebook")),
806
+ );
807
+
808
+ router.get("/auth/facebook/callback", async ({ request, response }) => {
809
+ const { tokens } = await completeProviderLogin(User, "facebook", request, response);
810
+ authService.setAuthCookie(response, tokens.accessToken);
811
+ return response.redirect("/");
812
+ });
813
+ ```
814
+
815
+ - Plain OAuth 2 (no `id_token`); the profile comes from Graph `/me?fields=id,name,email,picture`. Facebook only ever returns a confirmed address, so a present `email` counts as verified.
816
+ - An app without the `email` permission granted (or a user with none on file) gets no email — the same missing/unverified path as every other provider, never an invented address.
817
+
818
+ ## X (Twitter)
819
+
820
+ ```ts
821
+ providers: {
822
+ x: {
823
+ clientId: env("X_CLIENT_ID"),
824
+ clientSecret: env("X_CLIENT_SECRET"),
825
+ redirectUri: `${env("APP_URL")}/auth/x/callback`,
826
+ // scopes: ["tweet.read", "users.read"],
827
+ },
828
+ },
829
+ ```
830
+
831
+ ```ts
832
+ router.get("/auth/x", async ({ response }) =>
833
+ response.redirect(await startProviderLogin(response, "x")),
834
+ );
835
+
836
+ router.get("/auth/x/callback", async ({ request, response }) => {
837
+ const { tokens } = await completeProviderLogin(User, "x", request, response);
838
+ authService.setAuthCookie(response, tokens.accessToken);
839
+ return response.redirect("/");
840
+ });
841
+ ```
842
+
843
+ - OAuth 2 with PKCE, plus HTTP Basic auth (`client_id:client_secret`) at the token endpoint for the confidential client — X requires both together.
844
+ - X's `/2/users/me` never returns an email address at all. `email` is always `undefined`; a first login therefore always needs an existing `provider_accounts` link (create one another way first) or it is rejected the same as any missing email — X gives auth no way to invent or infer one.
845
+
846
+ ## Passkeys
847
+
848
+ ```ts
849
+ passkeys: { rpID: "example.com", rpName: "Example", origin: "https://example.com" },
850
+ ```
851
+
852
+ ```ts
853
+ // Register (logged in)
854
+ router.post("/auth/passkeys/register/options", async ({ request, response }) =>
855
+ response.success(await generatePasskeyRegistrationOptions(request.locals.user)),
856
+ { middleware: [authMiddleware("user")] });
857
+ router.post("/auth/passkeys/register", async ({ request, response }) => {
858
+ await verifyPasskeyRegistration(request, request.locals.user, request.input("credential"));
859
+ return response.success({ registered: true });
860
+ }, { middleware: [authMiddleware("user")] });
861
+
862
+ // Log in
863
+ router.post("/auth/passkeys/login/options", async ({ response }) =>
864
+ response.success(await generatePasskeyAuthenticationOptions()));
865
+ router.post("/auth/passkeys/login", async ({ request, response }) => {
866
+ const { tokens } = await verifyPasskeyAuthentication(request, request.input("credential"));
867
+ authService.setAuthCookie(response, tokens.accessToken);
868
+ return response.success({ ok: true });
869
+ });
870
+ ```
871
+
872
+ Browser:
873
+
874
+ ```ts
875
+ import { startAuthentication, startRegistration } from "@simplewebauthn/browser";
876
+
877
+ const post = (url: string, body?: unknown) =>
878
+ fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) })
879
+ .then((r) => r.json());
976
880
 
977
- ## Refresh with rotation — `refreshTokens`
881
+ // register
882
+ const regOptions = await post("/auth/passkeys/register/options");
883
+ await post("/auth/passkeys/register", { credential: await startRegistration({ optionsJSON: regOptions }) });
884
+
885
+ // log in
886
+ const authOptions = await post("/auth/passkeys/login/options");
887
+ await post("/auth/passkeys/login", { credential: await startAuthentication({ optionsJSON: authOptions }) });
888
+ ```
889
+
890
+ (Unwrap `response.success`'s envelope if your app wraps payloads.)
891
+
892
+ - Challenges live in `one_time_tokens` as SHA-256 hashes, expire after 5m (`auth.passkeys.challengeExpiresIn`), and are **consumed before verification**. A replayed challenge fails, and so does one whose first verification failed.
893
+ - The request `Origin` must be one of `auth.passkeys.origin`. The same list is checked against the signed `clientDataJSON`.
894
+ - The signature counter must advance. A counter that did not move past the stored value (when either is non-zero) is rejected as a cloned authenticator. The new counter is saved with a compare-and-set.
895
+ - Every rejection throws `InvalidPasskeyError` (400, `EC011`) with a `reason`.
896
+
897
+ ## Phone code (OTP)
898
+
899
+ ```ts
900
+ otp: { channel: "sms" /* or "whatsapp" */, phoneField: "phone", expiresIn: "5m", maxAttempts: 5 },
901
+ ```
902
+
903
+ ```ts
904
+ router.post("/auth/otp/request", async ({ request, response }) => {
905
+ await requestOtp(User, request.input("phone"), { channel: request.input("via") === "whatsapp" ? "whatsapp" : "sms" });
906
+ return response.success({ message: "If that number is registered, a code is on its way." });
907
+ }, { middleware: [otpRequestThrottleMiddleware()] }); // 3 / 1h per phone + IP
908
+
909
+ router.post("/auth/otp/verify", async ({ request, response }) => {
910
+ const { tokens } = await verifyOtp(User, request.input("phone"), request.input("code"));
911
+ authService.setAuthCookie(response, tokens.accessToken);
912
+ return response.success({ ok: true });
913
+ }, { middleware: [otpVerifyThrottleMiddleware()] }); // 5 failures / 15m per phone + IP
914
+ ```
915
+
916
+ - 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.
917
+ - Every verify counts an attempt atomically first. After `maxAttempts` the code is invalidated, even if the right code comes next, and even under concurrent guesses.
918
+ - Delivery: `notify.channel(channel).send(phone, { body, code, expiresAt })`. Register that channel in `config/notifications.ts`, or set `auth.otp.send(phone, message, channel)`. `auth.otp.message(code)` changes the text.
919
+ - **Anti-enumeration:** an unknown phone gets the same `requestOtp` result (nothing sent) and the same `InvalidOneTimeTokenError` (400, `EC008`) from `verifyOtp`.
920
+ - CSRF: `verifyOtp` is a POST. If the route uses cookie auth, the 5.12 Origin check applies. Otherwise there is no ambient credential to ride.
921
+
922
+ ## Gotchas
923
+
924
+ - `completeProviderLogin` needs the **same** response object to clear the state cookie. Do not pass a different one.
925
+ - One provider identity links to one account, of one user type.
926
+ - `auth.cleanup` hard-deletes expired and consumed `one_time_tokens` rows (challenges and codes included).
927
+ - The passkey specs run the real `@simplewebauthn/server` against a software ES256 authenticator, and the consume-once, OTP attempt cap, counter compare-and-set and provider-link uniqueness are proven on real MongoDB and Postgres. Google's token endpoint is still mocked, so test a real Google client before you ship.
928
+ - A counter the SDK rejects is reported with `reason: "counter-regression"`, the same as auth's own check.
929
+
930
+ ## See also
931
+
932
+ - [`@warlock.js/auth/handle-login-and-logout/SKILL.md`](@warlock.js/auth/handle-login-and-logout/SKILL.md): password login and `setAuthCookie`
933
+ - [`@warlock.js/auth/throttle-login-attempts/SKILL.md`](@warlock.js/auth/throttle-login-attempts/SKILL.md): the throttle the OTP presets wrap
934
+ - [`@warlock.js/notifications/define-channel/SKILL.md`](@warlock.js/notifications/define-channel/SKILL.md): an SMS/WhatsApp channel
978
935
 
979
- Refresh and family revocation use durable family revision checks inside a
980
- serializable Cascade transaction. Run the additive `authMigrations` before
981
- using the upgraded APIs. Transaction support is required: the service fails
982
- rather than falling back to uncoordinated writes. MongoDB deployments therefore
983
- need a transaction-capable topology.
984
936
 
985
- An operation that owns its transaction retries serialization conflicts at most
986
- three times. Inside an application-owned transaction it joins once and leaves
987
- retry/rollback to the caller. Auth notifications from owned attempts are emitted
988
- only after successful commit; notifications inside an application-owned outer
989
- transaction retain their existing timing before that outer commit. Keep external
990
- side effects outside such a transaction.
937
+ ## manage-tokens `@warlock.js/auth/manage-tokens/SKILL.md`
938
+
939
+ ---
940
+ name: manage-tokens
941
+ description: 'Token lifecycle — generateAccessToken, createRefreshToken, createTokenPair, refreshTokens (with rotation + replay detection), revokeAllTokens, revokeTokenFamily, cleanupExpiredTokens, getActiveSessions. Triggers: `createTokenPair`, `refreshTokens`, `revokeTokenFamily`, `cleanupExpiredTokens`, `getActiveSessions`, `jwt.generate`, `jwt.verify`, `AccessToken`, `RefreshToken`; "rotate refresh tokens", "detect token replay", "logout from all devices", "list active sessions", "clean up expired tokens"; typical import `import { authService, jwt } from "@warlock.js/auth"`. Skip: login flow — `@warlock.js/auth/handle-login-and-logout/SKILL.md`; CLI cleanup — `@warlock.js/auth/run-auth-commands/SKILL.md`; competing libs `jsonwebtoken`, `jose`, `fast-jwt`.'
942
+ ---
943
+
944
+ # Manage tokens
945
+
946
+ Tokens are persisted Cascade models. Issuing a token writes a row. Verification checks the row exists. Revocation deletes / marks-revoked. This gives you JWT's stateless verification + statelful revocation.
947
+
948
+ ## Token shapes
949
+
950
+ ```ts
951
+ type AccessTokenOutput = { token: string; expiresAt: string };
952
+ type RefreshTokenOutput = { token: string; expiresAt: string };
953
+
954
+ type TokenPair = {
955
+ accessToken: AccessTokenOutput;
956
+ refreshToken?: RefreshTokenOutput; // omitted if config.auth.refreshToken.enabled = false
957
+ };
958
+ ```
959
+
960
+ ## Issuing tokens
961
+
962
+ ```ts
963
+ import { authService } from "@warlock.js/auth";
964
+
965
+ // Just an access token (rare — usually use createTokenPair)
966
+ const access = await authService.generateAccessToken(user);
967
+
968
+ // Just a refresh token
969
+ const refresh = await authService.createRefreshToken(user, deviceInfo);
970
+
971
+ // Both — the everyday case
972
+ const pair = await authService.createTokenPair(user, deviceInfo);
973
+ ```
974
+
975
+ `createTokenPair` is the typical issuance path. It respects `config.auth.refreshToken.enabled` — if disabled, returns only `accessToken`.
976
+
977
+ ## Refresh with rotation — `refreshTokens`
978
+
979
+ Refresh and family revocation use durable family revision checks inside a
980
+ serializable Cascade transaction. Run the additive `authMigrations` before
981
+ using the upgraded APIs. Transaction support is required: the service fails
982
+ rather than falling back to uncoordinated writes. MongoDB deployments therefore
983
+ need a transaction-capable topology.
984
+
985
+ An operation that owns its transaction retries serialization conflicts at most
986
+ three times. Inside an application-owned transaction it joins once and leaves
987
+ retry/rollback to the caller. Auth notifications from owned attempts are emitted
988
+ only after successful commit; notifications inside an application-owned outer
989
+ transaction retain their existing timing before that outer commit. Keep external
990
+ side effects outside such a transaction.
991
991
 
992
992
  ```ts
993
993
  const next = await authService.refreshTokens(oldRefreshToken, deviceInfo);
@@ -1214,149 +1214,149 @@ Two CLI commands — `warlock jwt.generate` (strong JWT secret → `.env`) and `
1214
1214
 
1215
1215
  ## protect-routes `@warlock.js/auth/protect-routes/SKILL.md`
1216
1216
 
1217
- ---
1218
- name: protect-routes
1219
- description: "Gate routes with authMiddleware in @warlock.js/auth; use when you need to protect routes."
1220
- ---
1221
-
1222
- # Gate routes with `authMiddleware`
1223
-
1224
- `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.
1225
-
1226
- ```ts title="src/app/users/models/user/user.model.ts"
1227
- import { Auth } from "@warlock.js/auth";
1228
- import { RegisterModel } from "@warlock.js/cascade";
1229
-
1230
- @RegisterModel()
1231
- export class User extends Auth {
1232
- public static table = "users";
1233
-
1234
- public get userType(): string {
1235
- return "user";
1236
- }
1237
- }
1238
- ```
1239
-
1240
- ## Two modes
1241
-
1242
- Middleware is attached via the route's `options.middleware` array (the third argument) — never as a positional argument.
1243
-
1244
- ```ts
1245
- import { router, type RequestHandler } from "@warlock.js/core";
1246
- import { authMiddleware } from "@warlock.js/auth";
1247
-
1248
- const accountController: RequestHandler = async ({ response }) => response.success({});
1249
- const adminController: RequestHandler = async ({ response }) => response.success({});
1250
- const staffController: RequestHandler = async ({ response }) => response.success({});
1251
-
1217
+ ---
1218
+ name: protect-routes
1219
+ 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.'
1220
+ ---
1221
+
1222
+ # Gate routes with `authMiddleware`
1223
+
1224
+ `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.
1225
+
1226
+ ```ts title="src/app/users/models/user/user.model.ts"
1227
+ import { Auth } from "@warlock.js/auth";
1228
+ import { RegisterModel } from "@warlock.js/cascade";
1229
+
1230
+ @RegisterModel()
1231
+ export class User extends Auth {
1232
+ public static table = "users";
1233
+
1234
+ public get userType(): string {
1235
+ return "user";
1236
+ }
1237
+ }
1238
+ ```
1239
+
1240
+ ## Two modes
1241
+
1242
+ Middleware is attached via the route's `options.middleware` array (the third argument) — never as a positional argument.
1243
+
1244
+ ```ts
1245
+ import { router, type RequestHandler } from "@warlock.js/core";
1246
+ import { authMiddleware } from "@warlock.js/auth";
1247
+
1248
+ const accountController: RequestHandler = async ({ response }) => response.success({});
1249
+ const adminController: RequestHandler = async ({ response }) => response.success({});
1250
+ const staffController: RequestHandler = async ({ response }) => response.success({});
1251
+
1252
1252
  // Required default type (`auth.defaultUserType`, or the sole configured type)
1253
1253
  router.get("/account", accountController, {
1254
1254
  middleware: [authMiddleware()],
1255
- });
1256
-
1257
- // Mode 2 — required, specific user type(s)
1258
- // Rejects missing/invalid credentials with 401 and a disallowed user type with 403.
1259
- router.get("/admin", adminController, {
1260
- middleware: [authMiddleware("admin")],
1261
- });
1262
-
1263
- router.get("/staff", staffController, {
1264
- middleware: [authMiddleware(["admin", "staff"])],
1265
- });
1266
- ```
1267
-
1268
- 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).
1269
-
1270
- ## What the middleware does
1271
-
1272
- On success, before your controller runs:
1273
-
1274
- ```ts
1275
- request.locals.user = <hydrated user model instance>;
1276
- request.decodedAccessToken = <decoded JWT payload>;
1277
- ```
1278
-
1279
- 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.
1280
-
1281
- A token passes three separate checks, in order — a token can fail any one of them while satisfying the other two:
1282
-
1283
- 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.
1284
- 2. **The access-token row still exists** — deleting it (logout) invalidates the token immediately, before its JWT expiry.
1285
- 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.
1286
-
1287
- Missing or invalid credentials return 401; an authenticated disallowed user type returns 403:
1288
-
1289
- | Error code | When |
1290
- | --- | --- |
1291
- | `MissingAccessToken` | No credential at the configured source |
1292
- | `InvalidAccessToken` | Token doesn't verify (signature, missing/passed `exp`, wrong token type), has no DB row, or the row's `expires_at` has passed |
1293
- | `Unauthorized` | Token valid but user-type isn't in the allowed list (403) |
1294
-
1295
- ## Page routes: redirect to login instead of the JSON 401
1296
-
1297
- 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.
1298
-
1299
- 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:
1300
-
1301
- ```ts title="src/config/auth.ts"
1302
- import { User } from "app/users/models/user/user.model";
1303
-
1304
- export default {
1305
- userType: { user: User },
1306
- pageAuth: {
1307
- loginPath: "/login", // set this to turn the redirect on
1308
- returnUrlParam: "returnUrl", // optional, this is the default
1309
- },
1310
- };
1311
- ```
1312
-
1313
- Behavior, precisely:
1314
-
1315
- - **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 `?`.
1316
- - **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.
1317
- - **API route** → always the JSON `401`, regardless of `pageAuth`. The API contract is never altered.
1318
-
1319
- 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`.
1320
-
1321
- > A local `redirect` object is preferred for a page that needs a different login destination; absent it, `auth.pageAuth` remains the compatibility fallback.
1322
-
1323
- ## Reading the user in a controller
1324
-
1325
- `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:
1326
-
1327
- ```ts title="src/app/users/request-user.ts"
1328
- import type { User } from "app/users/models/user/user.model";
1329
-
1330
- declare module "@warlock.js/auth" {
1331
- interface RequestUser extends User {}
1332
- }
1333
- ```
1334
-
1335
- ```ts
1336
- import { type RequestHandler } from "@warlock.js/core";
1337
-
1338
- export const accountController: RequestHandler = async ({ request, response }) => {
1339
- const user = request.locals.user!; // typed via the RequestUser augmentation above
1340
- return response.success({
1341
- id: user.id,
1342
- email: user.get("email"),
1343
- });
1344
- };
1345
- ```
1346
-
1347
- 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.
1348
-
1349
- ## Route-group protection
1350
-
1351
- ```ts
1352
- router.group({ prefix: "/admin", middleware: [authMiddleware("admin")] }, () => {
1353
- router.get("/users", listUsersController);
1354
- router.post("/users", createUserController);
1355
- });
1356
- ```
1357
-
1358
- 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.
1359
-
1255
+ });
1256
+
1257
+ // Mode 2 — required, specific user type(s)
1258
+ // Rejects missing/invalid credentials with 401 and a disallowed user type with 403.
1259
+ router.get("/admin", adminController, {
1260
+ middleware: [authMiddleware("admin")],
1261
+ });
1262
+
1263
+ router.get("/staff", staffController, {
1264
+ middleware: [authMiddleware(["admin", "staff"])],
1265
+ });
1266
+ ```
1267
+
1268
+ 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).
1269
+
1270
+ ## What the middleware does
1271
+
1272
+ On success, before your controller runs:
1273
+
1274
+ ```ts
1275
+ request.locals.user = <hydrated user model instance>;
1276
+ request.decodedAccessToken = <decoded JWT payload>;
1277
+ ```
1278
+
1279
+ 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.
1280
+
1281
+ A token passes three separate checks, in order — a token can fail any one of them while satisfying the other two:
1282
+
1283
+ 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.
1284
+ 2. **The access-token row still exists** — deleting it (logout) invalidates the token immediately, before its JWT expiry.
1285
+ 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.
1286
+
1287
+ Missing or invalid credentials return 401; an authenticated disallowed user type returns 403:
1288
+
1289
+ | Error code | When |
1290
+ | --- | --- |
1291
+ | `MissingAccessToken` | No credential at the configured source |
1292
+ | `InvalidAccessToken` | Token doesn't verify (signature, missing/passed `exp`, wrong token type), has no DB row, or the row's `expires_at` has passed |
1293
+ | `Unauthorized` | Token valid but user-type isn't in the allowed list (403) |
1294
+
1295
+ ## Page routes: redirect to login instead of the JSON 401
1296
+
1297
+ 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.
1298
+
1299
+ 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:
1300
+
1301
+ ```ts title="src/config/auth.ts"
1302
+ import { User } from "app/users/models/user/user.model";
1303
+
1304
+ export default {
1305
+ userType: { user: User },
1306
+ pageAuth: {
1307
+ loginPath: "/login", // set this to turn the redirect on
1308
+ returnUrlParam: "returnUrl", // optional, this is the default
1309
+ },
1310
+ };
1311
+ ```
1312
+
1313
+ Behavior, precisely:
1314
+
1315
+ - **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 `?`.
1316
+ - **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.
1317
+ - **API route** → always the JSON `401`, regardless of `pageAuth`. The API contract is never altered.
1318
+
1319
+ 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`.
1320
+
1321
+ > A local `redirect` object is preferred for a page that needs a different login destination; absent it, `auth.pageAuth` remains the compatibility fallback.
1322
+
1323
+ ## Reading the user in a controller
1324
+
1325
+ `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:
1326
+
1327
+ ```ts title="src/app/users/request-user.ts"
1328
+ import type { User } from "app/users/models/user/user.model";
1329
+
1330
+ declare module "@warlock.js/auth" {
1331
+ interface RequestUser extends User {}
1332
+ }
1333
+ ```
1334
+
1335
+ ```ts
1336
+ import { type RequestHandler } from "@warlock.js/core";
1337
+
1338
+ export const accountController: RequestHandler = async ({ request, response }) => {
1339
+ const user = request.locals.user!; // typed via the RequestUser augmentation above
1340
+ return response.success({
1341
+ id: user.id,
1342
+ email: user.get("email"),
1343
+ });
1344
+ };
1345
+ ```
1346
+
1347
+ 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.
1348
+
1349
+ ## Route-group protection
1350
+
1351
+ ```ts
1352
+ router.group({ prefix: "/admin", middleware: [authMiddleware("admin")] }, () => {
1353
+ router.get("/users", listUsersController);
1354
+ router.post("/users", createUserController);
1355
+ });
1356
+ ```
1357
+
1358
+ 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.
1359
+
1360
1360
  ## Optional auth
1361
1361
 
1362
1362
  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.
@@ -1365,475 +1365,475 @@ Use `optional: true` in the object form when a public route should receive a ver
1365
1365
  middleware: [authMiddleware({ source: "header", optional: true })]
1366
1366
  ```
1367
1367
 
1368
- ## Automatic cookie renewal
1369
-
1370
- Add `refresh: { source: "cookie", key: "refresh_token", overlapMs?: 5000 }`
1371
- to a typed cookie middleware to renew an absent, invalid, or expired access
1372
- cookie before the handler. It writes the successor access and refresh cookies
1373
- through `authService.setAuthCookie`; it never replays a handler. `overlapMs` is
1374
- clamped to 0–10,000 ms (default 5,000). Only the exact immediate successor is
1375
- tolerated during that window. The legacy `refreshTokens` API stays strict; a
1376
- later old-token presentation revokes its family.
1377
- ## Custom error responses
1378
-
1379
- The middleware uses the framework's `response.unauthorized({...})` shape. To override the response globally, hook the framework's error transformer to remap `AuthErrorCodes.*` codes.
1380
-
1381
- ## When flat user types aren't enough — reach for `@warlock.js/access`
1382
-
1383
- `authMiddleware` gates on **user type** by flat string membership. That answers "is this an admin?" and nothing more. It deliberately cannot express:
1384
-
1385
- - a permission matrix (`articles.publish` rather than `"editor"`)
1386
- - role hierarchy or inheritance
1387
- - **who may act on whom** — "an `admin` may create a `teacher`, but only a `superAdmin` may create or promote an `admin`"
1388
-
1389
- 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.
1390
-
1391
- 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:
1392
-
1393
- ```ts
1394
- import { router, type RequestHandler } from "@warlock.js/core";
1395
- import { authMiddleware } from "@warlock.js/auth";
1396
- import { gate, can, definePolicy } from "@warlock.js/access";
1397
- import { User } from "app/users/models/user/user.model";
1398
-
1399
- const articlesController: RequestHandler = async ({ request, response }) => {
1400
- // Who-may-act-on-whom: an ABAC policy on top of the RBAC grant
1401
- definePolicy("users.create", (actor, target, ctx) =>
1402
- ctx.hasRole("superAdmin") || (target as User).userType === "teacher",
1403
- );
1404
-
1405
- if (await can(request.locals.user!, "users.create", { resource: request.body })) {
1406
- /* ... */
1407
- }
1408
-
1409
- return response.success({});
1410
- };
1411
-
1412
- // Permission-based route gate, in place of a user-type gate
1413
- router.post("/articles", articlesController, { middleware: [authMiddleware([]), gate("articles.create")] });
1414
- ```
1415
-
1416
- 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.
1417
-
1418
- ## Cookie credentials
1419
-
1420
- 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.
1421
-
1422
- ```ts
1423
- router.get("/browser-account", accountController, {
1424
- middleware: [authMiddleware({ source: "cookie", key: "token" })],
1425
- });
1426
- ```
1427
-
1428
- 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).
1429
-
1430
- ### Writing and clearing the cookie: `authService.setAuthCookie` / `clearAuthCookie`
1431
-
1432
- 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.
1433
-
1434
- ```ts
1435
- import { type RequestHandler } from "@warlock.js/core";
1436
- import { authService } from "@warlock.js/auth";
1437
- import { User } from "app/users/models/user/user.model";
1438
-
1439
- export const loginController: RequestHandler = async ({ request, response }) => {
1440
- const result = await authService.login(User, {
1441
- email: request.input("email"),
1442
- password: request.input("password"),
1443
- });
1444
- if (!result) return response.unauthorized({ error: "Invalid credentials" });
1445
-
1446
- const { user, tokens } = result;
1447
- authService.setAuthCookie(response, tokens.accessToken); // Max-Age derived from tokens.accessToken.expiresAt
1448
-
1449
- return response.success({ id: user.id });
1450
- };
1451
-
1452
- export const logoutController: RequestHandler = async ({ request, response }) => {
1453
- const user = request.locals.user!;
1454
- await authService.logout(user, request.header("authorization"));
1455
- authService.clearAuthCookie(response);
1456
-
1457
- return response.success({});
1458
- };
1459
- ```
1460
-
1461
- `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.
1462
-
1463
- 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:
1464
-
1465
- ```ts title="src/config/auth.ts"
1466
- import { User } from "app/users/models/user/user.model";
1467
-
1468
- export default {
1469
- userType: { user: User },
1470
- cookie: {
1471
- name: "access_token", // must match the `cookie:<name>` a route reads
1472
- path: "/",
1473
- },
1474
- };
1475
- ```
1476
-
1477
- The cookie name `setAuthCookie`/`clearAuthCookie` write **must match** the name a `cookie:<name>` token source reads — they are not linked automatically.
1478
-
1479
- ### CSRF: an automatic Origin check on cookie-authenticated writes
1480
-
1481
- 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`.
1482
-
1483
- 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:
1484
-
1485
- - **Allowed** when `Origin` equals the request's own origin, or is listed in `auth.csrf.allowedOrigins` (config, default `[]`).
1486
- - If `Origin` is absent, `Referer`'s origin is checked the same way.
1487
- - If **both** are absent, or the check fails, the request is rejected with `403` and `errorCode: AuthErrorCodes.CsrfOriginMismatch` ("EC006") — never silently passed through.
1488
- - **Header-token auth is completely unaffected**, as are safe methods (`GET`/`HEAD`/`OPTIONS`) under cookie auth.
1489
-
1490
- ```ts title="src/config/auth.ts"
1491
- import { User } from "app/users/models/user/user.model";
1492
-
1493
- export default {
1494
- userType: { user: User },
1495
- csrf: {
1496
- allowedOrigins: ["https://admin.example.com"], // extra trusted origins, beyond same-origin
1497
- },
1498
- };
1499
- ```
1500
-
1501
- 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.
1502
-
1503
- ## Things NOT to do
1504
-
1505
- - 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).
1506
- - Don't manually decode JWTs in the controller. The middleware already does it and exposes the decoded payload via `request.decodedAccessToken`.
1507
- - 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.
1508
- - 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.
1509
-
1510
- ## See also
1511
-
1512
- - [`@warlock.js/auth/customize-user-type/SKILL.md`](@warlock.js/auth/customize-user-type/SKILL.md) — config and multi-user-type semantics
1513
- - [`@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
1514
- - [`@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
1515
- - [`@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
1516
-
1517
-
1518
- ## register-user `@warlock.js/auth/register-user/SKILL.md`
1519
-
1520
- ---
1521
- name: register-user
1522
- description: "Register-and-issue-tokens flow in @warlock.js/auth; use when you need to register user."
1523
- ---
1524
-
1525
- # Register-and-issue-tokens flow
1526
-
1527
- Two-step on the server: create the user (with hashed password), then issue tokens. Cascade handles the persistence; `authService` handles the tokens.
1528
-
1529
- ```ts title="src/app/users/models/user/user.model.ts"
1530
- import { Auth } from "@warlock.js/auth";
1531
- import { RegisterModel } from "@warlock.js/cascade";
1532
-
1533
- @RegisterModel()
1534
- export class User extends Auth {
1535
- public static table = "users";
1536
-
1537
- public get userType(): string {
1538
- return "user";
1539
- }
1540
- }
1541
- ```
1542
-
1543
- ## The minimal shape
1544
-
1545
- ```ts
1546
- import { authService } from "@warlock.js/auth";
1547
- import { hashPassword, type RequestHandler } from "@warlock.js/core";
1548
- import { User } from "app/users/models/user/user.model";
1549
-
1550
- export const registerController: RequestHandler = async ({ request, response }) => {
1551
- const { email, password, name } = request.all();
1552
-
1553
- // 1. Check duplicates
1554
- const existing = await User.first({ email });
1555
- if (existing) {
1556
- return response.conflict({ error: "Email already registered" });
1557
- }
1558
-
1559
- // 2. Create the user with hashed password
1560
- const user = await User.create({
1561
- email,
1562
- name,
1563
- password: await hashPassword(password),
1564
- });
1565
-
1566
- // 3. Issue tokens
1567
- const tokens = await authService.createTokenPair(user, {
1568
- userAgent: request.header("user-agent"),
1569
- ip: request.ip,
1570
- });
1571
-
1572
- // 4. Respond
1573
- return response.successCreate({
1574
- user, // shape via static toJsonColumns / static resource
1575
- tokens,
1576
- });
1577
- };
1578
- ```
1579
-
1580
- That's the whole flow. `User.create({...})` runs the schema validation (including `.email()`, `.min()`, etc. on each field), so you don't need a separate validation pass — see [`@warlock.js/seal/handle-seal-errors/SKILL.md`](@warlock.js/seal/handle-seal-errors/SKILL.md) for catching validation failures.
1581
-
1582
- ## Hash the password on the way in
1583
-
1584
- Always pass `hashPassword(plain)` — never store the plain password. The `hashPassword` helper is `bcrypt`-based and async; the cost factor matches the framework default.
1585
-
1586
- ```ts
1587
- import { hashPassword, verifyPassword } from "@warlock.js/core";
1588
-
1589
- const hash = await hashPassword("plaintext"); // store this
1590
- const ok = await verifyPassword("plaintext", hash); // compare on login
1591
- ```
1592
-
1593
- `authService.attemptLogin` already calls `verifyPassword` against the stored hash — you don't compare passwords manually.
1594
-
1595
- ## Schema enforcement
1596
-
1597
- Define the password as `v.string().strongPassword(12)` (or similar) in your User schema so weak passwords are rejected at `create()` time:
1598
-
1599
- ```ts
1600
- const userSchema = v.object({
1601
- email: v.string().email(),
1602
- name: v.string().min(2).max(120),
1603
- password: v.string().strongPassword(12), // 12+ chars, upper/lower/digit/symbol
1604
- // status, role, etc.
1605
- });
1606
- ```
1607
-
1608
- But **don't return the password in the public output**:
1609
-
1610
- ```ts
1611
- @RegisterModel()
1612
- export class User extends Model<UserSchema> {
1613
- public static table = "users";
1614
- public static schema = userSchema;
1615
- public static toJsonColumns = ["id", "email", "name", "created_at"]; // omit password
1616
- }
1617
- ```
1618
-
1619
- Without this, `JSON.stringify(user)` in your response leaks the hash.
1620
-
1621
- ## Email verification flow (extending registration)
1622
-
1623
- Since 5.13 auth ships this. Declare `emailVerifiedAt: v.date().optional()` on the schema, then send the verification after creating the user:
1624
-
1625
- ```ts
1626
- import { authService, sendEmailVerification } from "@warlock.js/auth";
1627
-
1628
- const user = await User.create({ ...data, password: await hashPassword(data.password) });
1629
-
1630
- await sendEmailVerification(user); // hashed, single-use, 24h token via @warlock.js/notifications
1631
-
1632
- const tokens = await authService.createTokenPair(user);
1633
- return response.successCreate({ user, tokens });
1634
- ```
1635
-
1636
- Gate routes that need a confirmed address with `requireVerifiedEmail()` after `authMiddleware`. See [`@warlock.js/auth/verify-email-and-reset-password/SKILL.md`](@warlock.js/auth/verify-email-and-reset-password/SKILL.md).
1637
-
1638
- ## Side effects via auth events
1639
-
1640
- Hook post-registration logic:
1641
-
1642
- ```ts
1643
- import { authEvents } from "@warlock.js/auth";
1644
-
1645
- authEvents.on("session.created", async (user, refreshToken, deviceInfo) => {
1646
- if (user.get("created_at") > new Date(Date.now() - 5000)) {
1647
- // freshly created in the last 5s — treat as registration
1648
- await sendWelcomeEmail(user);
1649
- }
1650
- });
1651
- ```
1652
-
1653
- Cleaner alternative: emit your own `user.registered` event from the controller after `User.create`. Decouples auth-package events from your domain events.
1654
-
1655
- ## Things NOT to do
1656
-
1657
- - Don't pass the plain password to `User.create()`. `await hashPassword(plain)` first.
1658
- - Don't return the user without `toJsonColumns` / `resource` shaping — the password hash will leak otherwise.
1659
- - Don't issue tokens before validating the user shape. `User.create` runs validation; let it throw on bad input before tokens get created.
1660
- - Don't run "send welcome email" inline in the controller. Push it to a queue or run it after-commit via the outbox pattern — see [`@warlock.js/cascade/manage-transactions/SKILL.md`](@warlock.js/cascade/manage-transactions/SKILL.md).
1661
-
1662
- ## See also
1663
-
1664
- - [`@warlock.js/auth/handle-login-and-logout/SKILL.md`](@warlock.js/auth/handle-login-and-logout/SKILL.md) — login flow (same `createTokenPair` step)
1665
- - [`@warlock.js/auth/manage-tokens/SKILL.md`](@warlock.js/auth/manage-tokens/SKILL.md) — token issuance internals
1666
- - [`@warlock.js/cascade/define-model/SKILL.md`](@warlock.js/cascade/define-model/SKILL.md) — `toJsonColumns` / `resource` for public output
1667
-
1668
-
1669
- ## run-auth-commands `@warlock.js/auth/run-auth-commands/SKILL.md`
1670
-
1671
- ---
1672
- name: run-auth-commands
1673
- description: "Run auth commands in @warlock.js/auth; use when you need to run auth commands."
1674
- ---
1675
-
1676
- # Run auth commands
1677
-
1678
- The package ships three CLI commands. Register them in `warlock.config.ts`; the framework picks them up.
1679
-
1680
- ## Register
1681
-
1682
- ```ts title="warlock.config.ts"
1683
- import {
1684
- registerAuthCleanupCommand,
1685
- registerAuthPurgeNeverExpiringCommand,
1686
- registerJWTSecretGeneratorCommand,
1687
- } from "@warlock.js/auth";
1688
- import { defineConfig } from "@warlock.js/core";
1689
-
1690
- export default defineConfig({
1691
- cli: {
1692
- commands: [
1693
- registerJWTSecretGeneratorCommand(),
1694
- registerAuthCleanupCommand(),
1695
- registerAuthPurgeNeverExpiringCommand(),
1696
- ],
1697
- },
1698
- });
1699
- ```
1700
-
1701
- ## `warlock jwt.generate` — JWT secret bootstrap
1702
-
1703
- ```bash
1704
- npx warlock jwt.generate
1705
- ```
1706
-
1707
- Generates a cryptographically strong secret string and writes it to your `.env` as `JWT_SECRET=...` (and `JWT_REFRESH_SECRET=...` if refresh tokens are enabled).
1708
-
1709
- Run it once when setting up a new project. Each developer typically runs it locally; production secrets come from your secret manager (Vault, AWS Secrets Manager, k8s secrets) and bypass this command.
1710
-
1711
- **Don't commit `.env`.** The generated secret should never live in the repo. The command writes to `.env`, which `.gitignore` already excludes in a default Warlock project.
1712
-
1713
- ## `warlock auth.cleanup` — expired token sweep
1714
-
1715
- ```bash
1716
- npx warlock auth.cleanup
1717
- ```
1718
-
1719
- Runs `authService.cleanupExpiredTokens()` — deletes every refresh token whose `expires_at` has passed, then sweeps expired access-token rows and hard-deletes expired or consumed `one_time_tokens` rows (verification/reset tokens, OTP codes, passkey challenges). Fires `token.expired` per refresh token and `cleanup.completed` once.
1720
-
1721
- Schedule it periodically. Two common shapes:
1722
-
1723
- ### Via the scheduler
1724
-
1725
- ```ts
1726
- import { scheduler, job } from "@warlock.js/scheduler";
1727
- import { authService } from "@warlock.js/auth";
1728
-
1729
- scheduler.addJob(
1730
- job("auth-cleanup", () => authService.cleanupExpiredTokens())
1731
- .daily()
1732
- .at("03:00")
1733
- .preventOverlap(),
1734
- );
1735
-
1736
- scheduler.start();
1737
- ```
1738
-
1739
- In-process — no shell call. See [`@warlock.js/scheduler/scheduler-basics/SKILL.md`](@warlock.js/scheduler/scheduler-basics/SKILL.md).
1740
-
1741
- ### Via system cron
1742
-
1743
- ```cron
1744
- 0 3 * * * cd /path/to/app && /usr/local/bin/npx warlock auth.cleanup
1745
- ```
1746
-
1747
- Out-of-process — works when you don't want the scheduler subsystem running in this service.
1748
-
1749
- ## How often?
1750
-
1751
- Once a day is usually enough. The check is cheap (single indexed DELETE on `expires_at < now()`), and refresh tokens that have already expired don't grant access — cleanup is housekeeping, not security.
1752
-
1753
- If you have very-short-lived refresh tokens (1h expiry) and a million-user scale where the table grows fast, cleanup more often (hourly).
1754
-
1755
- ## `warlock auth.purge-never-expiring` — one-off remediation
1756
-
1757
- ```bash
1758
- npx warlock auth.purge-never-expiring --dry-run # report only
1759
- npx warlock auth.purge-never-expiring # report, then revoke
1760
- ```
1761
-
1762
- Register with `registerAuthPurgeNeverExpiringCommand()`.
1763
-
1764
- 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.
1765
-
1766
- **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.
1767
-
1768
- 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.
1769
-
1770
- Unlike `auth.cleanup`, do **not** schedule it: it is a full table scan (it must read rows to judge them, since neither signal is expressible as a `where`), and once the poisoned rows are gone, 4.12.0 cannot create more.
1771
-
1772
- ## Custom commands
1773
-
1774
- If `auth.cleanup` doesn't cover everything your app needs (e.g. you also want to revoke tokens for inactive users), write your own command and combine the auth service helpers:
1775
-
1776
- ```ts title="src/app/users/models/user/user.model.ts"
1777
- import { Auth } from "@warlock.js/auth";
1778
- import { RegisterModel } from "@warlock.js/cascade";
1779
-
1780
- @RegisterModel()
1781
- export class User extends Auth {
1782
- public static table = "users";
1783
-
1784
- public get userType(): string {
1785
- return "user";
1786
- }
1787
- }
1788
- ```
1789
-
1790
- ```ts
1791
- import { command } from "@warlock.js/core";
1792
- import { authService } from "@warlock.js/auth";
1793
- import { User } from "app/users/models/user/user.model";
1368
+ ## Automatic cookie renewal
1369
+
1370
+ Add `refresh: { source: "cookie", key: "refresh_token", overlapMs?: 5000 }`
1371
+ to a typed cookie middleware to renew an absent, invalid, or expired access
1372
+ cookie before the handler. It writes the successor access and refresh cookies
1373
+ through `authService.setAuthCookie`; it never replays a handler. `overlapMs` is
1374
+ clamped to 0–10,000 ms (default 5,000). Only the exact immediate successor is
1375
+ tolerated during that window. The legacy `refreshTokens` API stays strict; a
1376
+ later old-token presentation revokes its family.
1377
+ ## Custom error responses
1378
+
1379
+ The middleware uses the framework's `response.unauthorized({...})` shape. To override the response globally, hook the framework's error transformer to remap `AuthErrorCodes.*` codes.
1380
+
1381
+ ## When flat user types aren't enough — reach for `@warlock.js/access`
1382
+
1383
+ `authMiddleware` gates on **user type** by flat string membership. That answers "is this an admin?" and nothing more. It deliberately cannot express:
1384
+
1385
+ - a permission matrix (`articles.publish` rather than `"editor"`)
1386
+ - role hierarchy or inheritance
1387
+ - **who may act on whom** — "an `admin` may create a `teacher`, but only a `superAdmin` may create or promote an `admin`"
1388
+
1389
+ 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.
1390
+
1391
+ 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:
1392
+
1393
+ ```ts
1394
+ import { router, type RequestHandler } from "@warlock.js/core";
1395
+ import { authMiddleware } from "@warlock.js/auth";
1396
+ import { gate, can, definePolicy } from "@warlock.js/access";
1397
+ import { User } from "app/users/models/user/user.model";
1398
+
1399
+ const articlesController: RequestHandler = async ({ request, response }) => {
1400
+ // Who-may-act-on-whom: an ABAC policy on top of the RBAC grant
1401
+ definePolicy("users.create", (actor, target, ctx) =>
1402
+ ctx.hasRole("superAdmin") || (target as User).userType === "teacher",
1403
+ );
1404
+
1405
+ if (await can(request.locals.user!, "users.create", { resource: request.body })) {
1406
+ /* ... */
1407
+ }
1408
+
1409
+ return response.success({});
1410
+ };
1411
+
1412
+ // Permission-based route gate, in place of a user-type gate
1413
+ router.post("/articles", articlesController, { middleware: [authMiddleware([]), gate("articles.create")] });
1414
+ ```
1415
+
1416
+ 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.
1417
+
1418
+ ## Cookie credentials
1419
+
1420
+ 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.
1421
+
1422
+ ```ts
1423
+ router.get("/browser-account", accountController, {
1424
+ middleware: [authMiddleware({ source: "cookie", key: "token" })],
1425
+ });
1426
+ ```
1427
+
1428
+ 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).
1429
+
1430
+ ### Writing and clearing the cookie: `authService.setAuthCookie` / `clearAuthCookie`
1431
+
1432
+ 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.
1433
+
1434
+ ```ts
1435
+ import { type RequestHandler } from "@warlock.js/core";
1436
+ import { authService } from "@warlock.js/auth";
1437
+ import { User } from "app/users/models/user/user.model";
1438
+
1439
+ export const loginController: RequestHandler = async ({ request, response }) => {
1440
+ const result = await authService.login(User, {
1441
+ email: request.input("email"),
1442
+ password: request.input("password"),
1443
+ });
1444
+ if (!result) return response.unauthorized({ error: "Invalid credentials" });
1445
+
1446
+ const { user, tokens } = result;
1447
+ authService.setAuthCookie(response, tokens.accessToken); // Max-Age derived from tokens.accessToken.expiresAt
1448
+
1449
+ return response.success({ id: user.id });
1450
+ };
1451
+
1452
+ export const logoutController: RequestHandler = async ({ request, response }) => {
1453
+ const user = request.locals.user!;
1454
+ await authService.logout(user, request.header("authorization"));
1455
+ authService.clearAuthCookie(response);
1456
+
1457
+ return response.success({});
1458
+ };
1459
+ ```
1460
+
1461
+ `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.
1462
+
1463
+ 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:
1464
+
1465
+ ```ts title="src/config/auth.ts"
1466
+ import { User } from "app/users/models/user/user.model";
1467
+
1468
+ export default {
1469
+ userType: { user: User },
1470
+ cookie: {
1471
+ name: "access_token", // must match the `cookie:<name>` a route reads
1472
+ path: "/",
1473
+ },
1474
+ };
1475
+ ```
1476
+
1477
+ The cookie name `setAuthCookie`/`clearAuthCookie` write **must match** the name a `cookie:<name>` token source reads — they are not linked automatically.
1478
+
1479
+ ### CSRF: an automatic Origin check on cookie-authenticated writes
1480
+
1481
+ 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`.
1482
+
1483
+ 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:
1484
+
1485
+ - **Allowed** when `Origin` equals the request's own origin, or is listed in `auth.csrf.allowedOrigins` (config, default `[]`).
1486
+ - If `Origin` is absent, `Referer`'s origin is checked the same way.
1487
+ - If **both** are absent, or the check fails, the request is rejected with `403` and `errorCode: AuthErrorCodes.CsrfOriginMismatch` ("EC006") — never silently passed through.
1488
+ - **Header-token auth is completely unaffected**, as are safe methods (`GET`/`HEAD`/`OPTIONS`) under cookie auth.
1489
+
1490
+ ```ts title="src/config/auth.ts"
1491
+ import { User } from "app/users/models/user/user.model";
1492
+
1493
+ export default {
1494
+ userType: { user: User },
1495
+ csrf: {
1496
+ allowedOrigins: ["https://admin.example.com"], // extra trusted origins, beyond same-origin
1497
+ },
1498
+ };
1499
+ ```
1500
+
1501
+ 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.
1502
+
1503
+ ## Things NOT to do
1504
+
1505
+ - 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).
1506
+ - Don't manually decode JWTs in the controller. The middleware already does it and exposes the decoded payload via `request.decodedAccessToken`.
1507
+ - 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.
1508
+ - 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.
1509
+
1510
+ ## See also
1511
+
1512
+ - [`@warlock.js/auth/customize-user-type/SKILL.md`](@warlock.js/auth/customize-user-type/SKILL.md) — config and multi-user-type semantics
1513
+ - [`@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
1514
+ - [`@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
1515
+ - [`@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
1794
1516
 
1795
- export function registerDeepCleanupCommand() {
1796
- return command({
1797
- name: "auth.deep-cleanup",
1798
- description: "Expire stale tokens AND revoke tokens for inactive users",
1799
- preload: {
1800
- env: true,
1801
- config: ["auth", "database"],
1802
- connectors: ["database"],
1803
- },
1804
- action: async () => {
1805
- await authService.cleanupExpiredTokens();
1806
-
1807
- const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
1808
- const stale = await User.where("last_seen_at", "<", thirtyDaysAgo).get();
1809
-
1810
- for (const user of stale) {
1811
- await authService.revokeAllTokens(user);
1812
- }
1813
- },
1814
- });
1815
- }
1816
- ```
1817
1517
 
1818
- Register it the same way as the bundled commands — call the factory inside `defineConfig({ cli: { commands: [...] } })`.
1518
+ ## register-user `@warlock.js/auth/register-user/SKILL.md`
1819
1519
 
1820
- ## Things NOT to do
1520
+ ---
1521
+ name: register-user
1522
+ description: 'Sign up a new user and issue the initial token pair — User.create({...password: await hashPassword(plain)}) then authService.createTokenPair(user). Triggers: `User.create`, `hashPassword`, `verifyPassword`, `authService.createTokenPair`, `toJsonColumns`, `strongPassword`, `authEvents`; "build a register endpoint", "POST /register controller", "sign up a new user", "hash password on signup", "email verification flow"; typical import `import { authService } from "@warlock.js/auth"; import { hashPassword } from "@warlock.js/core"`. Skip: login — `@warlock.js/auth/handle-login-and-logout/SKILL.md`; token internals — `@warlock.js/auth/manage-tokens/SKILL.md`; competing libs `bcrypt`, `bcryptjs`, `argon2`.'
1523
+ ---
1524
+
1525
+ # Register-and-issue-tokens flow
1526
+
1527
+ Two-step on the server: create the user (with hashed password), then issue tokens. Cascade handles the persistence; `authService` handles the tokens.
1528
+
1529
+ ```ts title="src/app/users/models/user/user.model.ts"
1530
+ import { Auth } from "@warlock.js/auth";
1531
+ import { RegisterModel } from "@warlock.js/cascade";
1532
+
1533
+ @RegisterModel()
1534
+ export class User extends Auth {
1535
+ public static table = "users";
1536
+
1537
+ public get userType(): string {
1538
+ return "user";
1539
+ }
1540
+ }
1541
+ ```
1542
+
1543
+ ## The minimal shape
1544
+
1545
+ ```ts
1546
+ import { authService } from "@warlock.js/auth";
1547
+ import { hashPassword, type RequestHandler } from "@warlock.js/core";
1548
+ import { User } from "app/users/models/user/user.model";
1549
+
1550
+ export const registerController: RequestHandler = async ({ request, response }) => {
1551
+ const { email, password, name } = request.all();
1552
+
1553
+ // 1. Check duplicates
1554
+ const existing = await User.first({ email });
1555
+ if (existing) {
1556
+ return response.conflict({ error: "Email already registered" });
1557
+ }
1558
+
1559
+ // 2. Create the user with hashed password
1560
+ const user = await User.create({
1561
+ email,
1562
+ name,
1563
+ password: await hashPassword(password),
1564
+ });
1565
+
1566
+ // 3. Issue tokens
1567
+ const tokens = await authService.createTokenPair(user, {
1568
+ userAgent: request.header("user-agent"),
1569
+ ip: request.ip,
1570
+ });
1571
+
1572
+ // 4. Respond
1573
+ return response.successCreate({
1574
+ user, // shape via static toJsonColumns / static resource
1575
+ tokens,
1576
+ });
1577
+ };
1578
+ ```
1579
+
1580
+ That's the whole flow. `User.create({...})` runs the schema validation (including `.email()`, `.min()`, etc. on each field), so you don't need a separate validation pass — see [`@warlock.js/seal/handle-seal-errors/SKILL.md`](@warlock.js/seal/handle-seal-errors/SKILL.md) for catching validation failures.
1581
+
1582
+ ## Hash the password on the way in
1583
+
1584
+ Always pass `hashPassword(plain)` — never store the plain password. The `hashPassword` helper is `bcrypt`-based and async; the cost factor matches the framework default.
1585
+
1586
+ ```ts
1587
+ import { hashPassword, verifyPassword } from "@warlock.js/core";
1588
+
1589
+ const hash = await hashPassword("plaintext"); // store this
1590
+ const ok = await verifyPassword("plaintext", hash); // compare on login
1591
+ ```
1592
+
1593
+ `authService.attemptLogin` already calls `verifyPassword` against the stored hash — you don't compare passwords manually.
1594
+
1595
+ ## Schema enforcement
1596
+
1597
+ Define the password as `v.string().strongPassword(12)` (or similar) in your User schema so weak passwords are rejected at `create()` time:
1598
+
1599
+ ```ts
1600
+ const userSchema = v.object({
1601
+ email: v.string().email(),
1602
+ name: v.string().min(2).max(120),
1603
+ password: v.string().strongPassword(12), // 12+ chars, upper/lower/digit/symbol
1604
+ // status, role, etc.
1605
+ });
1606
+ ```
1607
+
1608
+ But **don't return the password in the public output**:
1609
+
1610
+ ```ts
1611
+ @RegisterModel()
1612
+ export class User extends Model<UserSchema> {
1613
+ public static table = "users";
1614
+ public static schema = userSchema;
1615
+ public static toJsonColumns = ["id", "email", "name", "created_at"]; // omit password
1616
+ }
1617
+ ```
1618
+
1619
+ Without this, `JSON.stringify(user)` in your response leaks the hash.
1620
+
1621
+ ## Email verification flow (extending registration)
1622
+
1623
+ Since 5.13 auth ships this. Declare `emailVerifiedAt: v.date().optional()` on the schema, then send the verification after creating the user:
1624
+
1625
+ ```ts
1626
+ import { authService, sendEmailVerification } from "@warlock.js/auth";
1627
+
1628
+ const user = await User.create({ ...data, password: await hashPassword(data.password) });
1629
+
1630
+ await sendEmailVerification(user); // hashed, single-use, 24h token via @warlock.js/notifications
1631
+
1632
+ const tokens = await authService.createTokenPair(user);
1633
+ return response.successCreate({ user, tokens });
1634
+ ```
1635
+
1636
+ Gate routes that need a confirmed address with `requireVerifiedEmail()` after `authMiddleware`. See [`@warlock.js/auth/verify-email-and-reset-password/SKILL.md`](@warlock.js/auth/verify-email-and-reset-password/SKILL.md).
1637
+
1638
+ ## Side effects via auth events
1639
+
1640
+ Hook post-registration logic:
1641
+
1642
+ ```ts
1643
+ import { authEvents } from "@warlock.js/auth";
1644
+
1645
+ authEvents.on("session.created", async (user, refreshToken, deviceInfo) => {
1646
+ if (user.get("created_at") > new Date(Date.now() - 5000)) {
1647
+ // freshly created in the last 5s — treat as registration
1648
+ await sendWelcomeEmail(user);
1649
+ }
1650
+ });
1651
+ ```
1652
+
1653
+ Cleaner alternative: emit your own `user.registered` event from the controller after `User.create`. Decouples auth-package events from your domain events.
1654
+
1655
+ ## Things NOT to do
1656
+
1657
+ - Don't pass the plain password to `User.create()`. `await hashPassword(plain)` first.
1658
+ - Don't return the user without `toJsonColumns` / `resource` shaping — the password hash will leak otherwise.
1659
+ - Don't issue tokens before validating the user shape. `User.create` runs validation; let it throw on bad input before tokens get created.
1660
+ - Don't run "send welcome email" inline in the controller. Push it to a queue or run it after-commit via the outbox pattern — see [`@warlock.js/cascade/manage-transactions/SKILL.md`](@warlock.js/cascade/manage-transactions/SKILL.md).
1661
+
1662
+ ## See also
1663
+
1664
+ - [`@warlock.js/auth/handle-login-and-logout/SKILL.md`](@warlock.js/auth/handle-login-and-logout/SKILL.md) — login flow (same `createTokenPair` step)
1665
+ - [`@warlock.js/auth/manage-tokens/SKILL.md`](@warlock.js/auth/manage-tokens/SKILL.md) — token issuance internals
1666
+ - [`@warlock.js/cascade/define-model/SKILL.md`](@warlock.js/cascade/define-model/SKILL.md) — `toJsonColumns` / `resource` for public output
1821
1667
 
1822
- - Don't run `jwt.generate` repeatedly in production. It changes the secret, which invalidates every token in flight. Generate once per environment.
1823
- - Don't run `auth.cleanup` from a long-running scheduler at sub-minute intervals. The DELETE itself is cheap, but the per-token `token.expired` event fan-out has cost. Hourly is plenty even at scale.
1824
- - Don't put the JWT secret in your codebase fallback (`env("JWT_SECRET", "dev-secret")`). A missing secret should fail the boot — not silently degrade to a dev value.
1825
1668
 
1826
- ## See also
1669
+ ## run-auth-commands `@warlock.js/auth/run-auth-commands/SKILL.md`
1827
1670
 
1828
- - [`@warlock.js/auth/manage-tokens/SKILL.md`](@warlock.js/auth/manage-tokens/SKILL.md) — `cleanupExpiredTokens` internals
1829
- - [`@warlock.js/scheduler/scheduler-basics/SKILL.md`](@warlock.js/scheduler/scheduler-basics/SKILL.md) — in-process scheduling
1671
+ ---
1672
+ name: run-auth-commands
1673
+ description: 'Three bundled CLI commands — warlock jwt.generate (creates strong JWT secret + writes to .env), warlock auth.cleanup (removes expired refresh tokens), and warlock auth.purge-never-expiring (finds and revokes token rows that can never expire). Register via registerJWTSecretGeneratorCommand(), registerAuthCleanupCommand() and registerAuthPurgeNeverExpiringCommand(). Triggers: `registerJWTSecretGeneratorCommand`, `registerAuthCleanupCommand`, `registerAuthPurgeNeverExpiringCommand`, `warlock jwt.generate`, `warlock auth.cleanup`, `warlock auth.purge-never-expiring`, `cleanupExpiredTokens`, `purgeNeverExpiringTokens`, `command`; "token never expires", "no exp claim", "Invalid Date expires_at"; "generate JWT secret", "bootstrap .env JWT_SECRET", "cron job for expired tokens", "schedule auth cleanup"; typical import `import { registerJWTSecretGeneratorCommand, registerAuthCleanupCommand, registerAuthPurgeNeverExpiringCommand } from "@warlock.js/auth"`. Skip: programmatic cleanup — `@warlock.js/auth/manage-tokens/SKILL.md`; in-process scheduling — `@warlock.js/scheduler/scheduler-basics/SKILL.md`; competing tools `dotenv-cli`, `node-cron`.'
1674
+ ---
1675
+
1676
+ # Run auth commands
1677
+
1678
+ The package ships three CLI commands. Register them in `warlock.config.ts`; the framework picks them up.
1679
+
1680
+ ## Register
1681
+
1682
+ ```ts title="warlock.config.ts"
1683
+ import {
1684
+ registerAuthCleanupCommand,
1685
+ registerAuthPurgeNeverExpiringCommand,
1686
+ registerJWTSecretGeneratorCommand,
1687
+ } from "@warlock.js/auth";
1688
+ import { defineConfig } from "@warlock.js/core";
1689
+
1690
+ export default defineConfig({
1691
+ cli: {
1692
+ commands: [
1693
+ registerJWTSecretGeneratorCommand(),
1694
+ registerAuthCleanupCommand(),
1695
+ registerAuthPurgeNeverExpiringCommand(),
1696
+ ],
1697
+ },
1698
+ });
1699
+ ```
1700
+
1701
+ ## `warlock jwt.generate` — JWT secret bootstrap
1702
+
1703
+ ```bash
1704
+ npx warlock jwt.generate
1705
+ ```
1706
+
1707
+ Generates a cryptographically strong secret string and writes it to your `.env` as `JWT_SECRET=...` (and `JWT_REFRESH_SECRET=...` if refresh tokens are enabled).
1708
+
1709
+ Run it once when setting up a new project. Each developer typically runs it locally; production secrets come from your secret manager (Vault, AWS Secrets Manager, k8s secrets) and bypass this command.
1710
+
1711
+ **Don't commit `.env`.** The generated secret should never live in the repo. The command writes to `.env`, which `.gitignore` already excludes in a default Warlock project.
1712
+
1713
+ ## `warlock auth.cleanup` — expired token sweep
1714
+
1715
+ ```bash
1716
+ npx warlock auth.cleanup
1717
+ ```
1718
+
1719
+ Runs `authService.cleanupExpiredTokens()` — deletes every refresh token whose `expires_at` has passed, then sweeps expired access-token rows and hard-deletes expired or consumed `one_time_tokens` rows (verification/reset tokens, OTP codes, passkey challenges). Fires `token.expired` per refresh token and `cleanup.completed` once.
1720
+
1721
+ Schedule it periodically. Two common shapes:
1722
+
1723
+ ### Via the scheduler
1724
+
1725
+ ```ts
1726
+ import { scheduler, job } from "@warlock.js/scheduler";
1727
+ import { authService } from "@warlock.js/auth";
1728
+
1729
+ scheduler.addJob(
1730
+ job("auth-cleanup", () => authService.cleanupExpiredTokens())
1731
+ .daily()
1732
+ .at("03:00")
1733
+ .preventOverlap(),
1734
+ );
1735
+
1736
+ scheduler.start();
1737
+ ```
1738
+
1739
+ In-process — no shell call. See [`@warlock.js/scheduler/scheduler-basics/SKILL.md`](@warlock.js/scheduler/scheduler-basics/SKILL.md).
1740
+
1741
+ ### Via system cron
1742
+
1743
+ ```cron
1744
+ 0 3 * * * cd /path/to/app && /usr/local/bin/npx warlock auth.cleanup
1745
+ ```
1746
+
1747
+ Out-of-process — works when you don't want the scheduler subsystem running in this service.
1748
+
1749
+ ## How often?
1750
+
1751
+ Once a day is usually enough. The check is cheap (single indexed DELETE on `expires_at < now()`), and refresh tokens that have already expired don't grant access — cleanup is housekeeping, not security.
1752
+
1753
+ If you have very-short-lived refresh tokens (1h expiry) and a million-user scale where the table grows fast, cleanup more often (hourly).
1754
+
1755
+ ## `warlock auth.purge-never-expiring` — one-off remediation
1756
+
1757
+ ```bash
1758
+ npx warlock auth.purge-never-expiring --dry-run # report only
1759
+ npx warlock auth.purge-never-expiring # report, then revoke
1760
+ ```
1761
+
1762
+ Register with `registerAuthPurgeNeverExpiringCommand()`.
1763
+
1764
+ 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.
1765
+
1766
+ **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.
1767
+
1768
+ 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.
1769
+
1770
+ Unlike `auth.cleanup`, do **not** schedule it: it is a full table scan (it must read rows to judge them, since neither signal is expressible as a `where`), and once the poisoned rows are gone, 4.12.0 cannot create more.
1771
+
1772
+ ## Custom commands
1773
+
1774
+ If `auth.cleanup` doesn't cover everything your app needs (e.g. you also want to revoke tokens for inactive users), write your own command and combine the auth service helpers:
1775
+
1776
+ ```ts title="src/app/users/models/user/user.model.ts"
1777
+ import { Auth } from "@warlock.js/auth";
1778
+ import { RegisterModel } from "@warlock.js/cascade";
1779
+
1780
+ @RegisterModel()
1781
+ export class User extends Auth {
1782
+ public static table = "users";
1783
+
1784
+ public get userType(): string {
1785
+ return "user";
1786
+ }
1787
+ }
1788
+ ```
1789
+
1790
+ ```ts
1791
+ import { command } from "@warlock.js/core";
1792
+ import { authService } from "@warlock.js/auth";
1793
+ import { User } from "app/users/models/user/user.model";
1794
+
1795
+ export function registerDeepCleanupCommand() {
1796
+ return command({
1797
+ name: "auth.deep-cleanup",
1798
+ description: "Expire stale tokens AND revoke tokens for inactive users",
1799
+ preload: {
1800
+ env: true,
1801
+ config: ["auth", "database"],
1802
+ connectors: ["database"],
1803
+ },
1804
+ action: async () => {
1805
+ await authService.cleanupExpiredTokens();
1806
+
1807
+ const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
1808
+ const stale = await User.where("last_seen_at", "<", thirtyDaysAgo).get();
1809
+
1810
+ for (const user of stale) {
1811
+ await authService.revokeAllTokens(user);
1812
+ }
1813
+ },
1814
+ });
1815
+ }
1816
+ ```
1817
+
1818
+ Register it the same way as the bundled commands — call the factory inside `defineConfig({ cli: { commands: [...] } })`.
1819
+
1820
+ ## Things NOT to do
1821
+
1822
+ - Don't run `jwt.generate` repeatedly in production. It changes the secret, which invalidates every token in flight. Generate once per environment.
1823
+ - Don't run `auth.cleanup` from a long-running scheduler at sub-minute intervals. The DELETE itself is cheap, but the per-token `token.expired` event fan-out has cost. Hourly is plenty even at scale.
1824
+ - Don't put the JWT secret in your codebase fallback (`env("JWT_SECRET", "dev-secret")`). A missing secret should fail the boot — not silently degrade to a dev value.
1825
+
1826
+ ## See also
1827
+
1828
+ - [`@warlock.js/auth/manage-tokens/SKILL.md`](@warlock.js/auth/manage-tokens/SKILL.md) — `cleanupExpiredTokens` internals
1829
+ - [`@warlock.js/scheduler/scheduler-basics/SKILL.md`](@warlock.js/scheduler/scheduler-basics/SKILL.md) — in-process scheduling
1830
1830
 
1831
1831
 
1832
1832
  ## throttle-login-attempts `@warlock.js/auth/throttle-login-attempts/SKILL.md`
1833
1833
 
1834
1834
  ---
1835
1835
  name: throttle-login-attempts
1836
- description: "Throttle login attempts with loginThrottleMiddleware in @warlock.js/auth; use when you need to throttle login attempts."
1836
+ description: 'Brute-force / credential-stuffing protection via `loginThrottleMiddleware` — a failure-aware route gate that counts only failed logins (resets on success), locks per-account and per-source after a threshold, and rejects pre-controller with 429 so the DB lookup and bcrypt verify are skipped. Cache-backed (shared across replicas), fixed-window, fails open on a cache outage. Triggers: `loginThrottleMiddleware`, `AuthErrorCodes.TooManyAttempts`, `EC004`, "rate limit login", "brute force protection", "lock account after failed logins", "throttle login attempts", "too many login attempts 429"; typical import `import { loginThrottleMiddleware } from "@warlock.js/auth"`. Skip: generic per-route request rate limiting that counts every request (use core `middleware.rateLimit`); gating a route by auth — `@warlock.js/auth/protect-routes/SKILL.md`; issuing tokens — `@warlock.js/auth/handle-login-and-logout/SKILL.md`.'
1837
1837
  ---
1838
1838
 
1839
1839
  # Throttle login attempts with `loginThrottleMiddleware`
@@ -1938,6 +1938,7 @@ They compose. For a hard ceiling on request volume *and* failure-aware account l
1938
1938
 
1939
1939
  - **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.
1940
1940
  - **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.
1941
+ - **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.
1941
1942
  - **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`.
1942
1943
  - **Defense-in-depth, not a WAF.** This is application-layer. It won't stop a volumetric L3/L4 flood — pair it with an edge/CDN rate limit for that.
1943
1944
  - **Don't call it per-request.** Like every middleware, call it once at route definition; it returns the function that runs per request.
@@ -1951,142 +1952,142 @@ They compose. For a hard ceiling on request volume *and* failure-aware account l
1951
1952
 
1952
1953
  ## verify-email-and-reset-password `@warlock.js/auth/verify-email-and-reset-password/SKILL.md`
1953
1954
 
1954
- ---
1955
- name: verify-email-and-reset-password
1956
- description: "Verify email + reset password in @warlock.js/auth; use when you need to verify email and reset password."
1957
- ---
1958
-
1959
- # Verify email + reset password
1960
-
1961
- Four service functions, one guard, two throttle presets. Auth ships no controllers — wire the routes yourself (examples below).
1962
-
1963
- ## Prerequisites
1964
-
1965
- 1. **`@warlock.js/notifications` installed and configured with a `mail` channel.** It is an optional peer; without it (or without `src/config/notifications.ts`) every send/request call throws `NotificationsUnavailableError` **before** a token is issued — never a silent skip.
1966
- 2. **The `one_time_tokens` table.** It ships in `authMigrations` next to the access/refresh tables; run your migrations.
1967
- 3. **A verified-date field on the user schema** (default `emailVerifiedAt`):
1968
-
1969
- ```ts
1970
- export const userSchema = v.object({
1971
- // ...
1972
- emailVerifiedAt: v.date().optional(),
1973
- });
1974
- ```
1975
-
1976
- If the schema strips it, `verifyEmail` throws naming the field instead of pretending it worked.
1977
-
1978
- ## Tokens
1979
-
1980
- - 32 random bytes, base64url. Only the **SHA-256 hash** is stored; the raw token exists only in the notification.
1981
- - **Single use**, consumed with a conditional update — of two concurrent uses, exactly one succeeds.
1982
- - **Expiring**: verification `24h`, reset `60m` by default.
1983
- - **Purpose-bound**: a verification token is never accepted by `resetPassword`, and vice versa.
1984
- - A new reset request **invalidates the user's earlier unused reset tokens**. Verification tokens are not invalidated by a resend.
1985
- - Unknown, wrong-purpose, expired and used tokens all throw one `InvalidOneTimeTokenError` (`400`, `EC008`).
1986
-
1987
- ## Email verification
1988
-
1989
- ```ts
1990
- import { requireVerifiedEmail, sendEmailVerification, verifyEmail } from "@warlock.js/auth";
1991
- import { type RequestHandler } from "@warlock.js/core";
1992
-
1993
- // after User.create(...) in your register controller
1994
- await sendEmailVerification(user);
1995
-
1996
- export const verifyEmailController: RequestHandler = async ({ request, response }) => {
1997
- const user = await verifyEmail(request.input("token")); // throws InvalidOneTimeTokenError
1998
- return response.success({ user });
1999
- };
2000
-
2001
- router.post("/auth/verify-email", verifyEmailController, {
2002
- middleware: [tokenConsumeThrottleMiddleware()],
2003
- });
2004
-
2005
- router.post("/auth/verify-email/resend", resendController, {
2006
- middleware: [authMiddleware("user"), tokenIssueThrottleMiddleware({ by: ["ip"] })],
2007
- });
2008
-
2009
- // Opt-in guard, AFTER authMiddleware: 403 EmailNotVerifiedError (EC007)
2010
- router.post("/orders", createOrder, {
2011
- middleware: [authMiddleware("user"), requireVerifiedEmail()],
2012
- });
2013
- ```
2014
-
2015
- `isEmailVerified(user)` answers the same question in code.
2016
-
2017
- ## Password reset
2018
-
2019
- ```ts
2020
- import { requestPasswordReset, resetPassword } from "@warlock.js/auth";
2021
-
2022
- export const forgotPasswordController: RequestHandler = async ({ request, response }) => {
2023
- await requestPasswordReset(User, request.input("email"));
2024
- // Same answer whether or not the account exists.
2025
- return response.success({ message: "If that account exists, we sent a reset link." });
2026
- };
2027
-
2028
- export const resetPasswordController: RequestHandler = async ({ request, response }) => {
2029
- // Validate strength FIRST — the token is consumed before the password is written.
2030
- await resetPassword(request.input("token"), request.input("password"));
2031
- return response.success({ message: "Password updated. Please log in again." });
2032
- };
2033
-
2034
- router.post("/auth/forgot-password", forgotPasswordController, {
2035
- middleware: [tokenIssueThrottleMiddleware()], // 3 / 1h per email + per IP
2036
- });
2037
- router.post("/auth/reset-password", resetPasswordController, {
2038
- middleware: [tokenConsumeThrottleMiddleware()], // 10 failures / 15m per IP
2039
- });
2040
- ```
2041
-
2042
- `resetPassword` writes the password, then calls `authService.revokeAllTokens(user)`: every refresh token is revoked and every access token deleted, which also kills cookie sessions (a cookie holds an access token). It emits `password.reset`; `requestPasswordReset` emits `password.resetRequested`.
2043
-
2044
- **Password hashing.** By default auth saves `hashPassword(plain)` and checks it with `verifyPassword`, the same check login uses. If your model hashes on save (`useHashedPassword()`), that first write gets hashed twice and fails the check, so auth saves the plaintext and lets your transformer hash it once. If neither write passes the check, it throws. Plaintext is never the first thing written. To write it yourself, set `auth.passwordReset.setPassword`.
2045
-
2046
- ## Throttling
2047
-
2048
- Both presets wrap `loginThrottleMiddleware` and accept its options:
2049
-
2050
- | Preset | Tracks | Counts | Default |
2051
- | --- | --- | --- | --- |
2052
- | `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 |
2053
- | `tokenConsumeThrottleMiddleware` | IP | failures only, cleared on success | 10 / 15m, 15m lock |
2054
-
2055
- ## Configuration (`src/config/auth.ts`)
2056
-
2057
- ```ts
2058
- const authConfigurations: AuthConfigurations = {
2059
- // ...
2060
- verification: {
2061
- expiresIn: "24h",
2062
- field: "emailVerifiedAt",
2063
- url: (token) => `${env("APP_URL")}/verify-email?token=${token}`,
2064
- notification: myVerificationNotification, // optional replacement
2065
- },
2066
- passwordReset: {
2067
- expiresIn: "60m",
2068
- identifierField: "email",
2069
- url: (token) => `${env("APP_URL")}/reset-password?token=${token}`,
2070
- notification: myResetNotification,
2071
- setPassword: async (user, plain) => { /* optional */ },
2072
- },
2073
- oneTimeToken: { model: AppOneTimeToken }, // optional model override
2074
- };
2075
- ```
2076
-
2077
- The default notifications are `defineNotification` objects (`type: "auth.email-verification"` / `"auth.password-reset"`, `via: ["mail"]`). To replace one, pass anything with a `send(user, { token, expiresAt, url? })` method, for example your own `defineNotification<OneTimeTokenNotificationData>({...})`. `notifications` still has to be installed and configured.
2078
-
2079
- ## Gotchas
2080
-
2081
- - **Without `url` config, the email contains the raw token**, not a link. Set `url` for anything user-facing.
2082
- - **Timing isn't equalised.** An unknown email skips the database write and the send, so the response can come back measurably faster. The throttle limits probing, but it doesn't hide the timing difference.
2083
- - **Channel send failures don't throw.** `@warlock.js/notifications` only rethrows configuration errors (such as a missing `mail` channel). An SMTP failure goes to its `failed` event, so observe it there.
2084
- - `auth.cleanup` hard-deletes expired and consumed `one_time_tokens` rows (never trashed, even on MongoDB).
2085
-
2086
- ## See also
2087
-
2088
- - [`@warlock.js/auth/register-user/SKILL.md`](@warlock.js/auth/register-user/SKILL.md) — where `sendEmailVerification` is usually called
2089
- - [`@warlock.js/auth/throttle-login-attempts/SKILL.md`](@warlock.js/auth/throttle-login-attempts/SKILL.md) — the underlying throttle
2090
- - [`@warlock.js/notifications/configure-notifications/SKILL.md`](@warlock.js/notifications/configure-notifications/SKILL.md) — mail channel setup
1955
+ ---
1956
+ name: verify-email-and-reset-password
1957
+ description: 'Email verification and password reset with hashed, single-use, expiring, purpose-bound tokens delivered through `@warlock.js/notifications` — `sendEmailVerification(user)`, `verifyEmail(token)`, `requestPasswordReset(Model, email)`, `resetPassword(token, newPassword)`, the `requireVerifiedEmail()` guard, and the `tokenIssueThrottleMiddleware` / `tokenConsumeThrottleMiddleware` presets. Reset revokes every access token, refresh token and cookie session; a reset request answers the same for unknown emails. Triggers: `sendEmailVerification`, `verifyEmail`, `requestPasswordReset`, `resetPassword`, `requireVerifiedEmail`, `isEmailVerified`, `OneTimeToken`, `InvalidOneTimeTokenError`, `EmailNotVerifiedError`, `NotificationsUnavailableError`, `EC007`, `EC008`, `auth.verification`, `auth.passwordReset`; "verify email", "confirm email address", "forgot password", "reset password link", "block unverified users", "resend verification email"; typical import `import { requestPasswordReset, resetPassword } from "@warlock.js/auth"`. Skip: sign-up itself — `@warlock.js/auth/register-user/SKILL.md`; login throttling — `@warlock.js/auth/throttle-login-attempts/SKILL.md`; defining notifications — `@warlock.js/notifications/define-notification/SKILL.md`.'
1958
+ ---
1959
+
1960
+ # Verify email + reset password
1961
+
1962
+ Four service functions, one guard, two throttle presets. Auth ships no controllers — wire the routes yourself (examples below).
1963
+
1964
+ ## Prerequisites
1965
+
1966
+ 1. **`@warlock.js/notifications` installed and configured with a `mail` channel.** It is an optional peer; without it (or without `src/config/notifications.ts`) every send/request call throws `NotificationsUnavailableError` **before** a token is issued — never a silent skip.
1967
+ 2. **The `one_time_tokens` table.** It ships in `authMigrations` next to the access/refresh tables; run your migrations.
1968
+ 3. **A verified-date field on the user schema** (default `emailVerifiedAt`):
1969
+
1970
+ ```ts
1971
+ export const userSchema = v.object({
1972
+ // ...
1973
+ emailVerifiedAt: v.date().optional(),
1974
+ });
1975
+ ```
1976
+
1977
+ If the schema strips it, `verifyEmail` throws naming the field instead of pretending it worked.
1978
+
1979
+ ## Tokens
1980
+
1981
+ - 32 random bytes, base64url. Only the **SHA-256 hash** is stored; the raw token exists only in the notification.
1982
+ - **Single use**, consumed with a conditional update — of two concurrent uses, exactly one succeeds.
1983
+ - **Expiring**: verification `24h`, reset `60m` by default.
1984
+ - **Purpose-bound**: a verification token is never accepted by `resetPassword`, and vice versa.
1985
+ - A new reset request **invalidates the user's earlier unused reset tokens**. Verification tokens are not invalidated by a resend.
1986
+ - Unknown, wrong-purpose, expired and used tokens all throw one `InvalidOneTimeTokenError` (`400`, `EC008`).
1987
+
1988
+ ## Email verification
1989
+
1990
+ ```ts
1991
+ import { requireVerifiedEmail, sendEmailVerification, verifyEmail } from "@warlock.js/auth";
1992
+ import { type RequestHandler } from "@warlock.js/core";
1993
+
1994
+ // after User.create(...) in your register controller
1995
+ await sendEmailVerification(user);
1996
+
1997
+ export const verifyEmailController: RequestHandler = async ({ request, response }) => {
1998
+ const user = await verifyEmail(request.input("token")); // throws InvalidOneTimeTokenError
1999
+ return response.success({ user });
2000
+ };
2001
+
2002
+ router.post("/auth/verify-email", verifyEmailController, {
2003
+ middleware: [tokenConsumeThrottleMiddleware()],
2004
+ });
2005
+
2006
+ router.post("/auth/verify-email/resend", resendController, {
2007
+ middleware: [authMiddleware("user"), tokenIssueThrottleMiddleware({ by: ["ip"] })],
2008
+ });
2009
+
2010
+ // Opt-in guard, AFTER authMiddleware: 403 EmailNotVerifiedError (EC007)
2011
+ router.post("/orders", createOrder, {
2012
+ middleware: [authMiddleware("user"), requireVerifiedEmail()],
2013
+ });
2014
+ ```
2015
+
2016
+ `isEmailVerified(user)` answers the same question in code.
2017
+
2018
+ ## Password reset
2019
+
2020
+ ```ts
2021
+ import { requestPasswordReset, resetPassword } from "@warlock.js/auth";
2022
+
2023
+ export const forgotPasswordController: RequestHandler = async ({ request, response }) => {
2024
+ await requestPasswordReset(User, request.input("email"));
2025
+ // Same answer whether or not the account exists.
2026
+ return response.success({ message: "If that account exists, we sent a reset link." });
2027
+ };
2028
+
2029
+ export const resetPasswordController: RequestHandler = async ({ request, response }) => {
2030
+ // Validate strength FIRST — the token is consumed before the password is written.
2031
+ await resetPassword(request.input("token"), request.input("password"));
2032
+ return response.success({ message: "Password updated. Please log in again." });
2033
+ };
2034
+
2035
+ router.post("/auth/forgot-password", forgotPasswordController, {
2036
+ middleware: [tokenIssueThrottleMiddleware()], // 3 / 1h per email + per IP
2037
+ });
2038
+ router.post("/auth/reset-password", resetPasswordController, {
2039
+ middleware: [tokenConsumeThrottleMiddleware()], // 10 failures / 15m per IP
2040
+ });
2041
+ ```
2042
+
2043
+ `resetPassword` writes the password, then calls `authService.revokeAllTokens(user)`: every refresh token is revoked and every access token deleted, which also kills cookie sessions (a cookie holds an access token). It emits `password.reset`; `requestPasswordReset` emits `password.resetRequested`.
2044
+
2045
+ **Password hashing.** By default auth saves `hashPassword(plain)` and checks it with `verifyPassword`, the same check login uses. If your model hashes on save (`useHashedPassword()`), that first write gets hashed twice and fails the check, so auth saves the plaintext and lets your transformer hash it once. If neither write passes the check, it throws. Plaintext is never the first thing written. To write it yourself, set `auth.passwordReset.setPassword`.
2046
+
2047
+ ## Throttling
2048
+
2049
+ Both presets wrap `loginThrottleMiddleware` and accept its options:
2050
+
2051
+ | Preset | Tracks | Counts | Default |
2052
+ | --- | --- | --- | --- |
2053
+ | `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 |
2054
+ | `tokenConsumeThrottleMiddleware` | IP | failures only, cleared on success | 10 / 15m, 15m lock |
2055
+
2056
+ ## Configuration (`src/config/auth.ts`)
2057
+
2058
+ ```ts
2059
+ const authConfigurations: AuthConfigurations = {
2060
+ // ...
2061
+ verification: {
2062
+ expiresIn: "24h",
2063
+ field: "emailVerifiedAt",
2064
+ url: (token) => `${env("APP_URL")}/verify-email?token=${token}`,
2065
+ notification: myVerificationNotification, // optional replacement
2066
+ },
2067
+ passwordReset: {
2068
+ expiresIn: "60m",
2069
+ identifierField: "email",
2070
+ url: (token) => `${env("APP_URL")}/reset-password?token=${token}`,
2071
+ notification: myResetNotification,
2072
+ setPassword: async (user, plain) => { /* optional */ },
2073
+ },
2074
+ oneTimeToken: { model: AppOneTimeToken }, // optional model override
2075
+ };
2076
+ ```
2077
+
2078
+ The default notifications are `defineNotification` objects (`type: "auth.email-verification"` / `"auth.password-reset"`, `via: ["mail"]`). To replace one, pass anything with a `send(user, { token, expiresAt, url? })` method, for example your own `defineNotification<OneTimeTokenNotificationData>({...})`. `notifications` still has to be installed and configured.
2079
+
2080
+ ## Gotchas
2081
+
2082
+ - **Without `url` config, the email contains the raw token**, not a link. Set `url` for anything user-facing.
2083
+ - **Timing isn't equalised.** An unknown email skips the database write and the send, so the response can come back measurably faster. The throttle limits probing, but it doesn't hide the timing difference.
2084
+ - **Channel send failures don't throw.** `@warlock.js/notifications` only rethrows configuration errors (such as a missing `mail` channel). An SMTP failure goes to its `failed` event, so observe it there.
2085
+ - `auth.cleanup` hard-deletes expired and consumed `one_time_tokens` rows (never trashed, even on MongoDB).
2086
+
2087
+ ## See also
2088
+
2089
+ - [`@warlock.js/auth/register-user/SKILL.md`](@warlock.js/auth/register-user/SKILL.md) — where `sendEmailVerification` is usually called
2090
+ - [`@warlock.js/auth/throttle-login-attempts/SKILL.md`](@warlock.js/auth/throttle-login-attempts/SKILL.md) — the underlying throttle
2091
+ - [`@warlock.js/notifications/configure-notifications/SKILL.md`](@warlock.js/notifications/configure-notifications/SKILL.md) — mail channel setup
2091
2092
 
2092
2093