@warlock.js/auth 5.15.0 → 5.17.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.
Files changed (94) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/esm/commands/auth-cleanup-command.mjs +1 -1
  3. package/esm/commands/auth-purge-never-expiring-command.mjs +1 -1
  4. package/esm/contracts/index.d.mts +1 -1
  5. package/esm/contracts/providers.d.mts +59 -1
  6. package/esm/contracts/providers.d.mts.map +1 -1
  7. package/esm/index.d.mts +11 -2
  8. package/esm/index.mjs +11 -1
  9. package/esm/localization/ar.translations.mjs +16 -0
  10. package/esm/localization/ar.translations.mjs.map +1 -0
  11. package/esm/localization/auth-default-translations.d.mts +10 -0
  12. package/esm/localization/auth-default-translations.d.mts.map +1 -0
  13. package/esm/localization/auth-default-translations.mjs +15 -0
  14. package/esm/localization/auth-default-translations.mjs.map +1 -0
  15. package/esm/localization/auth-translations.type.d.mts +27 -0
  16. package/esm/localization/auth-translations.type.d.mts.map +1 -0
  17. package/esm/localization/en.translations.mjs +16 -0
  18. package/esm/localization/en.translations.mjs.map +1 -0
  19. package/esm/localization/index.mjs +4 -0
  20. package/esm/localization/init.mjs +8 -0
  21. package/esm/localization/init.mjs.map +1 -0
  22. package/esm/localization/register-auth-translations.d.mts +17 -0
  23. package/esm/localization/register-auth-translations.d.mts.map +1 -0
  24. package/esm/localization/register-auth-translations.mjs +29 -0
  25. package/esm/localization/register-auth-translations.mjs.map +1 -0
  26. package/esm/middleware/auth.middleware.d.mts.map +1 -1
  27. package/esm/middleware/auth.middleware.mjs +4 -2
  28. package/esm/middleware/auth.middleware.mjs.map +1 -1
  29. package/esm/middleware/csrf-origin-check.d.mts +9 -0
  30. package/esm/middleware/csrf-origin-check.d.mts.map +1 -1
  31. package/esm/middleware/csrf-origin-check.mjs +12 -56
  32. package/esm/middleware/csrf-origin-check.mjs.map +1 -1
  33. package/esm/middleware/localized-login-path.mjs +48 -0
  34. package/esm/middleware/localized-login-path.mjs.map +1 -0
  35. package/esm/models/access-token/access-token.model.d.mts +7 -0
  36. package/esm/models/access-token/access-token.model.d.mts.map +1 -1
  37. package/esm/models/access-token/access-token.model.mjs +3 -0
  38. package/esm/models/access-token/access-token.model.mjs.map +1 -1
  39. package/esm/models/one-time-token/one-time-token.model.d.mts +6 -0
  40. package/esm/models/one-time-token/one-time-token.model.d.mts.map +1 -1
  41. package/esm/models/one-time-token/one-time-token.model.mjs +3 -0
  42. package/esm/models/one-time-token/one-time-token.model.mjs.map +1 -1
  43. package/esm/models/refresh-token/refresh-token.model.d.mts +7 -0
  44. package/esm/models/refresh-token/refresh-token.model.d.mts.map +1 -1
  45. package/esm/models/refresh-token/refresh-token.model.mjs +3 -0
  46. package/esm/models/refresh-token/refresh-token.model.mjs.map +1 -1
  47. package/esm/providers/apple-provider.d.mts +74 -0
  48. package/esm/providers/apple-provider.d.mts.map +1 -0
  49. package/esm/providers/apple-provider.mjs +157 -0
  50. package/esm/providers/apple-provider.mjs.map +1 -0
  51. package/esm/providers/discord-provider.d.mts +25 -0
  52. package/esm/providers/discord-provider.d.mts.map +1 -0
  53. package/esm/providers/discord-provider.mjs +84 -0
  54. package/esm/providers/discord-provider.mjs.map +1 -0
  55. package/esm/providers/facebook-provider.d.mts +36 -0
  56. package/esm/providers/facebook-provider.d.mts.map +1 -0
  57. package/esm/providers/facebook-provider.mjs +94 -0
  58. package/esm/providers/facebook-provider.mjs.map +1 -0
  59. package/esm/providers/github-provider.d.mts +31 -0
  60. package/esm/providers/github-provider.d.mts.map +1 -0
  61. package/esm/providers/github-provider.mjs +115 -0
  62. package/esm/providers/github-provider.mjs.map +1 -0
  63. package/esm/providers/index.d.mts +7 -1
  64. package/esm/providers/index.mjs +6 -0
  65. package/esm/providers/linkedin-provider.d.mts +35 -0
  66. package/esm/providers/linkedin-provider.d.mts.map +1 -0
  67. package/esm/providers/linkedin-provider.mjs +108 -0
  68. package/esm/providers/linkedin-provider.mjs.map +1 -0
  69. package/esm/providers/provider-login.d.mts +2 -1
  70. package/esm/providers/provider-login.d.mts.map +1 -1
  71. package/esm/providers/provider-login.mjs +47 -7
  72. package/esm/providers/provider-login.mjs.map +1 -1
  73. package/esm/providers/provider-state-cookie.mjs +35 -16
  74. package/esm/providers/provider-state-cookie.mjs.map +1 -1
  75. package/esm/providers/x-provider.d.mts +35 -0
  76. package/esm/providers/x-provider.d.mts.map +1 -0
  77. package/esm/providers/x-provider.mjs +101 -0
  78. package/esm/providers/x-provider.mjs.map +1 -0
  79. package/esm/services/auth-config.mjs +16 -1
  80. package/esm/services/auth-config.mjs.map +1 -1
  81. package/esm/services/auth.service.mjs +1 -1
  82. package/esm/services/generate-jwt-secret.mjs +1 -1
  83. package/llms-full.txt +1075 -765
  84. package/llms.txt +5 -5
  85. package/package.json +8 -8
  86. package/skills/auth-basics/SKILL.md +16 -1
  87. package/skills/customize-token-storage/SKILL.md +160 -122
  88. package/skills/customize-user-type/SKILL.md +168 -142
  89. package/skills/handle-login-and-logout/SKILL.md +179 -165
  90. package/skills/login-with-providers/SKILL.md +131 -2
  91. package/skills/protect-routes/SKILL.md +308 -249
  92. package/skills/register-user/SKILL.md +15 -1
  93. package/skills/run-auth-commands/SKILL.md +16 -1
  94. package/skills/throttle-login-attempts/SKILL.md +1 -1
package/llms-full.txt CHANGED
@@ -35,6 +35,20 @@ npm install @warlock.js/auth
35
35
 
36
36
  ## Minimal wire-up
37
37
 
38
+ ```ts title="src/app/users/models/user/user.model.ts"
39
+ import { Auth } from "@warlock.js/auth";
40
+ import { RegisterModel } from "@warlock.js/cascade";
41
+
42
+ @RegisterModel()
43
+ export class User extends Auth {
44
+ public static table = "users";
45
+
46
+ public get userType(): string {
47
+ return "user";
48
+ }
49
+ }
50
+ ```
51
+
38
52
  ```ts title="warlock.config.ts"
39
53
  import {
40
54
  authMigrations,
@@ -57,7 +71,8 @@ export default defineConfig({
57
71
  ```
58
72
 
59
73
  ```ts title="src/config/auth.ts"
60
- import { User } from "@/app/users/models/user.model";
74
+ import { env } from "@warlock.js/core";
75
+ import { User } from "app/users/models/user/user.model";
61
76
 
62
77
  export default {
63
78
  userType: {
@@ -114,453 +129,531 @@ Two values that look right and are not:
114
129
 
115
130
  ## customize-token-storage `@warlock.js/auth/customize-token-storage/SKILL.md`
116
131
 
117
- ---
118
- name: customize-token-storage
119
- 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`.'
120
- ---
121
-
122
- # Customize token storage
123
-
124
- 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.
125
-
126
- 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.
127
-
128
- ## The three pieces
129
-
130
- A storage override is always three coordinated steps. Miss any one and it breaks — see the strict-mode note below.
131
-
132
- ### 1. Extend the model + its schema
133
-
134
- `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).
135
-
136
- ```ts title="src/app/auth/models/app-access-token.ts"
137
- import { AccessToken, accessTokenSchema, type Auth } from "@warlock.js/auth";
138
- import { v } from "@warlock.js/seal";
139
-
140
- export class AppAccessToken extends AccessToken {
141
- public static schema = accessTokenSchema.extend({
142
- organization_id: v.string().exists("Organization", { column: "id" }),
143
- });
144
-
145
- // populate the tenant key on issue — read it off the authenticating user
146
- public static issue(user: Auth, token: string, expiresAt: Date) {
147
- return this.create({
148
- token,
149
- user_id: user.id,
150
- user_type: user.userType,
151
- expires_at: expiresAt,
152
- organization_id: user.get("organization_id"),
153
- });
154
- }
155
- }
156
- ```
157
-
158
- 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.
159
-
160
- ### 2. Register the subclass in config
161
-
162
- ```ts title="src/config/auth.ts"
163
- import { AppAccessToken } from "app/auth/models/app-access-token";
164
- import { AppRefreshToken } from "app/auth/models/app-refresh-token";
165
-
166
- export default {
167
- userType: { user: User },
168
- accessToken: {
169
- model: AppAccessToken, // ← the override
170
- secret: env("JWT_SECRET"),
171
- expiresIn: "1h",
172
- },
173
- refreshToken: {
174
- model: AppRefreshToken, // ← the override
175
- secret: env("JWT_REFRESH_SECRET"),
176
- expiresIn: "30d",
177
- },
178
- };
179
- ```
180
-
181
- 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.
182
-
183
- ### 3. Add the column to the migration
184
-
185
- 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):
186
-
187
- ```ts
188
- this.uuid("organization_id").references("organizations").onDelete("cascade").index();
189
- ```
190
-
191
- See [`@warlock.js/cascade/write-migration/SKILL.md`](@warlock.js/cascade/write-migration/SKILL.md) for the migration mechanics.
192
-
193
- ## Why all three — the strict-mode trap
194
-
195
- 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.
196
-
197
- ## You do NOT need read-side scoping
198
-
199
- 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.
200
-
201
- ## What you're overriding (the contract)
202
-
203
- Your subclass inherits and may override these statics — the service calls them, never raw queries:
204
-
205
- | Static | Role |
206
- | --- | --- |
207
- | `issue(user, token, …)` | persist a freshly-signed token (override to add columns) |
208
- | `findByToken(token)` | look a row up by its token string |
209
- | `findForUser(user, token)` | a user-scoped lookup (logout) |
210
- | `activeFor(user)` | active, unexpired sessions, newest-first |
211
- | `revokeAllFor(user)` / `revokeFamily(id)` | revoke a set, returning the revoked rows |
212
- | `enforceMax(user, max)` | cap concurrent refresh tokens |
213
- | `purgeExpired()` | delete expired rows (CLI cleanup) |
214
- | `findNeverExpiring()` / `purgeNeverExpiring()` | find/delete rows that can never expire (CLI remediation) |
215
-
216
- Plus two instance getters, both of which the route gate consults on every request:
217
-
218
- | Getter | Role |
219
- | --- | --- |
220
- | `isExpired` | whether the row's persisted expiry has passed — **the middleware rejects the request when this is `true`** |
221
- | `neverExpires` | whether nothing can ever retire the row (unusable `expires_at`, or a token with no `exp` claim) |
222
-
223
- 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.
224
-
225
- ⚠️ **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.
226
-
227
- ## Things NOT to do
228
-
229
- - **Don't set a column without declaring it in `schema.extend(...)`.** `strictMode: "strip"` removes it; a NOT-NULL column then fails the INSERT.
230
- - **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.
231
- - **Don't fork the package to add a column.** Extend + register — you keep receiving package fixes.
232
- - **Don't forget the migration.** The schema declares the field; only the migration creates the database column.
233
-
234
- ## See also
235
-
236
- - [`@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*).
237
- - [`@warlock.js/auth/manage-tokens/SKILL.md`](@warlock.js/auth/manage-tokens/SKILL.md) — the token lifecycle your statics power.
238
- - [`@warlock.js/cascade/define-model/SKILL.md`](@warlock.js/cascade/define-model/SKILL.md) — extending models and schemas.
132
+ ---
133
+ name: customize-token-storage
134
+ 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`.'
135
+ ---
136
+
137
+ # Customize token storage
138
+
139
+ 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.
140
+
141
+ 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.
142
+
143
+ ```ts title="src/app/users/models/user/user.model.ts"
144
+ import { Auth } from "@warlock.js/auth";
145
+ import { RegisterModel } from "@warlock.js/cascade";
146
+
147
+ @RegisterModel()
148
+ export class User extends Auth {
149
+ public static table = "users";
150
+
151
+ public get userType(): string {
152
+ return "user";
153
+ }
154
+ }
155
+ ```
156
+
157
+ ## The three pieces
158
+
159
+ A storage override is always three coordinated steps. Miss any one and it breaks — see the strict-mode note below.
160
+
161
+ ### 1. Extend the model + its schema
162
+
163
+ `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).
164
+
165
+ ```ts title="src/app/auth/models/app-access-token.ts"
166
+ import { AccessToken, accessTokenSchema, type Auth } from "@warlock.js/auth";
167
+ import { v } from "@warlock.js/seal";
168
+
169
+ export class AppAccessToken extends AccessToken {
170
+ public static schema = accessTokenSchema.extend({
171
+ organization_id: v.string().exists("Organization", { column: "id" }),
172
+ });
173
+
174
+ // populate the tenant key on issue — read it off the authenticating user
175
+ public static issue(user: Auth, token: string, expiresAt: Date) {
176
+ return this.create({
177
+ token,
178
+ user_id: user.id,
179
+ user_type: user.userType,
180
+ expires_at: expiresAt,
181
+ organization_id: user.get("organization_id"),
182
+ });
183
+ }
184
+ }
185
+ ```
186
+
187
+ 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.
188
+
189
+ ```ts title="src/app/auth/models/app-refresh-token.ts"
190
+ import { RefreshToken, refreshTokenSchema, type Auth, type RefreshTokenIssueOptions } from "@warlock.js/auth";
191
+ import { v } from "@warlock.js/seal";
192
+
193
+ export class AppRefreshToken extends RefreshToken {
194
+ public static schema = refreshTokenSchema.extend({
195
+ organization_id: v.string().exists("Organization", { column: "id" }),
196
+ });
197
+
198
+ public static issue(user: Auth, token: string, options: RefreshTokenIssueOptions) {
199
+ return this.create({
200
+ token,
201
+ user_id: user.id,
202
+ user_type: user.userType,
203
+ family_id: options.familyId,
204
+ expires_at: options.expiresAt,
205
+ organization_id: user.get("organization_id"),
206
+ });
207
+ }
208
+ }
209
+ ```
210
+
211
+ ### 2. Register the subclass in config
212
+
213
+ ```ts title="src/config/auth.ts"
214
+ import { env } from "@warlock.js/core";
215
+ import { User } from "app/users/models/user/user.model";
216
+ import { AppAccessToken } from "app/auth/models/app-access-token";
217
+ import { AppRefreshToken } from "app/auth/models/app-refresh-token";
218
+
219
+ export default {
220
+ userType: { user: User },
221
+ accessToken: {
222
+ model: AppAccessToken, // ← the override
223
+ secret: env("JWT_SECRET"),
224
+ expiresIn: "1h",
225
+ },
226
+ refreshToken: {
227
+ model: AppRefreshToken, // ← the override
228
+ secret: env("JWT_REFRESH_SECRET"),
229
+ expiresIn: "30d",
230
+ },
231
+ };
232
+ ```
233
+
234
+ 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.
235
+
236
+ ### 3. Add the column to the migration
237
+
238
+ 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):
239
+
240
+ ```ts
241
+ this.uuid("organization_id").references("organizations").onDelete("cascade").index();
242
+ ```
243
+
244
+ See [`@warlock.js/cascade/write-migration/SKILL.md`](@warlock.js/cascade/write-migration/SKILL.md) for the migration mechanics.
245
+
246
+ ## Why all three — the strict-mode trap
247
+
248
+ 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.
249
+
250
+ ## You do NOT need read-side scoping
251
+
252
+ 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.
253
+
254
+ ## What you're overriding (the contract)
255
+
256
+ Your subclass inherits and may override these statics — the service calls them, never raw queries:
257
+
258
+ | Static | Role |
259
+ | --- | --- |
260
+ | `issue(user, token, …)` | persist a freshly-signed token (override to add columns) |
261
+ | `findByToken(token)` | look a row up by its token string |
262
+ | `findForUser(user, token)` | a user-scoped lookup (logout) |
263
+ | `activeFor(user)` | active, unexpired sessions, newest-first |
264
+ | `revokeAllFor(user)` / `revokeFamily(id)` | revoke a set, returning the revoked rows |
265
+ | `enforceMax(user, max)` | cap concurrent refresh tokens |
266
+ | `purgeExpired()` | delete expired rows (CLI cleanup) |
267
+ | `findNeverExpiring()` / `purgeNeverExpiring()` | find/delete rows that can never expire (CLI remediation) |
268
+
269
+ Plus two instance getters, both of which the route gate consults on every request:
270
+
271
+ | Getter | Role |
272
+ | --- | --- |
273
+ | `isExpired` | whether the row's persisted expiry has passed — **the middleware rejects the request when this is `true`** |
274
+ | `neverExpires` | whether nothing can ever retire the row (unusable `expires_at`, or a token with no `exp` claim) |
275
+
276
+ 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.
277
+
278
+ ⚠️ **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.
279
+
280
+ ## Things NOT to do
281
+
282
+ - **Don't set a column without declaring it in `schema.extend(...)`.** `strictMode: "strip"` removes it; a NOT-NULL column then fails the INSERT.
283
+ - **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.
284
+ - **Don't fork the package to add a column.** Extend + register — you keep receiving package fixes.
285
+ - **Don't forget the migration.** The schema declares the field; only the migration creates the database column.
286
+
287
+ ## See also
288
+
289
+ - [`@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*).
290
+ - [`@warlock.js/auth/manage-tokens/SKILL.md`](@warlock.js/auth/manage-tokens/SKILL.md) — the token lifecycle your statics power.
291
+ - [`@warlock.js/cascade/define-model/SKILL.md`](@warlock.js/cascade/define-model/SKILL.md) — extending models and schemas.
239
292
 
240
293
 
241
294
  ## customize-user-type `@warlock.js/auth/customize-user-type/SKILL.md`
242
295
 
243
- ---
244
- name: customize-user-type
245
- 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`.'
246
- ---
247
-
248
- # Customize user type (multi-user-type auth)
249
-
250
- 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.
251
-
252
- ## Define a model per user type
253
-
254
- ```ts title="src/app/users/models/user/user.model.ts"
255
- import { Auth } from "@warlock.js/auth";
256
- import { RegisterModel } from "@warlock.js/cascade";
257
-
258
- @RegisterModel()
259
- export class User extends Auth<UserSchema> {
260
- public static table = "users";
261
- public static schema = userSchema;
262
-
263
- public get userType(): string {
264
- return "user";
265
- }
266
- }
267
- ```
268
-
269
- ```ts title="src/app/admins/models/admin/admin.model.ts"
270
- @RegisterModel()
271
- export class Admin extends Auth<AdminSchema> {
272
- public static table = "admins";
273
- public static schema = adminSchema;
274
-
275
- public get userType(): string {
276
- return "admin";
277
- }
278
- }
279
- ```
280
-
281
- Each gets its own table, its own schema, its own `userType` slug. They DON'T share table — they're separate models.
282
-
283
- ## Register them in `config.auth`
284
-
285
- ```ts title="src/config/auth.ts"
286
- import { User } from "@/app/users/models/user.model";
287
- import { Admin } from "@/app/admins/models/admin.model";
288
-
289
- export default {
290
- userType: {
291
- user: User,
292
- admin: Admin,
293
- // staff: Staff,
294
- // client: Client,
295
- },
296
- accessToken: {
297
- secret: env("JWT_SECRET"),
298
- expiresIn: "1h",
299
- },
300
- refreshToken: {
301
- secret: env("JWT_REFRESH_SECRET"),
302
- enabled: true,
303
- expiresIn: "30d",
304
- rotation: true,
305
- },
306
- };
307
- ```
308
-
309
- The keys (`"user"`, `"admin"`) are the **userType slugs** that flow through every token, middleware call, and event payload.
310
-
311
- ## Gate routes per user type
312
-
313
- ```ts
314
- import { authMiddleware } from "@warlock.js/auth";
315
-
316
- router.get("/account", userAccountController, { middleware: [authMiddleware("user")] });
317
- router.get("/admin/users", listUsersController, { middleware: [authMiddleware("admin")] });
318
- router.get("/back-office", backOfficeController, { middleware: [authMiddleware(["admin", "staff"])] });
319
- router.get("/dashboard", dashboardController, { middleware: [authMiddleware([])] }); // any logged-in
320
- ```
321
-
322
- See [`@warlock.js/auth/protect-routes/SKILL.md`](@warlock.js/auth/protect-routes/SKILL.md).
323
-
324
- ## Login per user type — pass the right Model
325
-
326
- `authService.login(Model, credentials, deviceInfo?)` is keyed off the model you pass:
327
-
328
- ```ts
329
- // User login endpoint
330
- const result = await authService.login(User, credentials);
331
- // Issues tokens with userType "user"; middleware will route them to User model.
332
-
333
- // Admin login endpoint
334
- const result = await authService.login(Admin, credentials);
335
- // Issues tokens with userType "admin"; middleware will route them to Admin model.
336
- ```
337
-
338
- The middleware then uses `config.auth.userType[token.userType]` to know which model to hydrate.
339
-
340
- ## Cross-type behavior
341
-
342
- - **Tokens are scoped to their issuing user-type.** A user-type token doesn't unlock admin-type routes.
343
- - **AccessToken / RefreshToken rows carry the `user_type` column.** Same model classes, different rows per type.
344
- - **`authMiddleware(["admin", "user"])`** allows either — useful for endpoints shared between roles.
345
-
346
- ## When NOT to use multi-user-type
347
-
348
- 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:
349
-
350
- - Different tables / schemas (admins have an `admin_level`; users have a `subscription_tier`).
351
- - Separate registration flows (admins are created via an admin panel; users self-register).
352
- - Truly separate concepts at the data layer (clients vs vendors in a marketplace).
353
-
354
- If users and admins differ only in a `role` field, stick with one `User` model + a role check at the controller layer.
355
-
356
- ## `Auth` base — what your subclass inherits
357
-
358
- ```ts
359
- abstract class Auth<TSchema> extends Model<TSchema> implements Authenticable {
360
- // ...all the Model<> methods
361
- public abstract get userType(): string;
362
- public generateAccessToken(payload?: Record<string, unknown>): Promise<AccessTokenOutput>;
363
- public generateRefreshToken(deviceInfo?: DeviceInfo): Promise<RefreshToken | undefined>;
364
- public createTokenPair(deviceInfo?: DeviceInfo): Promise<TokenPair>;
365
- public confirmPassword(password: string): Promise<boolean>;
366
- }
367
- ```
368
-
369
- `userType` is the only required override (an abstract getter — return the type slug). Override `generateAccessToken` if you need a non-default payload.
370
-
371
- `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).
372
-
373
- ## Things NOT to do
374
-
375
- - 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.
376
- - 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.
377
- - Don't reuse the same `userType` slug across two models — the `config.auth.userType` map can only point one slug at one model.
378
- - 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.
379
-
380
- ## See also
381
-
382
- - [`@warlock.js/auth/protect-routes/SKILL.md`](@warlock.js/auth/protect-routes/SKILL.md) — `authMiddleware` semantics
383
- - [`@warlock.js/auth/handle-login-and-logout/SKILL.md`](@warlock.js/auth/handle-login-and-logout/SKILL.md) — passing the right Model to `login`
384
- - [`@warlock.js/cascade/define-model/SKILL.md`](@warlock.js/cascade/define-model/SKILL.md) — `@RegisterModel`, models in general
296
+ ---
297
+ name: customize-user-type
298
+ 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`.'
299
+ ---
300
+
301
+ # Customize user type (multi-user-type auth)
302
+
303
+ 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.
304
+
305
+ ## Define a model per user type
306
+
307
+ ```ts title="src/app/users/models/user/user.model.ts"
308
+ import { Auth } from "@warlock.js/auth";
309
+ import { RegisterModel } from "@warlock.js/cascade";
310
+ import { v, type Infer } from "@warlock.js/seal";
311
+
312
+ export const userSchema = v.object({
313
+ email: v.string().email(),
314
+ password: v.string(),
315
+ });
316
+
317
+ export type UserSchema = Infer<typeof userSchema>;
318
+
319
+ @RegisterModel()
320
+ export class User extends Auth<UserSchema> {
321
+ public static table = "users";
322
+ public static schema = userSchema;
323
+
324
+ public get userType(): string {
325
+ return "user";
326
+ }
327
+ }
328
+ ```
329
+
330
+ ```ts title="src/app/admins/models/admin.model.ts"
331
+ import { Auth } from "@warlock.js/auth";
332
+ import { RegisterModel } from "@warlock.js/cascade";
333
+ import { v, type Infer } from "@warlock.js/seal";
334
+
335
+ export const adminSchema = v.object({
336
+ email: v.string().email(),
337
+ password: v.string(),
338
+ });
339
+
340
+ export type AdminSchema = Infer<typeof adminSchema>;
341
+
342
+ @RegisterModel()
343
+ export class Admin extends Auth<AdminSchema> {
344
+ public static table = "admins";
345
+ public static schema = adminSchema;
346
+
347
+ public get userType(): string {
348
+ return "admin";
349
+ }
350
+ }
351
+ ```
352
+
353
+ Each gets its own table, its own schema, its own `userType` slug. They DON'T share table — they're separate models.
354
+
355
+ ## Register them in `config.auth`
356
+
357
+ ```ts title="src/config/auth.ts"
358
+ import { env } from "@warlock.js/core";
359
+ import { User } from "app/users/models/user/user.model";
360
+ import { Admin } from "app/admins/models/admin.model";
361
+
362
+ export default {
363
+ userType: {
364
+ user: User,
365
+ admin: Admin,
366
+ // staff: Staff,
367
+ // client: Client,
368
+ },
369
+ accessToken: {
370
+ secret: env("JWT_SECRET"),
371
+ expiresIn: "1h",
372
+ },
373
+ refreshToken: {
374
+ secret: env("JWT_REFRESH_SECRET"),
375
+ enabled: true,
376
+ expiresIn: "30d",
377
+ rotation: true,
378
+ },
379
+ };
380
+ ```
381
+
382
+ The keys (`"user"`, `"admin"`) are the **userType slugs** that flow through every token, middleware call, and event payload.
383
+
384
+ ## Gate routes per user type
385
+
386
+ ```ts
387
+ import { router, type RequestHandler } from "@warlock.js/core";
388
+ import { authMiddleware } from "@warlock.js/auth";
389
+
390
+ const userAccountController: RequestHandler = async ({ response }) => response.success({});
391
+ const listUsersController: RequestHandler = async ({ response }) => response.success({});
392
+ const backOfficeController: RequestHandler = async ({ response }) => response.success({});
393
+ const dashboardController: RequestHandler = async ({ response }) => response.success({});
394
+
395
+ router.get("/account", userAccountController, { middleware: [authMiddleware("user")] });
396
+ router.get("/admin/users", listUsersController, { middleware: [authMiddleware("admin")] });
397
+ router.get("/back-office", backOfficeController, { middleware: [authMiddleware(["admin", "staff"])] });
398
+ router.get("/dashboard", dashboardController, { middleware: [authMiddleware([])] }); // any logged-in
399
+ ```
400
+
401
+ See [`@warlock.js/auth/protect-routes/SKILL.md`](@warlock.js/auth/protect-routes/SKILL.md).
402
+
403
+ ## Login per user type — pass the right Model
404
+
405
+ `authService.login(Model, credentials, deviceInfo?)` is keyed off the model you pass:
406
+
407
+ ```ts
408
+ // User login endpoint
409
+ const result = await authService.login(User, credentials);
410
+ // Issues tokens with userType "user"; middleware will route them to User model.
411
+
412
+ // Admin login endpoint
413
+ const result = await authService.login(Admin, credentials);
414
+ // Issues tokens with userType "admin"; middleware will route them to Admin model.
415
+ ```
416
+
417
+ The middleware then uses `config.auth.userType[token.userType]` to know which model to hydrate.
418
+
419
+ ## Cross-type behavior
420
+
421
+ - **Tokens are scoped to their issuing user-type.** A user-type token doesn't unlock admin-type routes.
422
+ - **AccessToken / RefreshToken rows carry the `user_type` column.** Same model classes, different rows per type.
423
+ - **`authMiddleware(["admin", "user"])`** allows either — useful for endpoints shared between roles.
424
+
425
+ ## When NOT to use multi-user-type
426
+
427
+ 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:
428
+
429
+ - Different tables / schemas (admins have an `admin_level`; users have a `subscription_tier`).
430
+ - Separate registration flows (admins are created via an admin panel; users self-register).
431
+ - Truly separate concepts at the data layer (clients vs vendors in a marketplace).
432
+
433
+ If users and admins differ only in a `role` field, stick with one `User` model + a role check at the controller layer.
434
+
435
+ ## `Auth` base — what your subclass inherits
436
+
437
+ ```ts
438
+ abstract class Auth<TSchema> extends Model<TSchema> implements Authenticable {
439
+ // ...all the Model<> methods
440
+ public abstract get userType(): string;
441
+ public generateAccessToken(payload?: Record<string, unknown>): Promise<AccessTokenOutput>;
442
+ public generateRefreshToken(deviceInfo?: DeviceInfo): Promise<RefreshToken | undefined>;
443
+ public createTokenPair(deviceInfo?: DeviceInfo): Promise<TokenPair>;
444
+ public confirmPassword(password: string): Promise<boolean>;
445
+ }
446
+ ```
447
+
448
+ `userType` is the only required override (an abstract getter — return the type slug). Override `generateAccessToken` if you need a non-default payload.
449
+
450
+ `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).
451
+
452
+ ## Things NOT to do
453
+
454
+ - 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.
455
+ - 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.
456
+ - Don't reuse the same `userType` slug across two models — the `config.auth.userType` map can only point one slug at one model.
457
+ - 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.
458
+
459
+ ## See also
460
+
461
+ - [`@warlock.js/auth/protect-routes/SKILL.md`](@warlock.js/auth/protect-routes/SKILL.md) — `authMiddleware` semantics
462
+ - [`@warlock.js/auth/handle-login-and-logout/SKILL.md`](@warlock.js/auth/handle-login-and-logout/SKILL.md) — passing the right Model to `login`
463
+ - [`@warlock.js/cascade/define-model/SKILL.md`](@warlock.js/cascade/define-model/SKILL.md) — `@RegisterModel`, models in general
385
464
 
386
465
 
387
466
  ## handle-login-and-logout `@warlock.js/auth/handle-login-and-logout/SKILL.md`
388
467
 
389
- ---
390
- name: handle-login-and-logout
391
- 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.'
392
- ---
393
-
394
- # Login + logout
395
-
396
- `authService` exposes the full flow. Pass the model class so the service knows which user-type to look up.
397
-
398
- ## Login — `authService.login(Model, credentials, deviceInfo?)`
399
-
400
- ```ts
401
- import { authService } from "@warlock.js/auth";
402
- import { type RequestHandler } from "@warlock.js/core";
403
- import { User } from "@/app/users/models/user.model";
404
-
405
- export const loginController: RequestHandler = async ({ request, response }) => {
406
- const result = await authService.login(User, {
407
- email: request.input("email"),
408
- password: request.input("password"),
409
- }, {
410
- userAgent: request.header("user-agent"),
411
- ip: request.ip,
412
- });
413
-
414
- if (!result) {
415
- return response.unauthorized({ error: "Invalid credentials" });
416
- }
417
-
418
- return response.success({
419
- user: result.user,
420
- tokens: result.tokens,
421
- });
422
- };
423
- ```
424
-
425
- The returned shape:
426
-
427
- ```ts
428
- {
429
- user: T, // your User subclass, hydrated
430
- tokens: {
431
- accessToken: { token: string, expiresAt: string },
432
- refreshToken?: { token: string, expiresAt: string }, // omitted if refresh tokens disabled
433
- },
434
- }
435
- ```
436
-
437
- 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.
438
-
439
- ## What `credentials` looks like
440
-
441
- The shape is **arbitrary** — every key except `password` is used as a `where(...)` filter against the model. The password is verified separately via bcrypt.
442
-
443
- ```ts
444
- // Email + password
445
- authService.login(User, { email: "ada@example.com", password: "..." });
446
-
447
- // Username + password
448
- authService.login(User, { username: "ada", password: "..." });
449
-
450
- // Phone-based OTP (where password is the OTP hash)
451
- authService.login(User, { phone: "+1...", password: hashedOTP });
452
- ```
453
-
454
- For lower-level credential verification (just check, don't issue tokens), use `authService.attemptLogin(Model, credentials)` — returns the user or null without creating tokens.
455
-
456
- ## Device info
457
-
458
- The optional `deviceInfo` carries metadata into the refresh token row:
459
-
460
- ```ts
461
- authService.login(User, credentials, {
462
- userAgent: request.header("user-agent"),
463
- ip: request.ip,
464
- deviceId: "...", // your client-side device fingerprint
465
- familyId: "...", // pre-existing family for token rotation, usually omitted
466
- });
467
- ```
468
-
469
- Useful for "show active sessions" UIs — see `authService.getActiveSessions(user)`.
470
-
471
- ## Logout — `authService.logout(user, accessToken?, refreshToken?)`
472
-
473
- ```ts
474
- import { type RequestHandler } from "@warlock.js/core";
475
-
476
- export const logoutController: RequestHandler = async ({ request, response }) => {
477
- await authService.logout(
478
- request.locals.user!,
479
- request.authorizationValue, // access token from the Authorization header
480
- request.input("refreshToken"), // refresh token from the request body
481
- );
482
-
483
- return response.success({ message: "Logged out" });
484
- };
485
- ```
486
-
487
- The contract:
488
- - **Pass the access token** → that specific access-token row is deleted.
489
- - **Pass the refresh token** → that specific refresh-token row is revoked.
490
- - **Omit refresh token** → behavior depends on `config.auth.refreshToken.logoutWithoutToken`:
491
- - `"revoke-all"` (default) — every refresh token for this user is revoked. Fail-safe.
492
- - `"error"` — throws. Force the client to send the refresh token.
493
-
494
- 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.
495
-
496
- ## Logout-everywhere
497
-
498
- ```ts
499
- await authService.revokeAllTokens(user);
500
- // Revokes every refresh token + deletes every access token for this user.
501
- ```
502
-
503
- Useful for "logout from all devices" buttons. Fires `token.revoked` per token + `logout.all` once.
504
-
505
- ## Refresh tokens — `authService.refreshTokens(refreshTokenString, deviceInfo?)`
506
-
507
- ```ts
508
- import { type RequestHandler } from "@warlock.js/core";
509
-
510
- export const refreshController: RequestHandler = async ({ request, response }) => {
511
- const tokens = await authService.refreshTokens(
512
- request.input("refreshToken"),
513
- { userAgent: request.header("user-agent"), ip: request.ip },
514
- );
515
-
516
- if (!tokens) {
517
- return response.unauthorized({ error: "Invalid refresh token" });
518
- }
519
-
520
- return response.success({ tokens });
521
- };
522
- ```
523
-
524
- 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).
525
-
526
- ## Auth events
527
-
528
- `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`:
529
-
530
- ```ts
531
- import { authEvents } from "@warlock.js/auth";
532
-
533
- authEvents.on("login.success", (user, tokens, deviceInfo) => { /* audit */ });
534
- authEvents.on("login.failed", (credentials, reason) => { /* alert on brute force */ });
535
- authEvents.on("logout", (user) => { /* clear server-side session, if any */ });
536
- authEvents.on("token.refreshed", (user, newPair, oldToken) => { /* track rotation */ });
537
- authEvents.on("cleanup.completed", (count) => { /* metrics */ });
538
- ```
539
-
540
- 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`.
541
-
542
- ## Things NOT to do
543
-
544
- - 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.
545
- - Don't return the password hash in the response. `static toJsonColumns` on the User model should explicitly exclude it.
546
- - Don't store the refresh token in localStorage. Use an httpOnly secure cookie for refresh tokens; the access token can sit in memory.
547
- - 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.
548
-
549
- ## See also
550
-
551
- - [`@warlock.js/auth/manage-tokens/SKILL.md`](@warlock.js/auth/manage-tokens/SKILL.md) — token lifecycle, rotation, family revocation
552
- - [`@warlock.js/auth/register-user/SKILL.md`](@warlock.js/auth/register-user/SKILL.md) — sign-up that issues tokens after creation
553
- - [`@warlock.js/auth/protect-routes/SKILL.md`](@warlock.js/auth/protect-routes/SKILL.md) — where the access token gets consumed
468
+ ---
469
+ name: handle-login-and-logout
470
+ 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.'
471
+ ---
472
+
473
+ # Login + logout
474
+
475
+ `authService` exposes the full flow. Pass the model class so the service knows which user-type to look up.
476
+
477
+ ```ts title="src/app/users/models/user/user.model.ts"
478
+ import { Auth } from "@warlock.js/auth";
479
+ import { RegisterModel } from "@warlock.js/cascade";
480
+
481
+ @RegisterModel()
482
+ export class User extends Auth {
483
+ public static table = "users";
484
+
485
+ public get userType(): string {
486
+ return "user";
487
+ }
488
+ }
489
+ ```
490
+
491
+ ## Login — `authService.login(Model, credentials, deviceInfo?)`
492
+
493
+ ```ts
494
+ import { authService } from "@warlock.js/auth";
495
+ import { type RequestHandler } from "@warlock.js/core";
496
+ import { User } from "app/users/models/user/user.model";
497
+
498
+ export const loginController: RequestHandler = async ({ request, response }) => {
499
+ const result = await authService.login(User, {
500
+ email: request.input("email"),
501
+ password: request.input("password"),
502
+ }, {
503
+ userAgent: request.header("user-agent"),
504
+ ip: request.ip,
505
+ });
506
+
507
+ if (!result) {
508
+ return response.unauthorized({ error: "Invalid credentials" });
509
+ }
510
+
511
+ return response.success({
512
+ user: result.user,
513
+ tokens: result.tokens,
514
+ });
515
+ };
516
+ ```
517
+
518
+ The returned shape:
519
+
520
+ ```ts
521
+ {
522
+ user: T, // your User subclass, hydrated
523
+ tokens: {
524
+ accessToken: { token: string, expiresAt: string },
525
+ refreshToken?: { token: string, expiresAt: string }, // omitted if refresh tokens disabled
526
+ },
527
+ }
528
+ ```
529
+
530
+ 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.
531
+
532
+ ## What `credentials` looks like
533
+
534
+ The shape is **arbitrary** — every key except `password` is used as a `where(...)` filter against the model. The password is verified separately via bcrypt.
535
+
536
+ ```ts
537
+ // Email + password
538
+ authService.login(User, { email: "ada@example.com", password: "..." });
539
+
540
+ // Username + password
541
+ authService.login(User, { username: "ada", password: "..." });
542
+
543
+ // Phone-based OTP (where password is the OTP hash)
544
+ authService.login(User, { phone: "+1...", password: hashedOTP });
545
+ ```
546
+
547
+ For lower-level credential verification (just check, don't issue tokens), use `authService.attemptLogin(Model, credentials)` — returns the user or null without creating tokens.
548
+
549
+ ## Device info
550
+
551
+ The optional `deviceInfo` carries metadata into the refresh token row:
552
+
553
+ ```ts
554
+ authService.login(User, credentials, {
555
+ userAgent: request.header("user-agent"),
556
+ ip: request.ip,
557
+ deviceId: "...", // your client-side device fingerprint
558
+ familyId: "...", // pre-existing family for token rotation, usually omitted
559
+ });
560
+ ```
561
+
562
+ Useful for "show active sessions" UIs — see `authService.getActiveSessions(user)`.
563
+
564
+ ## Logout — `authService.logout(user, accessToken?, refreshToken?)`
565
+
566
+ ```ts
567
+ import { type RequestHandler } from "@warlock.js/core";
568
+
569
+ export const logoutController: RequestHandler = async ({ request, response }) => {
570
+ await authService.logout(
571
+ request.locals.user!,
572
+ request.authorizationValue, // access token from the Authorization header
573
+ request.input("refreshToken"), // refresh token from the request body
574
+ );
575
+
576
+ return response.success({ message: "Logged out" });
577
+ };
578
+ ```
579
+
580
+ The contract:
581
+ - **Pass the access token** → that specific access-token row is deleted.
582
+ - **Pass the refresh token** → that specific refresh-token row is revoked.
583
+ - **Omit refresh token** → behavior depends on `config.auth.refreshToken.logoutWithoutToken`:
584
+ - `"revoke-all"` (default) — every refresh token for this user is revoked. Fail-safe.
585
+ - `"error"` — throws. Force the client to send the refresh token.
586
+
587
+ 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.
588
+
589
+ ## Logout-everywhere
590
+
591
+ ```ts
592
+ await authService.revokeAllTokens(user);
593
+ // Revokes every refresh token + deletes every access token for this user.
594
+ ```
595
+
596
+ Useful for "logout from all devices" buttons. Fires `token.revoked` per token + `logout.all` once.
597
+
598
+ ## Refresh tokens — `authService.refreshTokens(refreshTokenString, deviceInfo?)`
599
+
600
+ ```ts
601
+ import { type RequestHandler } from "@warlock.js/core";
602
+
603
+ export const refreshController: RequestHandler = async ({ request, response }) => {
604
+ const tokens = await authService.refreshTokens(
605
+ request.input("refreshToken"),
606
+ { userAgent: request.header("user-agent"), ip: request.ip },
607
+ );
608
+
609
+ if (!tokens) {
610
+ return response.unauthorized({ error: "Invalid refresh token" });
611
+ }
612
+
613
+ return response.success({ tokens });
614
+ };
615
+ ```
616
+
617
+ 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).
618
+
619
+ ## Auth events
620
+
621
+ `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`:
622
+
623
+ ```ts
624
+ import { authEvents } from "@warlock.js/auth";
625
+
626
+ authEvents.on("login.success", (user, tokens, deviceInfo) => { /* audit */ });
627
+ authEvents.on("login.failed", (credentials, reason) => { /* alert on brute force */ });
628
+ authEvents.on("logout", (user) => { /* clear server-side session, if any */ });
629
+ authEvents.on("token.refreshed", (user, newPair, oldToken) => { /* track rotation */ });
630
+ authEvents.on("cleanup.completed", (count) => { /* metrics */ });
631
+ ```
632
+
633
+ 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`.
634
+
635
+ ## Things NOT to do
636
+
637
+ - 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.
638
+ - Don't return the password hash in the response. `static toJsonColumns` on the User model should explicitly exclude it.
639
+ - Don't store the refresh token in localStorage. Use an httpOnly secure cookie for refresh tokens; the access token can sit in memory.
640
+ - 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.
641
+
642
+ ## See also
643
+
644
+ - [`@warlock.js/auth/manage-tokens/SKILL.md`](@warlock.js/auth/manage-tokens/SKILL.md) — token lifecycle, rotation, family revocation
645
+ - [`@warlock.js/auth/register-user/SKILL.md`](@warlock.js/auth/register-user/SKILL.md) — sign-up that issues tokens after creation
646
+ - [`@warlock.js/auth/protect-routes/SKILL.md`](@warlock.js/auth/protect-routes/SKILL.md) — where the access token gets consumed
554
647
 
555
648
 
556
649
  ## login-with-providers `@warlock.js/auth/login-with-providers/SKILL.md`
557
650
 
558
651
  ---
559
652
  name: login-with-providers
560
- description: 'Log in without a password — Google (OIDC code + PKCE + state + nonce, id_token verified with `jose`), 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", "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`.'
653
+ 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`.'
561
654
  ---
562
655
 
563
- # Login with Google, passkeys, or a phone code
656
+ # Login with an OAuth provider, passkeys, or a phone code
564
657
 
565
658
  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:
566
659
 
@@ -576,6 +669,8 @@ authService.setAuthCookie(response, tokens.accessToken); // cookie session, or r
576
669
  | Method | Command | Installs |
577
670
  | --- | --- | --- |
578
671
  | Google | `warlock add auth-google` | `jose` |
672
+ | Apple, LinkedIn | `warlock add auth-google` (or `npm install jose`) — there is no separate `auth-apple` / `auth-linkedin` feature yet | `jose` |
673
+ | GitHub, Discord, Facebook, X | nothing — plain OAuth 2 over `fetch` | nothing |
579
674
  | Passkeys | `warlock add auth-passkeys` | `@simplewebauthn/server` (add `@simplewebauthn/browser` to your client bundle) |
580
675
  | Phone code | `warlock add notifications` + your own `sms`/`whatsapp` channel | nothing else. Auth ships no SMS/WhatsApp driver |
581
676
 
@@ -620,6 +715,133 @@ Client side, the button is just a link. It must be a top-level navigation, not a
620
715
  - **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 }`.
621
716
  - Other OIDC providers: implement `AuthProvider` (`authorizationUrl(state)`, `handleCallback({ query, expected })`) and register it under `auth.providers.custom.<name>`.
622
717
 
718
+ ## GitHub, Discord, LinkedIn
719
+
720
+ Same shape as Google: a config block under `auth.providers.<name>`, a GET
721
+ route that redirects to `startProviderLogin(response, "<name>")`, and a GET
722
+ callback that calls `completeProviderLogin(User, "<name>", request, response)`.
723
+
724
+ ```ts
725
+ // src/config/auth.ts
726
+ providers: {
727
+ github: {
728
+ clientId: env("GITHUB_CLIENT_ID"),
729
+ clientSecret: env("GITHUB_CLIENT_SECRET"),
730
+ redirectUri: `${env("APP_URL")}/auth/github/callback`,
731
+ // scopes: ["read:user", "user:email"],
732
+ },
733
+ discord: {
734
+ clientId: env("DISCORD_CLIENT_ID"),
735
+ clientSecret: env("DISCORD_CLIENT_SECRET"),
736
+ redirectUri: `${env("APP_URL")}/auth/discord/callback`,
737
+ // scopes: ["identify", "email"],
738
+ },
739
+ linkedin: {
740
+ clientId: env("LINKEDIN_CLIENT_ID"),
741
+ clientSecret: env("LINKEDIN_CLIENT_SECRET"),
742
+ redirectUri: `${env("APP_URL")}/auth/linkedin/callback`,
743
+ // scopes: ["openid", "profile", "email"],
744
+ },
745
+ },
746
+ ```
747
+
748
+ - **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.
749
+ - **Discord** — OAuth 2 + PKCE; `email` comes from `/users/@me` and counts as verified only when Discord's `verified` flag is `true`.
750
+ - **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`.
751
+ - Provider names resolve by own key only — `auth.providers` inherited keys such as `toString` never resolve as a provider.
752
+
753
+ ## Apple
754
+
755
+ ```ts
756
+ // src/config/auth.ts
757
+ providers: {
758
+ apple: {
759
+ clientId: env("APPLE_CLIENT_ID"), // the Services ID
760
+ teamId: env("APPLE_TEAM_ID"),
761
+ keyId: env("APPLE_KEY_ID"),
762
+ privateKey: env("APPLE_PRIVATE_KEY"), // the .p8 file's PKCS8 PEM contents
763
+ redirectUri: `${env("APP_URL")}/auth/apple/callback`,
764
+ // scopes: ["name", "email"],
765
+ },
766
+ },
767
+ ```
768
+
769
+ ```ts
770
+ router.get("/auth/apple", async ({ response }) =>
771
+ response.redirect(await startProviderLogin(response, "apple")),
772
+ );
773
+
774
+ // Apple POSTs the callback (form_post) when name/email scopes are requested —
775
+ // this route must accept a POST body, not just a query string.
776
+ router.post("/auth/apple/callback", async ({ request, response }) => {
777
+ const { tokens } = await completeProviderLogin(User, "apple", request, response);
778
+ authService.setAuthCookie(response, tokens.accessToken);
779
+ return response.redirect("/");
780
+ });
781
+ ```
782
+
783
+ - 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.
784
+ - 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.
785
+ - The email may be a private-relay address (`@privaterelay.appleid.com`); it is still a real, working address and is accepted like any other.
786
+ - Same rejections and linking rules as Google, through `InvalidProviderCallbackError` / `ProviderEmailNotVerifiedError`.
787
+ - 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).
788
+
789
+ ## Facebook
790
+
791
+ ```ts
792
+ providers: {
793
+ facebook: {
794
+ clientId: env("FACEBOOK_CLIENT_ID"),
795
+ clientSecret: env("FACEBOOK_CLIENT_SECRET"),
796
+ redirectUri: `${env("APP_URL")}/auth/facebook/callback`,
797
+ // scopes: ["email", "public_profile"],
798
+ },
799
+ },
800
+ ```
801
+
802
+ ```ts
803
+ router.get("/auth/facebook", async ({ response }) =>
804
+ response.redirect(await startProviderLogin(response, "facebook")),
805
+ );
806
+
807
+ router.get("/auth/facebook/callback", async ({ request, response }) => {
808
+ const { tokens } = await completeProviderLogin(User, "facebook", request, response);
809
+ authService.setAuthCookie(response, tokens.accessToken);
810
+ return response.redirect("/");
811
+ });
812
+ ```
813
+
814
+ - 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.
815
+ - 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.
816
+
817
+ ## X (Twitter)
818
+
819
+ ```ts
820
+ providers: {
821
+ x: {
822
+ clientId: env("X_CLIENT_ID"),
823
+ clientSecret: env("X_CLIENT_SECRET"),
824
+ redirectUri: `${env("APP_URL")}/auth/x/callback`,
825
+ // scopes: ["tweet.read", "users.read"],
826
+ },
827
+ },
828
+ ```
829
+
830
+ ```ts
831
+ router.get("/auth/x", async ({ response }) =>
832
+ response.redirect(await startProviderLogin(response, "x")),
833
+ );
834
+
835
+ router.get("/auth/x/callback", async ({ request, response }) => {
836
+ const { tokens } = await completeProviderLogin(User, "x", request, response);
837
+ authService.setAuthCookie(response, tokens.accessToken);
838
+ return response.redirect("/");
839
+ });
840
+ ```
841
+
842
+ - OAuth 2 with PKCE, plus HTTP Basic auth (`client_id:client_secret`) at the token endpoint for the confidential client — X requires both together.
843
+ - 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.
844
+
623
845
  ## Passkeys
624
846
 
625
847
  ```ts
@@ -851,382 +1073,441 @@ Run this periodically via the scheduler:
851
1073
  import { scheduler, job } from "@warlock.js/scheduler";
852
1074
  import { authService } from "@warlock.js/auth";
853
1075
 
854
- scheduler.addJob(
855
- job("auth-cleanup", () => authService.cleanupExpiredTokens())
856
- .daily()
857
- .at("03:00"),
858
- );
859
- ```
860
-
861
- Or use the bundled CLI command — see [`@warlock.js/auth/run-auth-commands/SKILL.md`](@warlock.js/auth/run-auth-commands/SKILL.md).
862
-
863
- ## JWT helpers
864
-
865
- For low-level JWT signing/verification (outside the authService flow):
866
-
867
- ```ts
868
- import { jwt } from "@warlock.js/auth";
869
-
870
- const token = await jwt.generate(payload, { expiresIn: 3_600_000 }); // milliseconds
871
- const decoded = await jwt.verify(token);
872
-
873
- const refreshToken = await jwt.generateRefreshToken(payload, { expiresIn });
874
- const decodedRefresh = await jwt.verifyRefreshToken(refreshToken);
875
- ```
876
-
877
- ⚠️ **`jwt.generate` / `jwt.generateRefreshToken` are the low-level escape hatch — `expiresIn` goes straight to `fast-jwt`.** It takes a number of milliseconds or an `ms`-style string, and rejects an unparseable string. What it does **not** reject is `undefined` or `0`: both skip the check and emit a token with **no `exp` claim**. Pass a positive value, and never pass the result of parsing a config string without checking it first.
878
-
879
- **`jwt.verify` / `jwt.verifyRefreshToken` require an `exp` claim** (since 4.12.0) and reject a token without one. There is no deadline to check on such a token, so verification would otherwise succeed indefinitely. `requiredClaims` is additive — `jwt.verify(token, { requiredClaims: ["iat"] })` requires `iat` **and** `exp`; you cannot opt out of `exp`. If you want a token that effectively never expires, use `expiresIn: NO_EXPIRATION` (`"100y"`), which stamps a real `exp` about a century out.
880
-
881
- If tokens minted before 4.12.0 may lack an `exp`, see `warlock auth.purge-never-expiring` in [`@warlock.js/auth/run-auth-commands/SKILL.md`](@warlock.js/auth/run-auth-commands/SKILL.md) — they are still in your token tables and no date-based cleanup can reach them.
882
-
883
- `authService.generateAccessToken` / `createRefreshToken` read `config.auth.accessToken.expiresIn` / `config.auth.refreshToken.expiresIn`, validate them, and throw naming the key if the value is not a positive `ms` duration — before anything is signed, persisted, or capped.
884
-
885
- The package signs access and refresh tokens with independent secrets — `config.auth.accessToken.secret` and `config.auth.refreshToken.secret`. Setting a distinct `refresh.secret` is recommended: it prevents an access-token compromise from forging refresh tokens (and vice versa). The refresh secret is **optional** — when `config.auth.refreshToken.secret` is unset, refresh tokens fall back to the main `config.auth.accessToken.secret`, so refresh works out of the box without a second secret.
886
-
887
- ## Things NOT to do
888
-
889
- - Don't use raw JWT libraries directly. The package handles signing, verification, secret loading, and the access/refresh split.
890
- - Don't disable rotation (`config.auth.refreshToken.rotation = false`) unless you genuinely understand the tradeoff — you lose replay detection.
891
- - Don't increase `maxPerUser` to a huge number "to be safe." Each active refresh token is a revocation surface; fewer simultaneous tokens means less attack surface.
892
- - Don't manually delete `AccessToken` rows in a service. The user might be hitting a request mid-revoke and get an inconsistent state. Use the `authService` helpers.
893
-
894
- ## See also
895
-
896
- - [`@warlock.js/auth/handle-login-and-logout/SKILL.md`](@warlock.js/auth/handle-login-and-logout/SKILL.md) — full login/logout flow that uses these primitives
897
- - [`@warlock.js/auth/run-auth-commands/SKILL.md`](@warlock.js/auth/run-auth-commands/SKILL.md) — the bundled cleanup command
898
- - [`@warlock.js/scheduler/scheduler-basics/SKILL.md`](@warlock.js/scheduler/scheduler-basics/SKILL.md) — scheduling cleanup
899
-
900
-
901
- ## overview `@warlock.js/auth/overview/SKILL.md`
902
-
903
- ---
904
- name: overview
905
- description: 'Front-door orientation for `@warlock.js/auth` — JWT authentication for Warlock apps: the `Auth` base model, `authMiddleware` route gate, `authService` (login / logout / refresh with token rotation + replay detection), persisted AccessToken + RefreshToken, multi-user-type support, auth lifecycle events, and two CLI commands. Coupled to `@warlock.js/core`. TRIGGER when: code imports anything from `@warlock.js/auth`; user asks "what does @warlock.js/auth do", "how do I add login to my Warlock app", "JWT auth in Warlock", "protect a route", "multiple user types / admin + user", "refresh token rotation"; package.json adds `@warlock.js/auth`. Skip: specific task already known — load the matching task skill directly (`auth-basics`, `protect-routes`, `handle-login-and-logout`, `register-user`, `manage-tokens`, `customize-user-type`, `run-auth-commands`); non-Warlock apps (this package depends on core); session-cookie auth (this is JWT/token-based).'
906
- ---
907
-
908
- # `@warlock.js/auth` — overview
909
-
910
- JWT authentication for Warlock apps. You get a base `Auth` model your user types extend, an `authMiddleware` route gate, an `authService` that runs login/logout/refresh (with refresh-token rotation and replay detection), persisted access + refresh tokens, multi-user-type support, lifecycle events, and two bundled CLI commands.
911
-
912
- Coupled to `@warlock.js/core` — you're inside a Warlock project before this package makes sense.
913
-
914
- Server-only: `package.json` declares `"warlock": { "environment": "server" }`. `@warlock.js/web`'s build refuses app client code that value-imports this package; type-only imports are allowed, and server loaders/controllers/modules are unaffected.
915
-
916
- ## When to reach for it
917
-
918
- - You're building a Warlock app that needs login, protected routes, and token-based sessions.
919
- - You need **multiple user types** (admins + regular users, or client/vendor/staff personas) gated separately on the same auth system.
920
- - You want refresh-token **rotation + replay detection** out of the box rather than hand-rolling token security.
921
-
922
- Skip if you're not on `@warlock.js/core` (the package depends on it), or if you need session-cookie auth rather than JWTs.
923
-
924
- ## The mental model in one paragraph
925
-
926
- Your user model extends the `Auth` base model and declares its `userType`. A login flows through `authService.login(Model, credentials, deviceInfo?)`: it verifies the password, issues an access + refresh token pair (persisted as `AccessToken` / `RefreshToken` records), and fires events. `authMiddleware(allowedUserType)` gates routes — the argument is required and always requires a valid token: `[]` allows any authenticated user, a user-type argument restricts to those types (401 otherwise). There is no anonymous mode; public routes simply omit the middleware. Refresh rotates the refresh token and detects replay by revoking the whole token family. CLI commands generate the JWT secret and clean up expired tokens.
927
-
928
- ## Skills index
929
-
930
- Nine task skills. Most apps need `auth-basics` + `protect-routes` + `handle-login-and-logout`.
931
-
932
- ### Foundations
933
-
934
- #### [`auth-basics`](@warlock.js/auth/auth-basics/SKILL.md)
935
- Start here. The `Auth` base model, `authMiddleware` gate, `authService` (login/logout/refresh), AccessToken + RefreshToken persistence, multi-user-type support.
936
-
937
- ### The flows
938
-
939
- #### [`handle-login-and-logout`](@warlock.js/auth/handle-login-and-logout/SKILL.md)
940
- `authService.login(Model, credentials, deviceInfo?)` — verify password, issue the token pair, fire events. `authService.logout(user, accessToken?, refreshToken?)` — revoke tokens. For your `POST /login` and `POST /logout` controllers.
941
-
942
- #### [`register-user`](@warlock.js/auth/register-user/SKILL.md)
943
- Sign up a new user and issue the first token pair — `User.create({ ...password: await hashPassword(plain) })` then `authService.createTokenPair(user)`. For `POST /register`.
944
-
945
- #### [`protect-routes`](@warlock.js/auth/protect-routes/SKILL.md)
946
- `authMiddleware(allowedUserType)` — the argument is required and always requires a valid token: `[]` allows any authenticated user, a user-type argument restricts to those types. Sets `request.locals.user` + `request.decodedAccessToken`, responds 401 on failure.
947
-
948
- ### Going deeper
949
-
950
- #### [`manage-tokens`](@warlock.js/auth/manage-tokens/SKILL.md)
951
- The token lifecycle — `generateAccessToken`, `createRefreshToken`, `createTokenPair`, `refreshTokens` (rotation + replay detection), `revokeAllTokens`, `revokeTokenFamily`, `cleanupExpiredTokens`, `getActiveSessions`. For custom login/registration, token revocation, "logout everywhere", and scheduled cleanup.
952
-
953
- #### [`customize-user-type`](@warlock.js/auth/customize-user-type/SKILL.md)
954
- Support multiple user types in one system — each `Auth` subclass overrides `userType`, `config.auth.userType.<slug>` maps the slug to a model class, `authMiddleware("admin")` / `authMiddleware(["admin", "staff"])` gates per type.
955
-
956
- #### [`customize-token-storage`](@warlock.js/auth/customize-token-storage/SKILL.md)
957
- Override the persisted `AccessToken` / `RefreshToken` models to add columns (multi-tenant `organization_id`), rename, or change storage — extend the model + schema and register it under `config.auth.accessToken.model` / `config.auth.refreshToken.model`.
958
-
959
- #### [`throttle-login-attempts`](@warlock.js/auth/throttle-login-attempts/SKILL.md)
960
- Brute-force / credential-stuffing protection — `loginThrottleMiddleware()` counts only failed logins, resets on success, locks per-account + per-IP after a threshold, and rejects pre-controller with 429. Cache-backed, fails open on a cache outage.
961
-
962
- #### [`run-auth-commands`](@warlock.js/auth/run-auth-commands/SKILL.md)
963
- Two CLI commands — `warlock jwt.generate` (strong JWT secret → `.env`) and `warlock auth.cleanup` (remove expired refresh tokens). Register via `registerJWTSecretGeneratorCommand()` and `registerAuthCleanupCommand()`.
964
-
965
- ## What this package deliberately doesn't do
966
-
967
- - **Session-cookie auth.** It's JWT/token-based. If you need server-side sessions, this isn't it.
968
- - **OAuth / social login / SSO.** No provider adapters here — wire those at the controller layer and create the user through this package's models.
969
- - **Authorization / roles / permissions (RBAC).** It authenticates (who you are) and gates by user *type*, not fine-grained permissions. Build RBAC on top.
970
- - **Standalone use.** It depends on `@warlock.js/core` for routing, models (Cascade), and config.
971
-
972
- ## See also
973
-
974
- - [`@warlock.js/core/warlock-conventions/SKILL.md`](@warlock.js/core/warlock-conventions/SKILL.md) — the framework auth runs inside (routing, middleware, config).
975
- - [`@warlock.js/cascade/cascade-basics/SKILL.md`](@warlock.js/cascade/cascade-basics/SKILL.md) — the ORM behind the `Auth`, `AccessToken`, and `RefreshToken` models.
976
- - `mongez-agent-kit-authoring-skills` (load via agent-kit sync) — how this `overview/SKILL.md` becomes `.claude/skills/warlock-js-auth-overview/`.
977
-
978
-
979
- ## protect-routes `@warlock.js/auth/protect-routes/SKILL.md`
980
-
981
- ---
982
- name: protect-routes
983
- 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.'
984
- ---
985
-
986
- # Gate routes with `authMiddleware`
987
-
988
- `authMiddleware(allowedUserType: string | string[])` returns a Warlock middleware. Attach it to routes or route groups. The argument is **required** — there is no anonymous/optional mode. A request without a valid access token is always rejected with `401`; public routes simply omit the middleware.
989
-
990
- ## Two modes
991
-
992
- Middleware is attached via the route's `options.middleware` array (the third argument) — never as a positional argument.
993
-
994
- ```ts
995
- import { authMiddleware } from "@warlock.js/auth";
996
-
997
- // Mode 1 — required, any user type
998
- // Rejects with 401 if no valid token; any authenticated user passes.
999
- router.get("/account", accountController, {
1000
- middleware: [authMiddleware([])], // empty array = "any logged-in user"
1001
- });
1002
-
1003
- // Mode 2 — required, specific user type(s)
1004
- // Rejects with 401 if no token OR if token's userType isn't allowed.
1005
- router.get("/admin", adminController, {
1006
- middleware: [authMiddleware("admin")],
1007
- });
1008
-
1009
- router.get("/staff", staffController, {
1010
- middleware: [authMiddleware(["admin", "staff"])],
1011
- });
1012
- ```
1013
-
1014
- 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).
1015
-
1016
- ## What the middleware does
1017
-
1018
- On success, before your controller runs:
1019
-
1020
- ```ts
1021
- request.locals.user = <hydrated user model instance>;
1022
- request.decodedAccessToken = <decoded JWT payload>;
1023
- ```
1024
-
1025
- 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.
1026
-
1027
- A token passes three separate checks, in order — a token can fail any one of them while satisfying the other two:
1028
-
1029
- 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.
1030
- 2. **The access-token row still exists** — deleting it (logout) invalidates the token immediately, before its JWT expiry.
1031
- 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.
1032
-
1033
- On failure, the middleware returns one of these 401 responses:
1034
-
1035
- | Error code | When |
1036
- | --- | --- |
1037
- | `MissingAccessToken` | No `Authorization` header |
1038
- | `InvalidAccessToken` | Token doesn't verify (signature, missing/passed `exp`, wrong token type), has no DB row, or the row's `expires_at` has passed |
1039
- | `Unauthorized` | Token valid but user-type isn't in the allowed list |
1040
-
1041
- ## Page routes: redirect to login instead of the JSON 401
1042
-
1043
- 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.
1044
-
1045
- 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:
1046
-
1047
- ```ts title="src/config/auth.ts"
1048
- export default {
1049
- userType: { user: User },
1050
- pageAuth: {
1051
- loginPath: "/login", // set this to turn the redirect on
1052
- returnUrlParam: "returnUrl", // optional, this is the default
1053
- },
1054
- };
1055
- ```
1056
-
1057
- Behavior, precisely:
1058
-
1059
- - **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 `?`.
1060
- - **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.
1061
- - **API route** → always the JSON `401`, regardless of `pageAuth`. The API contract is never altered.
1062
-
1063
- 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`.
1064
-
1065
- > `auth.pageAuth` is **not a second auth mechanism** — it is a convenience *adapter* over the same "not authenticated" outcome the gate already produces. It only changes the **representation** of that outcome on a page route (a browser-friendly login redirect) instead of the JSON `401`; the decision that the request is unauthenticated is unchanged. The sanctioned *generic* way to make authentication soft is the app-owned optional-auth middleware below — `pageAuth` is the built-in adapter for the common "redirect a logged-out human to /login" case, so most apps never need to hand-roll it.
1066
-
1067
- ## Reading the user in a controller
1068
-
1069
- ```ts
1070
- import { type RequestHandler } from "@warlock.js/core";
1071
-
1072
- export const accountController: RequestHandler = async ({ request, response }) => {
1073
- const user = request.locals.user!; // typed via your Auth subclass
1074
- return response.success({
1075
- id: user.id,
1076
- email: user.get("email"),
1077
- });
1078
- };
1079
- ```
1080
-
1081
- 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.
1082
-
1083
- ## Route-group protection
1084
-
1085
- ```ts
1086
- router.group({ prefix: "/admin", middleware: [authMiddleware("admin")] }, () => {
1087
- router.get("/users", listUsersController);
1088
- router.post("/users", createUserController);
1089
- });
1076
+ scheduler.addJob(
1077
+ job("auth-cleanup", () => authService.cleanupExpiredTokens())
1078
+ .daily()
1079
+ .at("03:00"),
1080
+ );
1090
1081
  ```
1091
1082
 
1092
- 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.
1083
+ Or use the bundled CLI command — see [`@warlock.js/auth/run-auth-commands/SKILL.md`](@warlock.js/auth/run-auth-commands/SKILL.md).
1093
1084
 
1094
- ## Optional auth is an app-owned middleware (the sanctioned pattern)
1085
+ ## JWT helpers
1095
1086
 
1096
- `authMiddleware` is a **hard gate** — it always requires a valid token and rejects when one is absent. There is deliberately no built-in "hydrate `request.locals.user` if a token is present, otherwise continue" mode, because the *sanctioned generic pattern* for soft/optional auth is a small **app-owned optional-auth middleware**: resolve the user when a valid token is present, and otherwise leave the route to decide. The app owns it because the "what to do when absent" policy is the app's, not the framework's.
1087
+ For low-level JWT signing/verification (outside the authService flow):
1097
1088
 
1098
- ```ts title="src/app/middleware/optional-auth.middleware.ts"
1099
- import { type Middleware } from "@warlock.js/core";
1089
+ ```ts
1100
1090
  import { jwt } from "@warlock.js/auth";
1101
1091
 
1102
- // The one sanctioned optional-auth shape: resolve if present, never reject.
1103
- // Middleware receives the context object ({ request, response }), like a handler.
1104
- export const optionalAuth: Middleware = async ({ request }) => {
1105
- const token = request.authorizationValue;
1106
- if (!token) return; // absent → continue anonymously; the route decides
1107
- try {
1108
- const decoded = await jwt.verify(token);
1109
- // hydrate request.locals.user from your token/user model when valid
1110
- } catch {
1111
- // invalid token on an optional route → treat as anonymous, don't reject
1112
- }
1113
- // returning nothing (undefined) === continue to the handler
1114
- };
1092
+ const token = await jwt.generate(payload, { expiresIn: 3_600_000 }); // milliseconds
1093
+ const decoded = await jwt.verify(token);
1094
+
1095
+ const refreshToken = await jwt.generateRefreshToken(payload, { expiresIn });
1096
+ const decodedRefresh = await jwt.verifyRefreshToken(refreshToken);
1115
1097
  ```
1116
1098
 
1117
- Wire it like any middleware (`{ middleware: [optionalAuth] }`), then branch on `request.locals.user` in the controller. Use `authMiddleware` when the route must be gated; use this when the route is public but personalizes for a signed-in user. `auth.pageAuth` (above) is the built-in **adapter** over this same "unauthenticated" outcome for the common page-redirect case — it is not a competing mechanism.
1099
+ ⚠️ **`jwt.generate` / `jwt.generateRefreshToken` are the low-level escape hatch — `expiresIn` goes straight to `fast-jwt`.** It takes a number of milliseconds or an `ms`-style string, and rejects an unparseable string. What it does **not** reject is `undefined` or `0`: both skip the check and emit a token with **no `exp` claim**. Pass a positive value, and never pass the result of parsing a config string without checking it first.
1118
1100
 
1119
- ## Custom error responses
1101
+ **`jwt.verify` / `jwt.verifyRefreshToken` require an `exp` claim** (since 4.12.0) and reject a token without one. There is no deadline to check on such a token, so verification would otherwise succeed indefinitely. `requiredClaims` is additive — `jwt.verify(token, { requiredClaims: ["iat"] })` requires `iat` **and** `exp`; you cannot opt out of `exp`. If you want a token that effectively never expires, use `expiresIn: NO_EXPIRATION` (`"100y"`), which stamps a real `exp` about a century out.
1120
1102
 
1121
- The middleware uses the framework's `response.unauthorized({...})` shape. To override the response globally, hook the framework's error transformer to remap `AuthErrorCodes.*` codes.
1103
+ If tokens minted before 4.12.0 may lack an `exp`, see `warlock auth.purge-never-expiring` in [`@warlock.js/auth/run-auth-commands/SKILL.md`](@warlock.js/auth/run-auth-commands/SKILL.md) — they are still in your token tables and no date-based cleanup can reach them.
1122
1104
 
1123
- ## When flat user types aren't enough — reach for `@warlock.js/access`
1105
+ `authService.generateAccessToken` / `createRefreshToken` read `config.auth.accessToken.expiresIn` / `config.auth.refreshToken.expiresIn`, validate them, and throw naming the key if the value is not a positive `ms` duration — before anything is signed, persisted, or capped.
1124
1106
 
1125
- `authMiddleware` gates on **user type** by flat string membership. That answers "is this an admin?" and nothing more. It deliberately cannot express:
1107
+ The package signs access and refresh tokens with independent secrets — `config.auth.accessToken.secret` and `config.auth.refreshToken.secret`. Setting a distinct `refresh.secret` is recommended: it prevents an access-token compromise from forging refresh tokens (and vice versa). The refresh secret is **optional** — when `config.auth.refreshToken.secret` is unset, refresh tokens fall back to the main `config.auth.accessToken.secret`, so refresh works out of the box without a second secret.
1126
1108
 
1127
- - a permission matrix (`articles.publish` rather than `"editor"`)
1128
- - role hierarchy or inheritance
1129
- - **who may act on whom** — "an `admin` may create a `teacher`, but only a `superAdmin` may create or promote an `admin`"
1109
+ ## Things NOT to do
1130
1110
 
1131
- 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.
1111
+ - Don't use raw JWT libraries directly. The package handles signing, verification, secret loading, and the access/refresh split.
1112
+ - Don't disable rotation (`config.auth.refreshToken.rotation = false`) unless you genuinely understand the tradeoff — you lose replay detection.
1113
+ - Don't increase `maxPerUser` to a huge number "to be safe." Each active refresh token is a revocation surface; fewer simultaneous tokens means less attack surface.
1114
+ - Don't manually delete `AccessToken` rows in a service. The user might be hitting a request mid-revoke and get an inconsistent state. Use the `authService` helpers.
1132
1115
 
1133
- 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:
1116
+ ## See also
1134
1117
 
1135
- ```ts
1136
- import { gate, can, definePolicy } from "@warlock.js/access";
1118
+ - [`@warlock.js/auth/handle-login-and-logout/SKILL.md`](@warlock.js/auth/handle-login-and-logout/SKILL.md) — full login/logout flow that uses these primitives
1119
+ - [`@warlock.js/auth/run-auth-commands/SKILL.md`](@warlock.js/auth/run-auth-commands/SKILL.md) — the bundled cleanup command
1120
+ - [`@warlock.js/scheduler/scheduler-basics/SKILL.md`](@warlock.js/scheduler/scheduler-basics/SKILL.md) — scheduling cleanup
1121
+
1122
+
1123
+ ## overview `@warlock.js/auth/overview/SKILL.md`
1124
+
1125
+ ---
1126
+ name: overview
1127
+ description: 'Front-door orientation for `@warlock.js/auth` — JWT authentication for Warlock apps: the `Auth` base model, `authMiddleware` route gate, `authService` (login / logout / refresh with token rotation + replay detection), persisted AccessToken + RefreshToken, multi-user-type support, auth lifecycle events, and two CLI commands. Coupled to `@warlock.js/core`. TRIGGER when: code imports anything from `@warlock.js/auth`; user asks "what does @warlock.js/auth do", "how do I add login to my Warlock app", "JWT auth in Warlock", "protect a route", "multiple user types / admin + user", "refresh token rotation"; package.json adds `@warlock.js/auth`. Skip: specific task already known — load the matching task skill directly (`auth-basics`, `protect-routes`, `handle-login-and-logout`, `register-user`, `manage-tokens`, `customize-user-type`, `run-auth-commands`); non-Warlock apps (this package depends on core); session-cookie auth (this is JWT/token-based).'
1128
+ ---
1137
1129
 
1138
- // Permission-based route gate, in place of a user-type gate
1139
- router.post("/articles", articlesController, { middleware: [authMiddleware([]), gate("articles.create")] });
1130
+ # `@warlock.js/auth` — overview
1140
1131
 
1141
- // Who-may-act-on-whom: an ABAC policy on top of the RBAC grant
1142
- definePolicy("users.create", (actor, target, ctx) =>
1143
- ctx.hasRole("superAdmin") || (target as User).userType === "teacher",
1144
- );
1132
+ JWT authentication for Warlock apps. You get a base `Auth` model your user types extend, an `authMiddleware` route gate, an `authService` that runs login/logout/refresh (with refresh-token rotation and replay detection), persisted access + refresh tokens, multi-user-type support, lifecycle events, and two bundled CLI commands.
1145
1133
 
1146
- if (await can(request.locals.user, "users.create", { resource: payload })) { /* ... */ }
1147
- ```
1134
+ Coupled to `@warlock.js/core` — you're inside a Warlock project before this package makes sense.
1148
1135
 
1149
- 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.
1136
+ Server-only: `package.json` declares `"warlock": { "environment": "server" }`. `@warlock.js/web`'s build refuses app client code that value-imports this package; type-only imports are allowed, and server loaders/controllers/modules are unaffected.
1150
1137
 
1151
- ## Cookie sessions: `authMiddleware([], "cookie:<name>")`
1138
+ ## When to reach for it
1152
1139
 
1153
- `authMiddleware`'s second argument, `tokenFrom`, selects the credential source and defaults to `"header"`. Pass `` `cookie:<name>` `` to read the credential from a named cookie instead of the `Authorization` header — the same gate, the same three checks, the same `request.locals.user` / `request.decodedAccessToken` outcome, just a different place to find the token:
1140
+ - You're building a Warlock app that needs login, protected routes, and token-based sessions.
1141
+ - You need **multiple user types** (admins + regular users, or client/vendor/staff personas) gated separately on the same auth system.
1142
+ - You want refresh-token **rotation + replay detection** out of the box rather than hand-rolling token security.
1154
1143
 
1155
- ```ts
1156
- router.get("/browser-account", accountController, {
1157
- middleware: [authMiddleware([], "cookie:token")],
1158
- });
1159
- ```
1144
+ Skip if you're not on `@warlock.js/core` (the package depends on it), or if you need session-cookie auth rather than JWTs.
1160
1145
 
1161
- 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).
1146
+ ## The mental model in one paragraph
1162
1147
 
1163
- ### Writing and clearing the cookie: `authService.setAuthCookie` / `clearAuthCookie`
1148
+ Your user model extends the `Auth` base model and declares its `userType`. A login flows through `authService.login(Model, credentials, deviceInfo?)`: it verifies the password, issues an access + refresh token pair (persisted as `AccessToken` / `RefreshToken` records), and fires events. `authMiddleware(allowedUserType)` gates routes — the argument is required and always requires a valid token: `[]` allows any authenticated user, a user-type argument restricts to those types (401 otherwise). There is no anonymous mode; public routes simply omit the middleware. Refresh rotates the refresh token and detects replay by revoking the whole token family. CLI commands generate the JWT secret and clean up expired tokens.
1164
1149
 
1165
- 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.
1150
+ ## Skills index
1166
1151
 
1167
- ```ts
1168
- import { authService } from "@warlock.js/auth";
1152
+ Nine task skills. Most apps need `auth-basics` + `protect-routes` + `handle-login-and-logout`.
1169
1153
 
1170
- // after issuing tokens
1171
- const { user, tokens } = await authService.login(User, credentials);
1172
- authService.setAuthCookie(response, tokens.accessToken); // Max-Age derived from tokens.accessToken.expiresAt
1154
+ ### Foundations
1173
1155
 
1174
- // after revoking tokens
1175
- await authService.logout(user, accessToken, refreshToken);
1176
- authService.clearAuthCookie(response);
1177
- ```
1156
+ #### [`auth-basics`](@warlock.js/auth/auth-basics/SKILL.md)
1157
+ Start here. The `Auth` base model, `authMiddleware` gate, `authService` (login/logout/refresh), AccessToken + RefreshToken persistence, multi-user-type support.
1178
1158
 
1179
- `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.
1159
+ ### The flows
1180
1160
 
1181
- 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:
1161
+ #### [`handle-login-and-logout`](@warlock.js/auth/handle-login-and-logout/SKILL.md)
1162
+ `authService.login(Model, credentials, deviceInfo?)` — verify password, issue the token pair, fire events. `authService.logout(user, accessToken?, refreshToken?)` — revoke tokens. For your `POST /login` and `POST /logout` controllers.
1182
1163
 
1183
- ```ts title="src/config/auth.ts"
1184
- export default {
1185
- userType: { user: User },
1186
- cookie: {
1187
- name: "access_token", // must match the `cookie:<name>` a route reads
1188
- path: "/",
1189
- },
1190
- };
1191
- ```
1164
+ #### [`register-user`](@warlock.js/auth/register-user/SKILL.md)
1165
+ Sign up a new user and issue the first token pair — `User.create({ ...password: await hashPassword(plain) })` then `authService.createTokenPair(user)`. For `POST /register`.
1192
1166
 
1193
- The cookie name `setAuthCookie`/`clearAuthCookie` write **must match** the name a `cookie:<name>` token source reads — they are not linked automatically.
1167
+ #### [`protect-routes`](@warlock.js/auth/protect-routes/SKILL.md)
1168
+ `authMiddleware(allowedUserType)` — the argument is required and always requires a valid token: `[]` allows any authenticated user, a user-type argument restricts to those types. Sets `request.locals.user` + `request.decodedAccessToken`, responds 401 on failure.
1194
1169
 
1195
- ### CSRF: an automatic Origin check on cookie-authenticated writes
1170
+ ### Going deeper
1196
1171
 
1197
- 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`.
1172
+ #### [`manage-tokens`](@warlock.js/auth/manage-tokens/SKILL.md)
1173
+ The token lifecycle — `generateAccessToken`, `createRefreshToken`, `createTokenPair`, `refreshTokens` (rotation + replay detection), `revokeAllTokens`, `revokeTokenFamily`, `cleanupExpiredTokens`, `getActiveSessions`. For custom login/registration, token revocation, "logout everywhere", and scheduled cleanup.
1198
1174
 
1199
- 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:
1175
+ #### [`customize-user-type`](@warlock.js/auth/customize-user-type/SKILL.md)
1176
+ Support multiple user types in one system — each `Auth` subclass overrides `userType`, `config.auth.userType.<slug>` maps the slug to a model class, `authMiddleware("admin")` / `authMiddleware(["admin", "staff"])` gates per type.
1200
1177
 
1201
- - **Allowed** when `Origin` equals the request's own origin, or is listed in `auth.csrf.allowedOrigins` (config, default `[]`).
1202
- - If `Origin` is absent, `Referer`'s origin is checked the same way.
1203
- - If **both** are absent, or the check fails, the request is rejected with `403` and `errorCode: AuthErrorCodes.CsrfOriginMismatch` ("EC006") — never silently passed through.
1204
- - **Header-token auth is completely unaffected**, as are safe methods (`GET`/`HEAD`/`OPTIONS`) under cookie auth.
1178
+ #### [`customize-token-storage`](@warlock.js/auth/customize-token-storage/SKILL.md)
1179
+ Override the persisted `AccessToken` / `RefreshToken` models to add columns (multi-tenant `organization_id`), rename, or change storage — extend the model + schema and register it under `config.auth.accessToken.model` / `config.auth.refreshToken.model`.
1205
1180
 
1206
- ```ts title="src/config/auth.ts"
1207
- export default {
1208
- userType: { user: User },
1209
- csrf: {
1210
- allowedOrigins: ["https://admin.example.com"], // extra trusted origins, beyond same-origin
1211
- },
1212
- };
1213
- ```
1181
+ #### [`throttle-login-attempts`](@warlock.js/auth/throttle-login-attempts/SKILL.md)
1182
+ Brute-force / credential-stuffing protection — `loginThrottleMiddleware()` counts only failed logins, resets on success, locks per-account + per-IP after a threshold, and rejects pre-controller with 429. Cache-backed, fails open on a cache outage.
1214
1183
 
1215
- 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.
1184
+ #### [`run-auth-commands`](@warlock.js/auth/run-auth-commands/SKILL.md)
1185
+ Two CLI commands — `warlock jwt.generate` (strong JWT secret → `.env`) and `warlock auth.cleanup` (remove expired refresh tokens). Register via `registerJWTSecretGeneratorCommand()` and `registerAuthCleanupCommand()`.
1216
1186
 
1217
- ## Things NOT to do
1187
+ ## What this package deliberately doesn't do
1218
1188
 
1219
- - 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).
1220
- - Don't manually decode JWTs in the controller. The middleware already does it and exposes the decoded payload via `request.decodedAccessToken`.
1221
- - 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.
1222
- - 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.
1189
+ - **Session-cookie auth.** It's JWT/token-based. If you need server-side sessions, this isn't it.
1190
+ - **OAuth / social login / SSO.** No provider adapters here — wire those at the controller layer and create the user through this package's models.
1191
+ - **Authorization / roles / permissions (RBAC).** It authenticates (who you are) and gates by user *type*, not fine-grained permissions. Build RBAC on top.
1192
+ - **Standalone use.** It depends on `@warlock.js/core` for routing, models (Cascade), and config.
1223
1193
 
1224
1194
  ## See also
1225
1195
 
1226
- - [`@warlock.js/auth/customize-user-type/SKILL.md`](@warlock.js/auth/customize-user-type/SKILL.md) — config and multi-user-type semantics
1227
- - [`@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
1228
- - [`@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
1229
- - [`@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
1196
+ - [`@warlock.js/core/warlock-conventions/SKILL.md`](@warlock.js/core/warlock-conventions/SKILL.md) — the framework auth runs inside (routing, middleware, config).
1197
+ - [`@warlock.js/cascade/cascade-basics/SKILL.md`](@warlock.js/cascade/cascade-basics/SKILL.md) — the ORM behind the `Auth`, `AccessToken`, and `RefreshToken` models.
1198
+ - `mongez-agent-kit-authoring-skills` (load via agent-kit sync) — how this `overview/SKILL.md` becomes `.claude/skills/warlock-js-auth-overview/`.
1199
+
1200
+
1201
+ ## protect-routes `@warlock.js/auth/protect-routes/SKILL.md`
1202
+
1203
+ ---
1204
+ name: protect-routes
1205
+ 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.'
1206
+ ---
1207
+
1208
+ # Gate routes with `authMiddleware`
1209
+
1210
+ `authMiddleware(allowedUserType: string | string[])` returns a Warlock middleware. Attach it to routes or route groups. The argument is **required** — there is no anonymous/optional mode. A request without a valid access token is always rejected with `401`; public routes simply omit the middleware.
1211
+
1212
+ ```ts title="src/app/users/models/user/user.model.ts"
1213
+ import { Auth } from "@warlock.js/auth";
1214
+ import { RegisterModel } from "@warlock.js/cascade";
1215
+
1216
+ @RegisterModel()
1217
+ export class User extends Auth {
1218
+ public static table = "users";
1219
+
1220
+ public get userType(): string {
1221
+ return "user";
1222
+ }
1223
+ }
1224
+ ```
1225
+
1226
+ ## Two modes
1227
+
1228
+ Middleware is attached via the route's `options.middleware` array (the third argument) — never as a positional argument.
1229
+
1230
+ ```ts
1231
+ import { router, type RequestHandler } from "@warlock.js/core";
1232
+ import { authMiddleware } from "@warlock.js/auth";
1233
+
1234
+ const accountController: RequestHandler = async ({ response }) => response.success({});
1235
+ const adminController: RequestHandler = async ({ response }) => response.success({});
1236
+ const staffController: RequestHandler = async ({ response }) => response.success({});
1237
+
1238
+ // Mode 1 — required, any user type
1239
+ // Rejects with 401 if no valid token; any authenticated user passes.
1240
+ router.get("/account", accountController, {
1241
+ middleware: [authMiddleware([])], // empty array = "any logged-in user"
1242
+ });
1243
+
1244
+ // Mode 2 — required, specific user type(s)
1245
+ // Rejects with 401 if no token OR if token's userType isn't allowed.
1246
+ router.get("/admin", adminController, {
1247
+ middleware: [authMiddleware("admin")],
1248
+ });
1249
+
1250
+ router.get("/staff", staffController, {
1251
+ middleware: [authMiddleware(["admin", "staff"])],
1252
+ });
1253
+ ```
1254
+
1255
+ 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).
1256
+
1257
+ ## What the middleware does
1258
+
1259
+ On success, before your controller runs:
1260
+
1261
+ ```ts
1262
+ request.locals.user = <hydrated user model instance>;
1263
+ request.decodedAccessToken = <decoded JWT payload>;
1264
+ ```
1265
+
1266
+ 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.
1267
+
1268
+ A token passes three separate checks, in order — a token can fail any one of them while satisfying the other two:
1269
+
1270
+ 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.
1271
+ 2. **The access-token row still exists** — deleting it (logout) invalidates the token immediately, before its JWT expiry.
1272
+ 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.
1273
+
1274
+ On failure, the middleware returns one of these 401 responses:
1275
+
1276
+ | Error code | When |
1277
+ | --- | --- |
1278
+ | `MissingAccessToken` | No `Authorization` header |
1279
+ | `InvalidAccessToken` | Token doesn't verify (signature, missing/passed `exp`, wrong token type), has no DB row, or the row's `expires_at` has passed |
1280
+ | `Unauthorized` | Token valid but user-type isn't in the allowed list |
1281
+
1282
+ ## Page routes: redirect to login instead of the JSON 401
1283
+
1284
+ 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.
1285
+
1286
+ 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:
1287
+
1288
+ ```ts title="src/config/auth.ts"
1289
+ import { User } from "app/users/models/user/user.model";
1290
+
1291
+ export default {
1292
+ userType: { user: User },
1293
+ pageAuth: {
1294
+ loginPath: "/login", // set this to turn the redirect on
1295
+ returnUrlParam: "returnUrl", // optional, this is the default
1296
+ },
1297
+ };
1298
+ ```
1299
+
1300
+ Behavior, precisely:
1301
+
1302
+ - **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 `?`.
1303
+ - **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.
1304
+ - **API route** → always the JSON `401`, regardless of `pageAuth`. The API contract is never altered.
1305
+
1306
+ 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`.
1307
+
1308
+ > `auth.pageAuth` is **not a second auth mechanism** — it is a convenience *adapter* over the same "not authenticated" outcome the gate already produces. It only changes the **representation** of that outcome on a page route (a browser-friendly login redirect) instead of the JSON `401`; the decision that the request is unauthenticated is unchanged. The sanctioned *generic* way to make authentication soft is the app-owned optional-auth middleware below — `pageAuth` is the built-in adapter for the common "redirect a logged-out human to /login" case, so most apps never need to hand-roll it.
1309
+
1310
+ ## Reading the user in a controller
1311
+
1312
+ `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:
1313
+
1314
+ ```ts title="src/app/users/request-user.ts"
1315
+ import type { User } from "app/users/models/user/user.model";
1316
+
1317
+ declare module "@warlock.js/auth" {
1318
+ interface RequestUser extends User {}
1319
+ }
1320
+ ```
1321
+
1322
+ ```ts
1323
+ import { type RequestHandler } from "@warlock.js/core";
1324
+
1325
+ export const accountController: RequestHandler = async ({ request, response }) => {
1326
+ const user = request.locals.user!; // typed via the RequestUser augmentation above
1327
+ return response.success({
1328
+ id: user.id,
1329
+ email: user.get("email"),
1330
+ });
1331
+ };
1332
+ ```
1333
+
1334
+ 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.
1335
+
1336
+ ## Route-group protection
1337
+
1338
+ ```ts
1339
+ router.group({ prefix: "/admin", middleware: [authMiddleware("admin")] }, () => {
1340
+ router.get("/users", listUsersController);
1341
+ router.post("/users", createUserController);
1342
+ });
1343
+ ```
1344
+
1345
+ 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.
1346
+
1347
+ ## Optional auth is an app-owned middleware (the sanctioned pattern)
1348
+
1349
+ `authMiddleware` is a **hard gate** — it always requires a valid token and rejects when one is absent. There is deliberately no built-in "hydrate `request.locals.user` if a token is present, otherwise continue" mode, because the *sanctioned generic pattern* for soft/optional auth is a small **app-owned optional-auth middleware**: resolve the user when a valid token is present, and otherwise leave the route to decide. The app owns it because the "what to do when absent" policy is the app's, not the framework's.
1350
+
1351
+ ```ts title="src/app/middleware/optional-auth.middleware.ts"
1352
+ import { type Middleware } from "@warlock.js/core";
1353
+ import { jwt } from "@warlock.js/auth";
1354
+
1355
+ // The one sanctioned optional-auth shape: resolve if present, never reject.
1356
+ // Middleware receives the context object ({ request, response }), like a handler.
1357
+ export const optionalAuth: Middleware = async ({ request }) => {
1358
+ const token = request.authorizationValue;
1359
+ if (!token) return; // absent → continue anonymously; the route decides
1360
+ try {
1361
+ const decoded = await jwt.verify(token);
1362
+ // hydrate request.locals.user from your token/user model when valid
1363
+ } catch {
1364
+ // invalid token on an optional route → treat as anonymous, don't reject
1365
+ }
1366
+ // returning nothing (undefined) === continue to the handler
1367
+ };
1368
+ ```
1369
+
1370
+ Wire it like any middleware (`{ middleware: [optionalAuth] }`), then branch on `request.locals.user` in the controller. Use `authMiddleware` when the route must be gated; use this when the route is public but personalizes for a signed-in user. `auth.pageAuth` (above) is the built-in **adapter** over this same "unauthenticated" outcome for the common page-redirect case — it is not a competing mechanism.
1371
+
1372
+ ## Custom error responses
1373
+
1374
+ The middleware uses the framework's `response.unauthorized({...})` shape. To override the response globally, hook the framework's error transformer to remap `AuthErrorCodes.*` codes.
1375
+
1376
+ ## When flat user types aren't enough — reach for `@warlock.js/access`
1377
+
1378
+ `authMiddleware` gates on **user type** by flat string membership. That answers "is this an admin?" and nothing more. It deliberately cannot express:
1379
+
1380
+ - a permission matrix (`articles.publish` rather than `"editor"`)
1381
+ - role hierarchy or inheritance
1382
+ - **who may act on whom** — "an `admin` may create a `teacher`, but only a `superAdmin` may create or promote an `admin`"
1383
+
1384
+ 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.
1385
+
1386
+ 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:
1387
+
1388
+ ```ts
1389
+ import { router, type RequestHandler } from "@warlock.js/core";
1390
+ import { authMiddleware } from "@warlock.js/auth";
1391
+ import { gate, can, definePolicy } from "@warlock.js/access";
1392
+ import { User } from "app/users/models/user/user.model";
1393
+
1394
+ const articlesController: RequestHandler = async ({ request, response }) => {
1395
+ // Who-may-act-on-whom: an ABAC policy on top of the RBAC grant
1396
+ definePolicy("users.create", (actor, target, ctx) =>
1397
+ ctx.hasRole("superAdmin") || (target as User).userType === "teacher",
1398
+ );
1399
+
1400
+ if (await can(request.locals.user!, "users.create", { resource: request.body })) {
1401
+ /* ... */
1402
+ }
1403
+
1404
+ return response.success({});
1405
+ };
1406
+
1407
+ // Permission-based route gate, in place of a user-type gate
1408
+ router.post("/articles", articlesController, { middleware: [authMiddleware([]), gate("articles.create")] });
1409
+ ```
1410
+
1411
+ 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.
1412
+
1413
+ ## Cookie sessions: `authMiddleware([], "cookie:<name>")`
1414
+
1415
+ `authMiddleware`'s second argument, `tokenFrom`, selects the credential source and defaults to `"header"`. Pass `` `cookie:<name>` `` to read the credential from a named cookie instead of the `Authorization` header — the same gate, the same three checks, the same `request.locals.user` / `request.decodedAccessToken` outcome, just a different place to find the token:
1416
+
1417
+ ```ts
1418
+ router.get("/browser-account", accountController, {
1419
+ middleware: [authMiddleware([], "cookie:token")],
1420
+ });
1421
+ ```
1422
+
1423
+ 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).
1424
+
1425
+ ### Writing and clearing the cookie: `authService.setAuthCookie` / `clearAuthCookie`
1426
+
1427
+ 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.
1428
+
1429
+ ```ts
1430
+ import { type RequestHandler } from "@warlock.js/core";
1431
+ import { authService } from "@warlock.js/auth";
1432
+ import { User } from "app/users/models/user/user.model";
1433
+
1434
+ export const loginController: RequestHandler = async ({ request, response }) => {
1435
+ const result = await authService.login(User, {
1436
+ email: request.input("email"),
1437
+ password: request.input("password"),
1438
+ });
1439
+ if (!result) return response.unauthorized({ error: "Invalid credentials" });
1440
+
1441
+ const { user, tokens } = result;
1442
+ authService.setAuthCookie(response, tokens.accessToken); // Max-Age derived from tokens.accessToken.expiresAt
1443
+
1444
+ return response.success({ id: user.id });
1445
+ };
1446
+
1447
+ export const logoutController: RequestHandler = async ({ request, response }) => {
1448
+ const user = request.locals.user!;
1449
+ await authService.logout(user, request.header("authorization"));
1450
+ authService.clearAuthCookie(response);
1451
+
1452
+ return response.success({});
1453
+ };
1454
+ ```
1455
+
1456
+ `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.
1457
+
1458
+ 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:
1459
+
1460
+ ```ts title="src/config/auth.ts"
1461
+ import { User } from "app/users/models/user/user.model";
1462
+
1463
+ export default {
1464
+ userType: { user: User },
1465
+ cookie: {
1466
+ name: "access_token", // must match the `cookie:<name>` a route reads
1467
+ path: "/",
1468
+ },
1469
+ };
1470
+ ```
1471
+
1472
+ The cookie name `setAuthCookie`/`clearAuthCookie` write **must match** the name a `cookie:<name>` token source reads — they are not linked automatically.
1473
+
1474
+ ### CSRF: an automatic Origin check on cookie-authenticated writes
1475
+
1476
+ 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`.
1477
+
1478
+ 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:
1479
+
1480
+ - **Allowed** when `Origin` equals the request's own origin, or is listed in `auth.csrf.allowedOrigins` (config, default `[]`).
1481
+ - If `Origin` is absent, `Referer`'s origin is checked the same way.
1482
+ - If **both** are absent, or the check fails, the request is rejected with `403` and `errorCode: AuthErrorCodes.CsrfOriginMismatch` ("EC006") — never silently passed through.
1483
+ - **Header-token auth is completely unaffected**, as are safe methods (`GET`/`HEAD`/`OPTIONS`) under cookie auth.
1484
+
1485
+ ```ts title="src/config/auth.ts"
1486
+ import { User } from "app/users/models/user/user.model";
1487
+
1488
+ export default {
1489
+ userType: { user: User },
1490
+ csrf: {
1491
+ allowedOrigins: ["https://admin.example.com"], // extra trusted origins, beyond same-origin
1492
+ },
1493
+ };
1494
+ ```
1495
+
1496
+ 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.
1497
+
1498
+ ## Things NOT to do
1499
+
1500
+ - 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).
1501
+ - Don't manually decode JWTs in the controller. The middleware already does it and exposes the decoded payload via `request.decodedAccessToken`.
1502
+ - 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.
1503
+ - 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.
1504
+
1505
+ ## See also
1506
+
1507
+ - [`@warlock.js/auth/customize-user-type/SKILL.md`](@warlock.js/auth/customize-user-type/SKILL.md) — config and multi-user-type semantics
1508
+ - [`@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
1509
+ - [`@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
1510
+ - [`@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
1230
1511
 
1231
1512
 
1232
1513
  ## register-user `@warlock.js/auth/register-user/SKILL.md`
@@ -1240,12 +1521,26 @@ description: 'Sign up a new user and issue the initial token pair — User.creat
1240
1521
 
1241
1522
  Two-step on the server: create the user (with hashed password), then issue tokens. Cascade handles the persistence; `authService` handles the tokens.
1242
1523
 
1524
+ ```ts title="src/app/users/models/user/user.model.ts"
1525
+ import { Auth } from "@warlock.js/auth";
1526
+ import { RegisterModel } from "@warlock.js/cascade";
1527
+
1528
+ @RegisterModel()
1529
+ export class User extends Auth {
1530
+ public static table = "users";
1531
+
1532
+ public get userType(): string {
1533
+ return "user";
1534
+ }
1535
+ }
1536
+ ```
1537
+
1243
1538
  ## The minimal shape
1244
1539
 
1245
1540
  ```ts
1246
1541
  import { authService } from "@warlock.js/auth";
1247
1542
  import { hashPassword, type RequestHandler } from "@warlock.js/core";
1248
- import { User } from "@/app/users/models/user.model";
1543
+ import { User } from "app/users/models/user/user.model";
1249
1544
 
1250
1545
  export const registerController: RequestHandler = async ({ request, response }) => {
1251
1546
  const { email, password, name } = request.all();
@@ -1473,10 +1768,24 @@ Unlike `auth.cleanup`, do **not** schedule it: it is a full table scan (it must
1473
1768
 
1474
1769
  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:
1475
1770
 
1771
+ ```ts title="src/app/users/models/user/user.model.ts"
1772
+ import { Auth } from "@warlock.js/auth";
1773
+ import { RegisterModel } from "@warlock.js/cascade";
1774
+
1775
+ @RegisterModel()
1776
+ export class User extends Auth {
1777
+ public static table = "users";
1778
+
1779
+ public get userType(): string {
1780
+ return "user";
1781
+ }
1782
+ }
1783
+ ```
1784
+
1476
1785
  ```ts
1477
1786
  import { command } from "@warlock.js/core";
1478
1787
  import { authService } from "@warlock.js/auth";
1479
- import { User } from "@/app/users/models/user.model";
1788
+ import { User } from "app/users/models/user/user.model";
1480
1789
 
1481
1790
  export function registerDeepCleanupCommand() {
1482
1791
  return command({
@@ -1490,6 +1799,7 @@ export function registerDeepCleanupCommand() {
1490
1799
  action: async () => {
1491
1800
  await authService.cleanupExpiredTokens();
1492
1801
 
1802
+ const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
1493
1803
  const stale = await User.where("last_seen_at", "<", thirtyDaysAgo).get();
1494
1804
 
1495
1805
  for (const user of stale) {