@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.
- package/CHANGELOG.md +22 -0
- package/esm/commands/auth-cleanup-command.mjs +1 -1
- package/esm/commands/auth-purge-never-expiring-command.mjs +1 -1
- package/esm/contracts/index.d.mts +1 -1
- package/esm/contracts/providers.d.mts +59 -1
- package/esm/contracts/providers.d.mts.map +1 -1
- package/esm/index.d.mts +11 -2
- package/esm/index.mjs +11 -1
- package/esm/localization/ar.translations.mjs +16 -0
- package/esm/localization/ar.translations.mjs.map +1 -0
- package/esm/localization/auth-default-translations.d.mts +10 -0
- package/esm/localization/auth-default-translations.d.mts.map +1 -0
- package/esm/localization/auth-default-translations.mjs +15 -0
- package/esm/localization/auth-default-translations.mjs.map +1 -0
- package/esm/localization/auth-translations.type.d.mts +27 -0
- package/esm/localization/auth-translations.type.d.mts.map +1 -0
- package/esm/localization/en.translations.mjs +16 -0
- package/esm/localization/en.translations.mjs.map +1 -0
- package/esm/localization/index.mjs +4 -0
- package/esm/localization/init.mjs +8 -0
- package/esm/localization/init.mjs.map +1 -0
- package/esm/localization/register-auth-translations.d.mts +17 -0
- package/esm/localization/register-auth-translations.d.mts.map +1 -0
- package/esm/localization/register-auth-translations.mjs +29 -0
- package/esm/localization/register-auth-translations.mjs.map +1 -0
- package/esm/middleware/auth.middleware.d.mts.map +1 -1
- package/esm/middleware/auth.middleware.mjs +4 -2
- package/esm/middleware/auth.middleware.mjs.map +1 -1
- package/esm/middleware/csrf-origin-check.d.mts +9 -0
- package/esm/middleware/csrf-origin-check.d.mts.map +1 -1
- package/esm/middleware/csrf-origin-check.mjs +12 -56
- package/esm/middleware/csrf-origin-check.mjs.map +1 -1
- package/esm/middleware/localized-login-path.mjs +48 -0
- package/esm/middleware/localized-login-path.mjs.map +1 -0
- package/esm/models/access-token/access-token.model.d.mts +7 -0
- package/esm/models/access-token/access-token.model.d.mts.map +1 -1
- package/esm/models/access-token/access-token.model.mjs +3 -0
- package/esm/models/access-token/access-token.model.mjs.map +1 -1
- package/esm/models/one-time-token/one-time-token.model.d.mts +6 -0
- package/esm/models/one-time-token/one-time-token.model.d.mts.map +1 -1
- package/esm/models/one-time-token/one-time-token.model.mjs +3 -0
- package/esm/models/one-time-token/one-time-token.model.mjs.map +1 -1
- package/esm/models/refresh-token/refresh-token.model.d.mts +7 -0
- package/esm/models/refresh-token/refresh-token.model.d.mts.map +1 -1
- package/esm/models/refresh-token/refresh-token.model.mjs +3 -0
- package/esm/models/refresh-token/refresh-token.model.mjs.map +1 -1
- package/esm/providers/apple-provider.d.mts +74 -0
- package/esm/providers/apple-provider.d.mts.map +1 -0
- package/esm/providers/apple-provider.mjs +157 -0
- package/esm/providers/apple-provider.mjs.map +1 -0
- package/esm/providers/discord-provider.d.mts +25 -0
- package/esm/providers/discord-provider.d.mts.map +1 -0
- package/esm/providers/discord-provider.mjs +84 -0
- package/esm/providers/discord-provider.mjs.map +1 -0
- package/esm/providers/facebook-provider.d.mts +36 -0
- package/esm/providers/facebook-provider.d.mts.map +1 -0
- package/esm/providers/facebook-provider.mjs +94 -0
- package/esm/providers/facebook-provider.mjs.map +1 -0
- package/esm/providers/github-provider.d.mts +31 -0
- package/esm/providers/github-provider.d.mts.map +1 -0
- package/esm/providers/github-provider.mjs +115 -0
- package/esm/providers/github-provider.mjs.map +1 -0
- package/esm/providers/index.d.mts +7 -1
- package/esm/providers/index.mjs +6 -0
- package/esm/providers/linkedin-provider.d.mts +35 -0
- package/esm/providers/linkedin-provider.d.mts.map +1 -0
- package/esm/providers/linkedin-provider.mjs +108 -0
- package/esm/providers/linkedin-provider.mjs.map +1 -0
- package/esm/providers/provider-login.d.mts +2 -1
- package/esm/providers/provider-login.d.mts.map +1 -1
- package/esm/providers/provider-login.mjs +47 -7
- package/esm/providers/provider-login.mjs.map +1 -1
- package/esm/providers/provider-state-cookie.mjs +35 -16
- package/esm/providers/provider-state-cookie.mjs.map +1 -1
- package/esm/providers/x-provider.d.mts +35 -0
- package/esm/providers/x-provider.d.mts.map +1 -0
- package/esm/providers/x-provider.mjs +101 -0
- package/esm/providers/x-provider.mjs.map +1 -0
- package/esm/services/auth-config.mjs +16 -1
- package/esm/services/auth-config.mjs.map +1 -1
- package/esm/services/auth.service.mjs +1 -1
- package/esm/services/generate-jwt-secret.mjs +1 -1
- package/llms-full.txt +1075 -765
- package/llms.txt +5 -5
- package/package.json +8 -8
- package/skills/auth-basics/SKILL.md +16 -1
- package/skills/customize-token-storage/SKILL.md +160 -122
- package/skills/customize-user-type/SKILL.md +168 -142
- package/skills/handle-login-and-logout/SKILL.md +179 -165
- package/skills/login-with-providers/SKILL.md +131 -2
- package/skills/protect-routes/SKILL.md +308 -249
- package/skills/register-user/SKILL.md +15 -1
- package/skills/run-auth-commands/SKILL.md +16 -1
- 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 {
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
259
|
-
export
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
};
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
-
|
|
377
|
-
-
|
|
378
|
-
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
{
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
##
|
|
1085
|
+
## JWT helpers
|
|
1095
1086
|
|
|
1096
|
-
|
|
1087
|
+
For low-level JWT signing/verification (outside the authService flow):
|
|
1097
1088
|
|
|
1098
|
-
```ts
|
|
1099
|
-
import { type Middleware } from "@warlock.js/core";
|
|
1089
|
+
```ts
|
|
1100
1090
|
import { jwt } from "@warlock.js/auth";
|
|
1101
1091
|
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1116
|
+
## See also
|
|
1134
1117
|
|
|
1135
|
-
|
|
1136
|
-
|
|
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
|
-
|
|
1139
|
-
router.post("/articles", articlesController, { middleware: [authMiddleware([]), gate("articles.create")] });
|
|
1130
|
+
# `@warlock.js/auth` — overview
|
|
1140
1131
|
|
|
1141
|
-
|
|
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
|
-
|
|
1147
|
-
```
|
|
1134
|
+
Coupled to `@warlock.js/core` — you're inside a Warlock project before this package makes sense.
|
|
1148
1135
|
|
|
1149
|
-
|
|
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
|
-
##
|
|
1138
|
+
## When to reach for it
|
|
1152
1139
|
|
|
1153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1146
|
+
## The mental model in one paragraph
|
|
1162
1147
|
|
|
1163
|
-
|
|
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
|
-
|
|
1150
|
+
## Skills index
|
|
1166
1151
|
|
|
1167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1175
|
-
|
|
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
|
-
|
|
1159
|
+
### The flows
|
|
1180
1160
|
|
|
1181
|
-
|
|
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
|
-
|
|
1184
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
1170
|
+
### Going deeper
|
|
1196
1171
|
|
|
1197
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1202
|
-
|
|
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
|
-
|
|
1207
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
1187
|
+
## What this package deliberately doesn't do
|
|
1218
1188
|
|
|
1219
|
-
-
|
|
1220
|
-
-
|
|
1221
|
-
-
|
|
1222
|
-
-
|
|
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/
|
|
1227
|
-
- [`@warlock.js/
|
|
1228
|
-
-
|
|
1229
|
-
|
|
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 "
|
|
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 "
|
|
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) {
|