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