@zerotal/arch 1.7.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 +31 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/api-surface.md +283 -0
- package/docs/about.md +676 -0
- package/docs/admin/actions.md +183 -0
- package/docs/admin/auth.md +89 -0
- package/docs/admin/dashboard.md +155 -0
- package/docs/admin/extending-ui.md +229 -0
- package/docs/admin/extending.md +205 -0
- package/docs/admin/forms.md +155 -0
- package/docs/admin/index.md +144 -0
- package/docs/admin/operations.md +224 -0
- package/docs/admin/references.md +174 -0
- package/docs/admin/resources.md +132 -0
- package/docs/admin/structure.md +158 -0
- package/docs/admin/tables.md +185 -0
- package/docs/admin/testing.md +138 -0
- package/docs/ai.md +373 -0
- package/docs/application.md +435 -0
- package/docs/arch.md +263 -0
- package/docs/assets.md +755 -0
- package/docs/audit.md +367 -0
- package/docs/authentication.md +1000 -0
- package/docs/authorization.md +529 -0
- package/docs/broadcasting/channels.md +170 -0
- package/docs/broadcasting/client.md +108 -0
- package/docs/broadcasting/events.md +190 -0
- package/docs/broadcasting/index.md +163 -0
- package/docs/broadcasting/references.md +84 -0
- package/docs/broadcasting/testing.md +132 -0
- package/docs/cache.md +387 -0
- package/docs/carbon.md +830 -0
- package/docs/changelog.md +465 -0
- package/docs/client/auth.md +113 -0
- package/docs/client/errors.md +139 -0
- package/docs/client/files.md +118 -0
- package/docs/client/index.md +128 -0
- package/docs/client/references.md +58 -0
- package/docs/client/requests.md +131 -0
- package/docs/client/resilience.md +141 -0
- package/docs/client/testing.md +146 -0
- package/docs/commands.md +450 -0
- package/docs/components.md +2427 -0
- package/docs/config-system.md +258 -0
- package/docs/container.md +596 -0
- package/docs/context.md +579 -0
- package/docs/contributing.md +164 -0
- package/docs/controllers.md +354 -0
- package/docs/conventions.md +466 -0
- package/docs/cookies.md +101 -0
- package/docs/csrf.md +206 -0
- package/docs/database.md +476 -0
- package/docs/deployment.md +434 -0
- package/docs/devtools.md +1013 -0
- package/docs/email-verification.md +320 -0
- package/docs/encryption.md +380 -0
- package/docs/errors.md +317 -0
- package/docs/events.md +420 -0
- package/docs/flow/components.md +846 -0
- package/docs/flow/decorators.md +458 -0
- package/docs/flow/events.md +420 -0
- package/docs/flow/forms.md +551 -0
- package/docs/flow/index.md +568 -0
- package/docs/flow/layouts.md +793 -0
- package/docs/flow/lifecycle.md +336 -0
- package/docs/flow/pagination.md +402 -0
- package/docs/flow/performance.md +262 -0
- package/docs/flow/references.md +317 -0
- package/docs/flow/routing.md +550 -0
- package/docs/flow/testing.md +447 -0
- package/docs/getting-started.md +286 -0
- package/docs/health.md +269 -0
- package/docs/helpers.md +423 -0
- package/docs/i18n.md +414 -0
- package/docs/index.md +65 -0
- package/docs/inertia/build.md +95 -0
- package/docs/inertia/devtools.md +197 -0
- package/docs/inertia/index.md +267 -0
- package/docs/inertia/middleware.md +81 -0
- package/docs/inertia/props.md +507 -0
- package/docs/inertia/references.md +144 -0
- package/docs/inertia/rendering.md +189 -0
- package/docs/inertia/ssr.md +102 -0
- package/docs/inspirations.md +257 -0
- package/docs/lifecycle.md +331 -0
- package/docs/lock.md +454 -0
- package/docs/logger.md +517 -0
- package/docs/media.md +582 -0
- package/docs/middleware.md +509 -0
- package/docs/migrations.md +529 -0
- package/docs/monitor.md +377 -0
- package/docs/notifications.md +848 -0
- package/docs/orm/casts.md +365 -0
- package/docs/orm/factories.md +231 -0
- package/docs/orm/index.md +663 -0
- package/docs/orm/lifecycle.md +421 -0
- package/docs/orm/queries.md +692 -0
- package/docs/orm/relationships.md +440 -0
- package/docs/orm/serialization.md +251 -0
- package/docs/package-development.md +536 -0
- package/docs/pagination.md +340 -0
- package/docs/password-reset.md +335 -0
- package/docs/providers.md +447 -0
- package/docs/query-builder.md +465 -0
- package/docs/queue.md +549 -0
- package/docs/rate-limiting.md +253 -0
- package/docs/responses.md +336 -0
- package/docs/roles-and-2fa.md +418 -0
- package/docs/routing.md +1008 -0
- package/docs/scaffolding.md +335 -0
- package/docs/scheduler.md +533 -0
- package/docs/seeding.md +229 -0
- package/docs/session.md +451 -0
- package/docs/social.md +524 -0
- package/docs/storage.md +622 -0
- package/docs/structure.md +230 -0
- package/docs/support-policy.md +114 -0
- package/docs/telemetry.md +505 -0
- package/docs/tenancy.md +608 -0
- package/docs/testing/browser.md +153 -0
- package/docs/testing/console.md +130 -0
- package/docs/testing/database.md +238 -0
- package/docs/testing/flow-browser.md +216 -0
- package/docs/testing/http.md +441 -0
- package/docs/testing/index.md +240 -0
- package/docs/testing/mocking.md +410 -0
- package/docs/upgrade.md +192 -0
- package/docs/validator.md +549 -0
- package/docs/view.md +453 -0
- package/package.json +60 -0
- package/src/bin/mcp.ts +73 -0
- package/src/config.ts +63 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +56 -0
- package/src/install/ArchInstallCommand.ts +189 -0
- package/src/install/detect.ts +98 -0
- package/src/install/guidelines.ts +290 -0
- package/src/install/markers.ts +77 -0
- package/src/install/mcpConfig.ts +81 -0
- package/src/mcp/index.ts +27 -0
- package/src/mcp/jsonrpc.ts +133 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/stdio.ts +111 -0
- package/src/mcp/types.ts +146 -0
- package/src/probe/ArchProbeCommand.ts +48 -0
- package/src/probe/sentinel.ts +13 -0
- package/src/probe/topics.ts +291 -0
- package/src/provider/ArchProvider.ts +101 -0
- package/src/tools/_probe.ts +164 -0
- package/src/tools/apiSurface.ts +241 -0
- package/src/tools/appInfo.ts +88 -0
- package/src/tools/baselines.ts +210 -0
- package/src/tools/context.ts +37 -0
- package/src/tools/doctor.ts +93 -0
- package/src/tools/index.ts +50 -0
- package/src/tools/logs.ts +265 -0
- package/src/tools/routes.ts +133 -0
- package/src/tools/schema.ts +119 -0
- package/src/tools/searchDocs.ts +345 -0
|
@@ -0,0 +1,1000 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Authentication
|
|
3
|
+
description: Sessions, login, guards, API tokens, passwordless login, and testing — the whole authentication surface on one page.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Authentication
|
|
7
|
+
|
|
8
|
+
`@zerotal/auth` works out who is making each request — verifying credentials, persisting the user across requests, and exposing them as `ctx.user` / `Auth.user()`. It bundles session login, personal access tokens, passwordless magic links, social OAuth, and RFC 6238 two-factor authentication.
|
|
9
|
+
|
|
10
|
+
It builds on top of [`@zerotal/session`](/docs/session), so register both providers.
|
|
11
|
+
|
|
12
|
+
## Getting Started
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
# in your project root
|
|
16
|
+
bun add @zerotal/session @zerotal/auth
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Register the provider
|
|
20
|
+
|
|
21
|
+
Add `SessionProvider` and `AuthProvider` to the providers array in `bootstrap/providers.ts`:
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
// bootstrap/providers.ts
|
|
25
|
+
import { SessionProvider } from "@zerotal/session";
|
|
26
|
+
import { AuthProvider } from "@zerotal/auth";
|
|
27
|
+
|
|
28
|
+
export default [
|
|
29
|
+
// …other providers
|
|
30
|
+
SessionProvider,
|
|
31
|
+
AuthProvider,
|
|
32
|
+
];
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Registering `AuthProvider` switches on the following (in lifecycle order):
|
|
36
|
+
|
|
37
|
+
- `onRegister` — binds the `hash`, `gate`, and `two_factor` singletons, and registers schema/policy auto-discovery concerns.
|
|
38
|
+
- `onBooting` — resolves the user loader, binds it as `auth.userLoader`, then registers `PersistUserMiddleware` globally via `app.useOnce()` so `ctx.user` is populated on every request.
|
|
39
|
+
- `onBooted` — installs `HttpContext.authorize()` and registers the `make:policy` and `auth:sync-permissions` commands.
|
|
40
|
+
|
|
41
|
+
> **Note** — `PersistUserMiddleware` is the _populate_ step (it never blocks a request). Requiring a signed-in user on a route is a separate, opt-in concern — see [Route protection](#the-auth-facade).
|
|
42
|
+
|
|
43
|
+
## Configuration
|
|
44
|
+
|
|
45
|
+
Create `config/auth.ts` with the `AuthConfig()` helper so every field stays type-checked:
|
|
46
|
+
|
|
47
|
+
```typescript
|
|
48
|
+
// config/auth.ts
|
|
49
|
+
import { AuthConfig } from "@zerotal/auth";
|
|
50
|
+
|
|
51
|
+
export default AuthConfig({
|
|
52
|
+
algorithm: "argon2id",
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
| Field | Required | Default | Description |
|
|
57
|
+
| ----------------------------- | -------- | ------------ | -------------------------------------------------------------------------- |
|
|
58
|
+
| `algorithm` | no | `"argon2id"` | Password hashing algorithm — `"argon2id"` or `"bcrypt"`. |
|
|
59
|
+
| `twoFactor` | no | `undefined` | Two-factor options (see [Two-Factor Authentication](/docs/roles-and-2fa)). |
|
|
60
|
+
| `twoFactor.issuer` | no | `"Zerotal"` | Issuer name shown in the authenticator app. |
|
|
61
|
+
| `twoFactor.window` | no | `1` | TOTP periods (30 s each) of clock tolerance per side. |
|
|
62
|
+
| `twoFactor.recoveryCodeCount` | no | `8` | Number of recovery codes generated. |
|
|
63
|
+
|
|
64
|
+
## Wire the User model
|
|
65
|
+
|
|
66
|
+
`AuthProvider` needs to know how to load a user from their session-stored ID. Call `AuthProvider.resolveUsing()` in `bootstrap/app.ts` **before** `Application.create()`:
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
// bootstrap/app.ts
|
|
70
|
+
import { Application, basePath } from "zerotal";
|
|
71
|
+
import { AuthProvider } from "@zerotal/auth";
|
|
72
|
+
import { User } from "../app/models/User.ts";
|
|
73
|
+
import providers from "./providers.ts";
|
|
74
|
+
|
|
75
|
+
AuthProvider.resolveUsing((id) => User.find(id));
|
|
76
|
+
|
|
77
|
+
export default Application.create({ providers }).routing({ web: basePath("routes/web.ts") });
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
> **Tip** — `resolveUsing()` is optional. When omitted, the provider falls back to a convention default that loads the registered `AuthUser` subclass — registering `AuthProvider` is enough for the common case.
|
|
81
|
+
|
|
82
|
+
The user model extends `AuthUser` instead of `Model`:
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
// app/models/User.ts
|
|
86
|
+
import { column, table } from "@zerotal/orm";
|
|
87
|
+
import { AuthUser } from "@zerotal/auth";
|
|
88
|
+
|
|
89
|
+
@(table("users").withTimestamps())
|
|
90
|
+
export class User extends AuthUser {
|
|
91
|
+
@column() name!: string;
|
|
92
|
+
@column() email!: string;
|
|
93
|
+
@column() password!: string; // always stored hashed — see /docs/encryption
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`AuthUser` provides `getAuthId()` (returns `this.id`) and `getAuthPassword()` (returns `this.password`). Make `Auth.user()` return your concrete type by augmenting `UserModel` once:
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
// bootstrap/app.ts (or any file imported at boot)
|
|
101
|
+
import type { User } from "../app/models/User.ts";
|
|
102
|
+
|
|
103
|
+
declare module "@zerotal/auth" {
|
|
104
|
+
interface UserModel extends User {}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## How it works
|
|
109
|
+
|
|
110
|
+
On every request `PersistUserMiddleware` reads `user_id` from the session and populates `ctx.user`. If `user_id` is present but the user no longer exists, the stale key is cleared.
|
|
111
|
+
|
|
112
|
+
| Situation | `ctx.user` |
|
|
113
|
+
| ----------------------------- | ------------------------------ |
|
|
114
|
+
| No session / no `user_id` | `undefined` (guest) |
|
|
115
|
+
| Valid `user_id`, user found | User model instance |
|
|
116
|
+
| `user_id` found, user deleted | `undefined` (key auto-cleared) |
|
|
117
|
+
|
|
118
|
+
## The Auth facade
|
|
119
|
+
|
|
120
|
+
`Auth` reads the current user from async local storage — use it from controllers, services, or anywhere in the request tree:
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
// in a controller or service
|
|
124
|
+
import { Auth } from "@zerotal/auth";
|
|
125
|
+
|
|
126
|
+
Auth.check(); // boolean — true if authenticated
|
|
127
|
+
Auth.guest(); // boolean — inverse of check()
|
|
128
|
+
Auth.user(); // UserModel — throws UnauthorizedError for guests
|
|
129
|
+
Auth.userOrNull(); // UserModel | undefined — safe version
|
|
130
|
+
Auth.id(); // number — throws for guests
|
|
131
|
+
|
|
132
|
+
await Auth.login(user); // write user_id to session, set ctx.user
|
|
133
|
+
await Auth.logout(); // clear user_id from session, unset ctx.user
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`Auth.attempt()` rolls credential lookup, password verification, and login into one call:
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
function attempt(credentials: Credentials, remember?: boolean): Promise<boolean>;
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
// in a controller
|
|
144
|
+
if (await Auth.attempt({ email, password })) {
|
|
145
|
+
ctx.redirect("/dashboard", 303);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
ctx.flash("errors", { email: ["These credentials do not match our records."] });
|
|
149
|
+
ctx.redirect("/login", 303);
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
> **Tip** — `Auth.attempt()` finds the user by every credential _except_ `password`, then checks the password against the stored hash — so the verbose manual lookup below is optional. Use `Auth.validate()` to check credentials without logging in, or `Auth.once()` to authenticate for a single request without touching the session.
|
|
153
|
+
|
|
154
|
+
### Route protection
|
|
155
|
+
|
|
156
|
+
#### AuthMiddleware
|
|
157
|
+
|
|
158
|
+
`AuthMiddleware` is the built-in guard — the inverse of `GuestMiddleware`. It lets authenticated requests through, returns `401` JSON for API clients, and redirects HTML guests to `/login` (saving the originating URL to the session as `intended_url`):
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
// routes/web.ts
|
|
162
|
+
import { AuthMiddleware } from "@zerotal/auth";
|
|
163
|
+
import { Router } from "zerotal";
|
|
164
|
+
|
|
165
|
+
Router.group({ prefix: "/app", middleware: [AuthMiddleware] }, () => {
|
|
166
|
+
Router.get("/dashboard", DashboardController, "index");
|
|
167
|
+
Router.get("/profile", ProfileController, "show");
|
|
168
|
+
Router.put("/profile", ProfileController, "update");
|
|
169
|
+
});
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Override the redirect target, or also require a verified email:
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
// routes/web.ts
|
|
176
|
+
AuthMiddleware.with({ redirectTo: "/sign-in" });
|
|
177
|
+
AuthMiddleware.with({ mustVerifyEmail: true, verifyRedirectTo: "/confirm-email" });
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
| Option | Default | Description |
|
|
181
|
+
| ------------------ | ----------------- | --------------------------------------------------------------------------------------------- |
|
|
182
|
+
| `redirectTo` | `"/login"` | Where HTML guests are sent. |
|
|
183
|
+
| `mustVerifyEmail` | `false` | Also require a verified email (no-op without [Email Verification](/docs/email-verification)). |
|
|
184
|
+
| `verifyRedirectTo` | `"/verify-email"` | Where unverified users are sent when `mustVerifyEmail` is on. |
|
|
185
|
+
|
|
186
|
+
> **Note** — After a guest logs in, send them back to where they were headed with `redirect().intended()` / `url().intended()`, which reads the `intended_url` session key.
|
|
187
|
+
|
|
188
|
+
#### GuestMiddleware
|
|
189
|
+
|
|
190
|
+
Redirects authenticated users away from login/register pages:
|
|
191
|
+
|
|
192
|
+
```typescript
|
|
193
|
+
// routes/web.ts
|
|
194
|
+
import { GuestMiddleware } from "@zerotal/auth";
|
|
195
|
+
|
|
196
|
+
Router.get("/login", AuthController, "showLogin", [GuestMiddleware]);
|
|
197
|
+
Router.post("/login", AuthController, "login", [GuestMiddleware]);
|
|
198
|
+
Router.get("/register", AuthController, "showRegister", [GuestMiddleware]);
|
|
199
|
+
Router.post("/register", AuthController, "register", [GuestMiddleware]);
|
|
200
|
+
|
|
201
|
+
// Custom redirect target (default: '/')
|
|
202
|
+
Router.get("/login", AuthController, "showLogin", [
|
|
203
|
+
GuestMiddleware.with({ redirectTo: "/dashboard" }),
|
|
204
|
+
]);
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Between them, `AuthMiddleware` protects pages and APIs that require a signed-in
|
|
208
|
+
user and `GuestMiddleware` keeps signed-in users off the login and register
|
|
209
|
+
routes. Requests that carry no session — API clients, mobile apps, internal
|
|
210
|
+
endpoints — are handled by the guards covered in
|
|
211
|
+
[Which guard do I use?](#which-guard-do-i-use) below.
|
|
212
|
+
|
|
213
|
+
## Login, logout & registration
|
|
214
|
+
|
|
215
|
+
> **Note** — Password hashing is covered in [Encryption & Hashing](/docs/encryption); password reset in [Password Reset](/docs/password-reset).
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
// app/controllers/AuthController.ts
|
|
219
|
+
import { Auth, Hash } from "@zerotal/auth";
|
|
220
|
+
import type { HttpContext } from "zerotal";
|
|
221
|
+
import { User } from "#app/models/User.ts";
|
|
222
|
+
|
|
223
|
+
export class AuthController {
|
|
224
|
+
showLogin(ctx: HttpContext) {
|
|
225
|
+
ctx.view(LoginPage({ errors: ctx.flashed("errors"), old: ctx.flashed("old") }));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async login(ctx: HttpContext) {
|
|
229
|
+
const { email, password } = await ctx.body<{ email: string; password: string }>();
|
|
230
|
+
|
|
231
|
+
const user = await User.query().where("email", email).first();
|
|
232
|
+
|
|
233
|
+
if (!user || !(await Hash.check(password, user.password ?? ""))) {
|
|
234
|
+
ctx.flash("errors", { email: ["These credentials do not match our records."] });
|
|
235
|
+
ctx.flash("old", { email });
|
|
236
|
+
ctx.redirect("/login", 303);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
ctx.session.regenerate();
|
|
241
|
+
await Auth.login(user);
|
|
242
|
+
|
|
243
|
+
const intended = ctx.session.get("intended_url") as string | undefined;
|
|
244
|
+
ctx.session.forget("intended_url");
|
|
245
|
+
ctx.redirect(intended ?? "/dashboard", 303);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async logout(ctx: HttpContext) {
|
|
249
|
+
await Auth.logout();
|
|
250
|
+
ctx.session.flush();
|
|
251
|
+
ctx.session.regenerate();
|
|
252
|
+
ctx.redirect("/login", 303);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
showRegister(ctx: HttpContext) {
|
|
256
|
+
ctx.view(RegisterPage());
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async register(ctx: HttpContext) {
|
|
260
|
+
const body = await ctx.body<{ name: string; email: string; password: string }>();
|
|
261
|
+
|
|
262
|
+
if (await User.query().where("email", body.email).exists()) {
|
|
263
|
+
ctx.flash("errors", { email: ["Email already taken."] });
|
|
264
|
+
ctx.redirect("/register", 303);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const user = await User.create({
|
|
269
|
+
name: body.name,
|
|
270
|
+
email: body.email,
|
|
271
|
+
password: await Hash.make(body.password),
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
ctx.session.regenerate();
|
|
275
|
+
await Auth.login(user);
|
|
276
|
+
ctx.redirect("/dashboard", 303);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
> **Warning** — Always call `ctx.session.regenerate()` immediately after a successful login to rotate the session ID and prevent session fixation.
|
|
282
|
+
|
|
283
|
+
### Remember me
|
|
284
|
+
|
|
285
|
+
A "remember me" checkbox keeps a user signed in after their session expires. Pass `{ remember: true }` to `Auth.login()` (or as the second argument to `Auth.attempt()`) and the framework does the rest:
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
await Auth.login(user, { remember: true });
|
|
289
|
+
// or
|
|
290
|
+
await Auth.attempt({ email, password }, remember);
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Behind the scenes a high-entropy token is minted, its SHA-256 hash is stored in the user's `remember_token` column, and the raw token is written to a long-lived `remember_web` cookie. The column is provisioned automatically for every authenticatable model — you don't declare it or write a migration. On a later visit, after the session has lapsed, `RememberMeMiddleware` (registered globally by `AuthProvider`) reads the cookie, looks the user up, constant-time-compares the token against the stored hash, and signs them back in — re-seeding a fresh session.
|
|
294
|
+
|
|
295
|
+
Only the hash is persisted, so a leaked database row can't be replayed as a valid cookie. `Auth.logout()` clears the stored token and deletes the cookie, invalidating the persistent login everywhere.
|
|
296
|
+
|
|
297
|
+
When a request was restored from the cookie rather than an active session, `Auth.viaRemember()` returns `true`. Use it to demand a fresh login (or password confirmation) before sensitive actions:
|
|
298
|
+
|
|
299
|
+
```typescript
|
|
300
|
+
if (Auth.viaRemember()) {
|
|
301
|
+
return ctx.redirect("/confirm-password");
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
### Login throttling
|
|
306
|
+
|
|
307
|
+
To blunt credential-stuffing and brute-force attempts, throttle failed logins per identifier and IP. The shared `loginThrottle` limiter (5 attempts per 60 seconds by default) records misses, locks the pair out once the limit is reached, and emits a `Lockout` event you can hook for alerting:
|
|
308
|
+
|
|
309
|
+
```typescript
|
|
310
|
+
import { Auth, loginThrottle } from "@zerotal/auth";
|
|
311
|
+
|
|
312
|
+
async login(ctx: HttpContext) {
|
|
313
|
+
const { email, password } = await ctx.body<{ email: string; password: string }>();
|
|
314
|
+
|
|
315
|
+
const retryAfter = loginThrottle.ensureNotLocked(ctx, email);
|
|
316
|
+
if (retryAfter !== null) {
|
|
317
|
+
ctx.flash("errors", { email: [`Too many attempts. Try again in ${retryAfter}s.`] });
|
|
318
|
+
return ctx.redirect("/login", 303);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (await Auth.attempt({ email, password })) {
|
|
322
|
+
loginThrottle.clearFor(ctx, email); // reset the counter on success
|
|
323
|
+
ctx.session.regenerate();
|
|
324
|
+
return ctx.redirect("/dashboard", 303);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
loginThrottle.recordFailure(ctx, email); // count the miss
|
|
328
|
+
ctx.flash("errors", { email: ["These credentials do not match our records."] });
|
|
329
|
+
return ctx.redirect("/login", 303);
|
|
330
|
+
}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Tune the window with `new LoginRateLimiter({ maxAttempts, decaySeconds })` for a dedicated limiter, or listen for the lockout:
|
|
334
|
+
|
|
335
|
+
```typescript
|
|
336
|
+
FrameworkEvents.on(Lockout, ({ identifier }) => {
|
|
337
|
+
// notify the account owner, feed intrusion detection, etc.
|
|
338
|
+
});
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
### Automatic password rehashing
|
|
342
|
+
|
|
343
|
+
Hashing costs rise over time: you raise the work factor, or move from bcrypt to
|
|
344
|
+
argon2id. The stored hashes do not update themselves, and you cannot re-hash a
|
|
345
|
+
password you do not have — you only see it at login.
|
|
346
|
+
|
|
347
|
+
So that is when it happens. `Auth.attempt()` (and `attemptWhen`) compare the
|
|
348
|
+
stored hash's algorithm against `auth.algorithm`, and on a mismatch re-hash the
|
|
349
|
+
password the user just proved they know and persist it:
|
|
350
|
+
|
|
351
|
+
```typescript
|
|
352
|
+
// config/auth.ts — raise the cost, and logins migrate themselves
|
|
353
|
+
export default AuthConfig({
|
|
354
|
+
algorithm: "argon2id",
|
|
355
|
+
});
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
No code change is needed at the call site. Users are upgraded silently as they
|
|
359
|
+
sign in, and one that never returns keeps its old hash — which is correct, since
|
|
360
|
+
the account is dormant.
|
|
361
|
+
|
|
362
|
+
**It is best-effort.** A hashing or save failure never breaks the login: the user
|
|
363
|
+
gets in, and the upgrade is retried on their next sign-in. That trade is
|
|
364
|
+
deliberate — a rehash is an optimisation, and failing a login over one would be
|
|
365
|
+
an outage caused by a maintenance task.
|
|
366
|
+
|
|
367
|
+
To drive a migration rather than wait for it, check the hash yourself:
|
|
368
|
+
|
|
369
|
+
```typescript
|
|
370
|
+
// in a command or service
|
|
371
|
+
import { Hash } from "zerotal/security";
|
|
372
|
+
|
|
373
|
+
if (Hash.needsRehash(user.password)) {
|
|
374
|
+
// You still need the plaintext, so this only works at a point where you
|
|
375
|
+
// have it — a login hook, or a forced password reset.
|
|
376
|
+
await user.update({ password: await Hash.make(plaintext) });
|
|
377
|
+
}
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
> **Note** — `needsRehash()` reports whether the stored hash matches the current
|
|
381
|
+
> algorithm and cost. It cannot re-hash on its own: without the plaintext there
|
|
382
|
+
> is nothing to hash, which is the whole point of storing a hash.
|
|
383
|
+
|
|
384
|
+
### Password confirmation
|
|
385
|
+
|
|
386
|
+
Some actions — changing a password, deleting an account, viewing recovery codes — warrant re-entering the password even within an active session. Gate those routes with `ConfirmPasswordMiddleware`: it lets the request through if the user confirmed their password within the window (default 3 hours), otherwise it stores the intended URL and redirects to `/confirm-password` (or returns `423 Locked` for JSON).
|
|
387
|
+
|
|
388
|
+
```typescript
|
|
389
|
+
Router.group({ middleware: [AuthMiddleware, ConfirmPasswordMiddleware] }, () => {
|
|
390
|
+
Router.get("/settings/security", SecurityController, "show");
|
|
391
|
+
});
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
Your confirm-password route verifies the password and records the confirmation:
|
|
395
|
+
|
|
396
|
+
```typescript
|
|
397
|
+
async confirm(ctx: HttpContext) {
|
|
398
|
+
const { password } = await ctx.body<{ password: string }>();
|
|
399
|
+
if (await Auth.confirmPassword(password)) {
|
|
400
|
+
return ctx.redirect(ctx.session.get("intended_url") as string ?? "/", 303);
|
|
401
|
+
}
|
|
402
|
+
ctx.flash("errors", { password: ["Incorrect password."] });
|
|
403
|
+
return ctx.redirect("/confirm-password", 303);
|
|
404
|
+
}
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
`Auth.hasRecentlyConfirmedPassword()` checks the window manually; `Auth.markPasswordConfirmed()` records a confirmation you verified yourself.
|
|
408
|
+
|
|
409
|
+
### Logging out other devices
|
|
410
|
+
|
|
411
|
+
Let a user end their sessions on every _other_ device while staying signed in on the current one — typically offered after a password change. Attach `AuthenticateSessionMiddleware` to your authenticated routes; it binds each session to a snapshot of the user's password hash. Then call `Auth.logoutOtherDevices(currentPassword)`:
|
|
412
|
+
|
|
413
|
+
```typescript
|
|
414
|
+
Router.group({ middleware: [AuthMiddleware, AuthenticateSessionMiddleware] }, () => {
|
|
415
|
+
// ...the bulk of your authenticated routes
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// In a controller — requires the user to confirm their current password:
|
|
419
|
+
if (!(await Auth.logoutOtherDevices(currentPassword))) {
|
|
420
|
+
return back().withErrors({ password: ["Incorrect password."] });
|
|
421
|
+
}
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
It re-hashes the same password and persists it, so every other session's snapshot stops matching and `AuthenticateSessionMiddleware` tears it down on that device's next request. The mechanism is driver-agnostic — it works with cookie and Redis sessions alike, with no server-side session store. An `OtherDeviceLogout` event fires for auditing.
|
|
425
|
+
|
|
426
|
+
### Checking for compromised passwords
|
|
427
|
+
|
|
428
|
+
`isPasswordCompromised()` checks a password against the Have I Been Pwned breach corpus using the k-anonymity range API — only the first five characters of the SHA-1 hash ever leave the process. Use it during registration or password changes:
|
|
429
|
+
|
|
430
|
+
```typescript
|
|
431
|
+
import { isPasswordCompromised } from "@zerotal/auth";
|
|
432
|
+
|
|
433
|
+
if (await isPasswordCompromised(password)) {
|
|
434
|
+
return back().withErrors({ password: ["This password has appeared in a data breach."] });
|
|
435
|
+
}
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
It fails open (returns `false`) on a network error, so an outage never blocks a sign-up. Raise the `{ threshold }` option to tolerate low-frequency hits.
|
|
439
|
+
|
|
440
|
+
Failing open is the right default for a check that depends on a third party, but it
|
|
441
|
+
does make the check advisory: it cannot be the only thing standing between a weak
|
|
442
|
+
password and an account. Keep your length and complexity rules alongside it.
|
|
443
|
+
|
|
444
|
+
## Passwordless login
|
|
445
|
+
|
|
446
|
+
Both approaches here prove that someone controls an inbox and sign them in on that
|
|
447
|
+
basis, so neither needs a stored password. They differ in where the reader finishes
|
|
448
|
+
the flow, which is the thing to decide first:
|
|
449
|
+
|
|
450
|
+
- **One-time codes** keep the user on the page they started from — they read a code
|
|
451
|
+
and type it back. That survives the link-rewriting and click-tracking some mail
|
|
452
|
+
clients apply, and works when mail is read on a different device from the browser.
|
|
453
|
+
- **Magic links** ask for no typing at all, which is smoother on a phone, but the
|
|
454
|
+
session is established wherever the link is opened — including inside an email
|
|
455
|
+
client's in-app browser.
|
|
456
|
+
|
|
457
|
+
### Email OTP (passwordless codes)
|
|
458
|
+
|
|
459
|
+
`EmailOtpBroker` powers passwordless login by emailing a short numeric code. Like `PasswordBroker`, it's DB-agnostic via injected callbacks and stores only the code's hash.
|
|
460
|
+
|
|
461
|
+
```typescript
|
|
462
|
+
const otp = new EmailOtpBroker({
|
|
463
|
+
findCode: (email) => LoginCode.query().where("email", email).first(),
|
|
464
|
+
storeCode: (email, hash, expiresAt) => LoginCode.upsert({ email, code: hash, expiresAt }),
|
|
465
|
+
deleteCode: (email) => LoginCode.where("email", email).delete(),
|
|
466
|
+
// Deliver via the notifications mail channel — LoginCodeNotification implements toMail().
|
|
467
|
+
sendCode: (email, code) => Notify.send({ email }, new LoginCodeNotification(code)),
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
await otp.send(email); // emails a 6-digit code (valid 10 min)
|
|
471
|
+
if (await otp.attempt(email, submittedCode)) {
|
|
472
|
+
// true once, then the code is consumed
|
|
473
|
+
await Auth.login(await User.query().where("email", email).firstOrFail());
|
|
474
|
+
}
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
Three options tune the security trade-off, and the defaults are chosen to sit
|
|
478
|
+
together:
|
|
479
|
+
|
|
480
|
+
| Option | Default | Effect |
|
|
481
|
+
| --------------- | ------- | --------------------------------------------- |
|
|
482
|
+
| `length` | `6` | Digits in the generated code |
|
|
483
|
+
| `expireMinutes` | `10` | How long a code stays valid |
|
|
484
|
+
| `maxAttempts` | `5` | Failed guesses before the code is invalidated |
|
|
485
|
+
|
|
486
|
+
`maxAttempts` is the one not to disable. A six-digit code is one of a million
|
|
487
|
+
possibilities — trivial to exhaust by automation inside a ten-minute window, and
|
|
488
|
+
the only thing preventing that is a cap on guesses. Lengthening the code or
|
|
489
|
+
shortening its life are both reasonable adjustments; removing the attempt limit is
|
|
490
|
+
not, and lengthening the code is a poor substitute for it.
|
|
491
|
+
|
|
492
|
+
Shortening `expireMinutes` also has a cost worth weighing: mail delivery is not
|
|
493
|
+
instant, and a code that expires before it arrives reads to the user as a broken
|
|
494
|
+
login rather than a strict one.
|
|
495
|
+
|
|
496
|
+
`attempt()` returns `true` exactly once — the code is consumed on success — so a
|
|
497
|
+
replayed submission fails even inside the expiry window.
|
|
498
|
+
|
|
499
|
+
Treat a request for a code the same way you treat magic links below: respond
|
|
500
|
+
identically whether or not the address has an account, or the endpoint becomes a
|
|
501
|
+
way to discover who has registered.
|
|
502
|
+
|
|
503
|
+
### Magic link login
|
|
504
|
+
|
|
505
|
+
`MagicLinkBroker` generates signed, time-limited login URLs and establishes a session on verify.
|
|
506
|
+
|
|
507
|
+
```typescript
|
|
508
|
+
// app/auth/magicLinks.ts
|
|
509
|
+
import { MagicLinkBroker } from "@zerotal/auth";
|
|
510
|
+
import { env } from "zerotal";
|
|
511
|
+
import { Notify } from "@zerotal/notifications";
|
|
512
|
+
import { User } from "#app/models/User.ts";
|
|
513
|
+
|
|
514
|
+
export const magicLinks = new MagicLinkBroker({
|
|
515
|
+
secret: env("APP_KEY", ""),
|
|
516
|
+
verifyUrl: `${env("APP_URL", "")}/magic/verify`,
|
|
517
|
+
expiresInMinutes: 15,
|
|
518
|
+
findUser: (email) => User.where("email", email).first(),
|
|
519
|
+
// MagicLinkNotification implements toMail(); queue() sends it in the background.
|
|
520
|
+
sendLink: (email, url) => Notify.queue({ email }, new MagicLinkNotification(url)),
|
|
521
|
+
});
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
```typescript
|
|
525
|
+
// app/controllers/MagicLinkController.ts
|
|
526
|
+
import { magicLinks } from "#app/auth/magicLinks.ts";
|
|
527
|
+
import { MAGIC } from "@zerotal/auth";
|
|
528
|
+
|
|
529
|
+
export class MagicLinkController {
|
|
530
|
+
async send(ctx: HttpContext) {
|
|
531
|
+
const { email } = await ctx.body<{ email: string }>();
|
|
532
|
+
await magicLinks.sendLink(email);
|
|
533
|
+
ctx.flash("success", "Check your inbox for a login link.");
|
|
534
|
+
ctx.redirect("/login", 303);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async verify(ctx: HttpContext) {
|
|
538
|
+
const email = ctx.query("email") ?? "";
|
|
539
|
+
|
|
540
|
+
if (!magicLinks.verify(ctx.fullUrl())) {
|
|
541
|
+
ctx.flash("errors", { link: ["This link is invalid or has expired."] });
|
|
542
|
+
ctx.redirect("/login", 303);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const result = await magicLinks.login(email, ctx);
|
|
547
|
+
|
|
548
|
+
if (result === MAGIC.INVALID) {
|
|
549
|
+
ctx.flash("errors", { link: ["No account found for this email."] });
|
|
550
|
+
ctx.redirect("/login", 303);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
ctx.redirect("/dashboard", 303);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
Routes:
|
|
560
|
+
|
|
561
|
+
```typescript
|
|
562
|
+
// routes/web.ts
|
|
563
|
+
Router.post("/magic", MagicLinkController, "send");
|
|
564
|
+
Router.get("/magic/verify", MagicLinkController, "verify");
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
> **Note** — `sendLink()` returns `MAGIC.SENT` or `MAGIC.USER_NOT_FOUND`; `login()` returns `MAGIC.OK` or `MAGIC.INVALID`. Treat `USER_NOT_FOUND` as success in the UI so the endpoint doesn't reveal which emails have accounts.
|
|
568
|
+
|
|
569
|
+
## Guards & tokens
|
|
570
|
+
|
|
571
|
+
The default `web` guard reads a session cookie, which is the right answer for a
|
|
572
|
+
browser login and the wrong one for everything else. This page covers the rest:
|
|
573
|
+
proving who is making a request when there is no session to read.
|
|
574
|
+
|
|
575
|
+
### Which guard do I use?
|
|
576
|
+
|
|
577
|
+
| Middleware | Authenticates |
|
|
578
|
+
| ----------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
579
|
+
| `AuthMiddleware` | Pages and APIs that require a signed-in user |
|
|
580
|
+
| `GuestMiddleware` | Login/register routes, kept from signed-in users |
|
|
581
|
+
| `BasicAuthMiddleware` | Internal endpoints, straight from a Basic header |
|
|
582
|
+
| `BearerTokenMiddleware` | API and mobile clients holding a personal token |
|
|
583
|
+
| `JwtGuardMiddleware` | API clients holding a signed JWT |
|
|
584
|
+
| `TwoFactorMiddleware` | Layers over `AuthMiddleware` to require a passed challenge — see [Two-Factor Authentication](/docs/roles-and-2fa) |
|
|
585
|
+
|
|
586
|
+
The bearer and JWT middleware differ in where the truth lives. A personal access
|
|
587
|
+
token is a row you control: it can be listed, scoped to abilities, and revoked the
|
|
588
|
+
moment it is deleted. A JWT carries its own claims and is trusted until it expires,
|
|
589
|
+
so it needs no lookup — and cannot be withdrawn early without building a revocation
|
|
590
|
+
list that gives back the lookup you avoided. Choose tokens when revocation matters,
|
|
591
|
+
JWTs when statelessness does.
|
|
592
|
+
|
|
593
|
+
### Multiple guards
|
|
594
|
+
|
|
595
|
+
The top-level `Auth` facade is the default session-backed `web` guard. For separate auth schemes — most often a stateless API guard alongside the session UI — register a **request guard** with `Auth.viaRequest()` and reach it via `Auth.guard(name)`:
|
|
596
|
+
|
|
597
|
+
```typescript
|
|
598
|
+
import { Auth, Jwt } from "@zerotal/auth";
|
|
599
|
+
|
|
600
|
+
Auth.viaRequest("api", async (req) => {
|
|
601
|
+
const token = req.headers.get("authorization")?.replace("Bearer ", "");
|
|
602
|
+
const claims = token ? Jwt.verify<{ sub: number }>(token, Bun.env.JWT_SECRET!) : null;
|
|
603
|
+
return claims ? await User.find(claims.sub) : null;
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
// Anywhere in a request:
|
|
607
|
+
const user = await Auth.guard("api").userOrNull();
|
|
608
|
+
if (await Auth.guard("api").check()) {
|
|
609
|
+
/* ... */
|
|
610
|
+
}
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
`Auth.guard()` / `Auth.guard("web")` return the default guard — the same identity the top-level `Auth.*` methods read. Request guards are resolved lazily and cached per request.
|
|
614
|
+
|
|
615
|
+
Register guards where the rest of your bootstrapping happens — a service provider —
|
|
616
|
+
so the definition runs once at boot and is in place before any request arrives.
|
|
617
|
+
`Auth.guard()` throws when handed a name that was never registered, which turns a
|
|
618
|
+
typo or a missing provider into an immediate, clearly-worded failure rather than a
|
|
619
|
+
guard that quietly reports everyone as a guest.
|
|
620
|
+
|
|
621
|
+
Because a request guard is resolved lazily and then cached for the rest of the
|
|
622
|
+
request, its resolver runs at most once per request however many times you ask.
|
|
623
|
+
That makes it safe to hit the database or verify a token inside one.
|
|
624
|
+
|
|
625
|
+
### Reading the current identity
|
|
626
|
+
|
|
627
|
+
Every guard exposes the same five methods, and the two that differ only in how they
|
|
628
|
+
fail are worth choosing between deliberately:
|
|
629
|
+
|
|
630
|
+
| Method | Returns | Reach for it when |
|
|
631
|
+
| -------------- | -------------------------------- | ------------------------------------ |
|
|
632
|
+
| `user()` | The user, or throws | The route is already guarded |
|
|
633
|
+
| `userOrNull()` | The user, or `null` | Both outcomes are expected |
|
|
634
|
+
| `id()` | The identifier, or `undefined` | You need the key, not the record |
|
|
635
|
+
| `check()` | `true` when someone is signed in | Branching on signed-in state |
|
|
636
|
+
| `guest()` | `true` when nobody is | Branching the other way reads better |
|
|
637
|
+
|
|
638
|
+
Prefer `user()` behind middleware that has already established there is a user: it
|
|
639
|
+
returns a non-nullable value, so the code after it needs no defensive check, and a
|
|
640
|
+
misconfigured route fails loudly instead of proceeding with `null`. Reach for
|
|
641
|
+
`userOrNull()` on pages that render for signed-in and anonymous visitors alike.
|
|
642
|
+
|
|
643
|
+
`id()` skips loading the record entirely, which is what you want when the value is
|
|
644
|
+
only going into a foreign key or being compared against one.
|
|
645
|
+
|
|
646
|
+
### HTTP Basic authentication
|
|
647
|
+
|
|
648
|
+
For quick internal endpoints, `BasicAuthMiddleware` authenticates straight from the `Authorization: Basic` header — no login page. It's stateless (sets `ctx.user` for the request only) and challenges with `401 WWW-Authenticate` when credentials are missing or wrong.
|
|
649
|
+
|
|
650
|
+
```typescript
|
|
651
|
+
Router.get("/internal/metrics", MetricsController, "show", [BasicAuthMiddleware]);
|
|
652
|
+
// Authenticate by a different column / realm:
|
|
653
|
+
BasicAuthMiddleware.with({ field: "username", realm: "Admin" });
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
| Option | Default | Effect |
|
|
657
|
+
| ------- | ------------ | --------------------------------------------- |
|
|
658
|
+
| `field` | `email` | The credential column treated as the username |
|
|
659
|
+
| `realm` | `Restricted` | The realm shown in the browser's auth prompt |
|
|
660
|
+
|
|
661
|
+
Being stateless, it re-verifies the password on every request, and because the
|
|
662
|
+
`401` carries a `WWW-Authenticate` challenge a browser hitting the URL directly
|
|
663
|
+
gets the native credential prompt — which is what makes this convenient for an
|
|
664
|
+
internal dashboard nobody wants to build a login page for.
|
|
665
|
+
|
|
666
|
+
> **Warning** — Basic credentials are base64-encoded, not encrypted, so anything
|
|
667
|
+
> that can see the request can read them. Serve these endpoints over HTTPS only,
|
|
668
|
+
> and prefer a token guard for anything beyond internal use.
|
|
669
|
+
|
|
670
|
+
### JWT authentication
|
|
671
|
+
|
|
672
|
+
For stateless API or mobile clients, issue and verify HS256 JSON Web Tokens with the `Jwt` helper (no external dependency), and authenticate requests with `JwtGuardMiddleware`:
|
|
673
|
+
|
|
674
|
+
```typescript
|
|
675
|
+
import { Jwt, JwtGuardMiddleware, AuthMiddleware } from "@zerotal/auth";
|
|
676
|
+
|
|
677
|
+
// Issue on login:
|
|
678
|
+
const token = Jwt.sign({ sub: user.id, role: "admin" }, Bun.env.JWT_SECRET!, { expiresIn: 3600 });
|
|
679
|
+
|
|
680
|
+
// Authenticate requests (a populate step — pair with AuthMiddleware to guard):
|
|
681
|
+
const JwtGuard = JwtGuardMiddleware.with({
|
|
682
|
+
secret: Bun.env.JWT_SECRET!,
|
|
683
|
+
resolve: (claims) => User.find(Number(claims.sub)),
|
|
684
|
+
});
|
|
685
|
+
Router.get("/api/me", MeController, "show", [JwtGuard, AuthMiddleware]);
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
`Jwt.verify()` returns the claims or `null` (invalid signature, tampering, or past `exp`). The middleware reads the `Bearer` token, verifies it, and sets `ctx.user` from `resolve`; the secret falls back to `JWT_SECRET` then `APP_KEY` from the environment.
|
|
689
|
+
|
|
690
|
+
The pairing above is deliberate. `JwtGuardMiddleware` _populates_ `ctx.user` when a
|
|
691
|
+
valid token is present and stays quiet otherwise, so on its own it refuses nothing —
|
|
692
|
+
`AuthMiddleware` is what turns an unauthenticated request away. Applying the guard
|
|
693
|
+
without it leaves the route open to anyone who sends no token at all.
|
|
694
|
+
|
|
695
|
+
### API token authentication
|
|
696
|
+
|
|
697
|
+
For SPAs and mobile apps, issue personal access tokens instead of (or alongside) sessions.
|
|
698
|
+
|
|
699
|
+
#### API token migration
|
|
700
|
+
|
|
701
|
+
```typescript
|
|
702
|
+
// database/migrations/xxxx_create_personal_access_tokens.ts
|
|
703
|
+
await Schema.create("personal_access_tokens", (table) => {
|
|
704
|
+
table.increments("id");
|
|
705
|
+
table.integer("tokenable_id");
|
|
706
|
+
table.string("tokenable_type").default("user");
|
|
707
|
+
table.string("name");
|
|
708
|
+
table.string("token", 64).unique(); // SHA-256 hex of the plaintext
|
|
709
|
+
table.text("abilities").nullable(); // JSON array e.g. ["read","write"]
|
|
710
|
+
table.timestamp("last_used_at").nullable();
|
|
711
|
+
table.timestamp("expires_at").nullable();
|
|
712
|
+
table.timestamps();
|
|
713
|
+
});
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
#### Issuing tokens
|
|
717
|
+
|
|
718
|
+
```typescript
|
|
719
|
+
function createToken(options: {
|
|
720
|
+
tokenableId: number;
|
|
721
|
+
tokenableType?: string;
|
|
722
|
+
name: string;
|
|
723
|
+
abilities?: string[];
|
|
724
|
+
expiresAt?: Date;
|
|
725
|
+
}): Promise<{ plaintext: string; row: Omit<TokenRow, "id" | "created_at" | "updated_at"> }>;
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
> **Danger** — The plain-text token is returned to the client exactly once and is never stored — only its SHA-256 hash lives in the database. If the user loses it, issue a new one.
|
|
729
|
+
|
|
730
|
+
```typescript
|
|
731
|
+
// in a controller
|
|
732
|
+
import { createToken } from "@zerotal/auth";
|
|
733
|
+
import { DB } from "@zerotal/orm";
|
|
734
|
+
|
|
735
|
+
async issue(ctx: HttpContext) {
|
|
736
|
+
const user = Auth.user();
|
|
737
|
+
const { name, abilities } = await ctx.body<{ name: string; abilities?: string[] }>();
|
|
738
|
+
|
|
739
|
+
const { plaintext, row } = await createToken({
|
|
740
|
+
tokenableId: user.id,
|
|
741
|
+
name,
|
|
742
|
+
abilities: abilities ?? ["*"],
|
|
743
|
+
expiresAt: new Date(Date.now() + 90 * 86400 * 1000),
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
await DB.table("personal_access_tokens").insert(row);
|
|
747
|
+
|
|
748
|
+
// Return the plain-text token ONCE — it is never stored
|
|
749
|
+
ctx.json({ token: plaintext }, 201);
|
|
750
|
+
}
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
`createToken` builds the row but does not persist it, which is what lets you insert
|
|
754
|
+
it inside the same transaction as whatever else the request creates.
|
|
755
|
+
|
|
756
|
+
#### Setting up BearerTokenMiddleware
|
|
757
|
+
|
|
758
|
+
`BearerTokenMiddleware` reads `Authorization: Bearer <token>`, hashes it, looks it up via the registered loader, and sets `ctx.user` when valid. Register the loader (and an optional toucher to track `last_used_at`) once:
|
|
759
|
+
|
|
760
|
+
```typescript
|
|
761
|
+
// in AuthProvider.onBooted() or a custom AppProvider
|
|
762
|
+
import { BearerTokenMiddleware } from "@zerotal/auth";
|
|
763
|
+
import { DB } from "@zerotal/orm";
|
|
764
|
+
|
|
765
|
+
BearerTokenMiddleware.setLoader(async (hash) =>
|
|
766
|
+
DB.table("personal_access_tokens").where("token", hash).first(),
|
|
767
|
+
);
|
|
768
|
+
|
|
769
|
+
BearerTokenMiddleware.setToucher(async (id) => {
|
|
770
|
+
await DB.table("personal_access_tokens")
|
|
771
|
+
.where("id", id)
|
|
772
|
+
.update({ last_used_at: new Date().toISOString() });
|
|
773
|
+
});
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
The loader receives the _hash_, never the plaintext — the middleware hashes the
|
|
777
|
+
incoming header before looking anything up, which is why a leaked database still
|
|
778
|
+
yields no usable tokens. The toucher is optional; skip it when you do not need
|
|
779
|
+
last-used tracking, since it adds a write to every authenticated request.
|
|
780
|
+
|
|
781
|
+
Apply to API routes, and check abilities with `ctx.tokenCan()`:
|
|
782
|
+
|
|
783
|
+
```typescript
|
|
784
|
+
// routes/api.ts
|
|
785
|
+
Router.group({ prefix: "/api", middleware: [BearerTokenMiddleware] }, () => {
|
|
786
|
+
Router.get("/me", UserController, "show");
|
|
787
|
+
Router.post("/posts", PostController, "store");
|
|
788
|
+
});
|
|
789
|
+
```
|
|
790
|
+
|
|
791
|
+
## Testing
|
|
792
|
+
|
|
793
|
+
Authentication is worth testing from the outside in: what matters is whether a
|
|
794
|
+
request reaches the route, not which internal method decided that it could.
|
|
795
|
+
`@zerotal/testing` gives you a genuinely signed-in request and assertions about
|
|
796
|
+
who the server thinks is making it.
|
|
797
|
+
|
|
798
|
+
### Acting as a user
|
|
799
|
+
|
|
800
|
+
`actingAs(user)` makes the next request arrive authenticated. It is not a mock —
|
|
801
|
+
the test client encodes a session through your app's own session driver and sends
|
|
802
|
+
a real cookie, so the request travels the same middleware path a browser's would
|
|
803
|
+
and anything reading the current user sees the one you named.
|
|
804
|
+
|
|
805
|
+
```typescript
|
|
806
|
+
// tests/http/auth.test.ts
|
|
807
|
+
import { createTestApp } from "@zerotal/testing";
|
|
808
|
+
import { UserFactory } from "../../database/factories/UserFactory.ts";
|
|
809
|
+
import { Hash } from "@zerotal/auth";
|
|
810
|
+
|
|
811
|
+
it("dashboard is accessible to authenticated users", async () => {
|
|
812
|
+
const user = await UserFactory.create();
|
|
813
|
+
const res = await testApp.actingAs(user).get("/dashboard");
|
|
814
|
+
res.assertOk();
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
it("dashboard redirects guests to login", async () => {
|
|
818
|
+
const res = await testApp.get("/dashboard");
|
|
819
|
+
res.assertRedirect("/login");
|
|
820
|
+
});
|
|
821
|
+
```
|
|
822
|
+
|
|
823
|
+
`actingAs` only needs an object carrying an `id`, so a full model is optional when
|
|
824
|
+
the route reads nothing else:
|
|
825
|
+
|
|
826
|
+
```typescript
|
|
827
|
+
const res = await testApp.actingAs({ id: 42 }).get("/profile");
|
|
828
|
+
```
|
|
829
|
+
|
|
830
|
+
The acting user persists on the test client across requests — what you want inside
|
|
831
|
+
one test, and a leak across several. Clear it between tests:
|
|
832
|
+
|
|
833
|
+
```typescript
|
|
834
|
+
afterEach(() => testApp.actingAsGuest());
|
|
835
|
+
```
|
|
836
|
+
|
|
837
|
+
`withSession(data)` seeds extra session values alongside the acting user, for
|
|
838
|
+
routes that read something the real login flow would have put there:
|
|
839
|
+
|
|
840
|
+
```typescript
|
|
841
|
+
const res = await testApp.actingAs(user).withSession({ locale: "fr" }).get("/profile");
|
|
842
|
+
```
|
|
843
|
+
|
|
844
|
+
### Asserting who is signed in
|
|
845
|
+
|
|
846
|
+
These three describe the session the response left behind, which is how you test
|
|
847
|
+
the login flow itself rather than a route it protects.
|
|
848
|
+
|
|
849
|
+
| Assertion | Passes when |
|
|
850
|
+
| ----------------------------- | ------------------------------------------- |
|
|
851
|
+
| `assertAuthenticated()` | Someone is signed in |
|
|
852
|
+
| `assertAuthenticatedAs(user)` | That specific user is — takes a model or id |
|
|
853
|
+
| `assertGuest()` | Nobody is |
|
|
854
|
+
|
|
855
|
+
```typescript
|
|
856
|
+
it("signs the user in on valid credentials", async () => {
|
|
857
|
+
const user = await UserFactory.create({ password: await Hash.make("secret") });
|
|
858
|
+
|
|
859
|
+
const res = await testApp.post("/login", { email: user.email, password: "secret" });
|
|
860
|
+
|
|
861
|
+
res.assertRedirect("/dashboard");
|
|
862
|
+
res.assertAuthenticatedAs(user);
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
it("signs the user out", async () => {
|
|
866
|
+
const res = await testApp.actingAs(user).post("/logout");
|
|
867
|
+
res.assertGuest();
|
|
868
|
+
});
|
|
869
|
+
```
|
|
870
|
+
|
|
871
|
+
### Testing a rejected request
|
|
872
|
+
|
|
873
|
+
How a guarded route refuses differs by style, and asserting the wrong one lets a
|
|
874
|
+
test pass for the wrong reason:
|
|
875
|
+
|
|
876
|
+
- **Session routes** send an unauthenticated visitor to the login page — assert
|
|
877
|
+
`assertRedirect("/login")`.
|
|
878
|
+
- **API routes** answer with a status — `assertUnauthorized()` for 401 (not signed
|
|
879
|
+
in) and `assertForbidden()` for 403 (signed in, not permitted).
|
|
880
|
+
|
|
881
|
+
```typescript
|
|
882
|
+
it("rejects an API request with no token", async () => {
|
|
883
|
+
const res = await testApp.asJson().get("/api/orders");
|
|
884
|
+
res.assertUnauthorized();
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
it("rejects a signed-in user without the ability", async () => {
|
|
888
|
+
const res = await testApp.actingAs(viewer).delete("/api/orders/1");
|
|
889
|
+
res.assertForbidden();
|
|
890
|
+
});
|
|
891
|
+
```
|
|
892
|
+
|
|
893
|
+
A failed login usually redirects back carrying validation errors rather than a
|
|
894
|
+
status code, so assert on the errors:
|
|
895
|
+
|
|
896
|
+
```typescript
|
|
897
|
+
it("login with wrong password redirects back", async () => {
|
|
898
|
+
const user = await UserFactory.create({ password: await Hash.make("correct") });
|
|
899
|
+
|
|
900
|
+
const res = await testApp.post("/login", { email: user.email, password: "wrong" });
|
|
901
|
+
|
|
902
|
+
res.assertRedirect("/login");
|
|
903
|
+
res.assertSessionHasErrors("email");
|
|
904
|
+
});
|
|
905
|
+
```
|
|
906
|
+
|
|
907
|
+
### Token-authenticated requests
|
|
908
|
+
|
|
909
|
+
A bearer guard reads a header rather than a cookie, so `actingAs` plays no part —
|
|
910
|
+
issue a token and send it the way a client would. `createToken` returns the
|
|
911
|
+
plain-text value once, which is the value the header carries:
|
|
912
|
+
|
|
913
|
+
```typescript
|
|
914
|
+
import { createToken } from "@zerotal/auth";
|
|
915
|
+
import { DB } from "@zerotal/orm";
|
|
916
|
+
|
|
917
|
+
it("serves the API with a valid token", async () => {
|
|
918
|
+
const user = await UserFactory.create();
|
|
919
|
+
const { plaintext, row } = await createToken({ tokenableId: user.id, name: "tests" });
|
|
920
|
+
await DB.table("personal_access_tokens").insert(row);
|
|
921
|
+
|
|
922
|
+
const res = await testApp
|
|
923
|
+
.withHeaders({ Authorization: `Bearer ${plaintext}` })
|
|
924
|
+
.asJson()
|
|
925
|
+
.get("/api/me");
|
|
926
|
+
|
|
927
|
+
res.assertOk();
|
|
928
|
+
});
|
|
929
|
+
```
|
|
930
|
+
|
|
931
|
+
### Following the redirect
|
|
932
|
+
|
|
933
|
+
By default the client hands back the redirect itself, which is what a login flow
|
|
934
|
+
should assert on. When the page the user lands on is the point, ask for it:
|
|
935
|
+
|
|
936
|
+
```typescript
|
|
937
|
+
const res = await testApp.followingRedirects().post("/login", { email, password });
|
|
938
|
+
res.assertOk();
|
|
939
|
+
res.assertSee("Welcome back");
|
|
940
|
+
```
|
|
941
|
+
|
|
942
|
+
## References
|
|
943
|
+
|
|
944
|
+
### Commands
|
|
945
|
+
|
|
946
|
+
`@zerotal/auth` ships two commands:
|
|
947
|
+
|
|
948
|
+
| Command | What it does |
|
|
949
|
+
| -------------------------------------------- | --------------------------------------------------------------------- |
|
|
950
|
+
| `bun zt make:policy PostPolicy --model Post` | Create a new authorization policy class |
|
|
951
|
+
| `bun zt auth:sync-permissions` | Create code-declared permissions that don't yet exist in the database |
|
|
952
|
+
|
|
953
|
+
### Auth facade
|
|
954
|
+
|
|
955
|
+
| Method | Signature | Description |
|
|
956
|
+
| ------------------------------------- | -------------------------------------------------------------- | ---------------------------------------------------- |
|
|
957
|
+
| `Auth.check()` | `(): boolean` | `true` if request has an authenticated user |
|
|
958
|
+
| `Auth.guest()` | `(): boolean` | Inverse of `check()` |
|
|
959
|
+
| `Auth.viaRemember()` | `(): boolean` | `true` if authenticated via the remember-me cookie |
|
|
960
|
+
| `Auth.user()` | `(): UserModel` | Current user; throws `UnauthorizedError` for guests |
|
|
961
|
+
| `Auth.userOrNull()` | `(): UserModel \| undefined` | Safe — never throws |
|
|
962
|
+
| `Auth.id()` | `(): number` | Current user's ID; throws for guests |
|
|
963
|
+
| `Auth.login(user, opts?)` | `(user: UserModel, opts?: LoginOptions): Promise<void>` | Write `user_id` to session and set `ctx.user` |
|
|
964
|
+
| `Auth.logout()` | `(): Promise<void>` | Clear `user_id` from session and unset `ctx.user` |
|
|
965
|
+
| `Auth.attempt(creds, rem?)` | `(creds: Credentials, remember?: boolean): Promise<boolean>` | Find user, verify password, log in on success |
|
|
966
|
+
| `Auth.attemptWhen(...)` | `(creds, cb, remember?): Promise<boolean>` | Like `attempt`, gated by a callback check |
|
|
967
|
+
| `Auth.validate(creds)` | `(creds: Credentials): Promise<boolean>` | Verify credentials without logging in |
|
|
968
|
+
| `Auth.once(creds)` | `(creds: Credentials): Promise<boolean>` | Authenticate for this request only (no session) |
|
|
969
|
+
| `Auth.loginUsingId(id)` | `(id: number, remember?: boolean): Promise<UserModel \| null>` | Log in by primary key |
|
|
970
|
+
| `Auth.confirmPassword(pw)` | `(password: string): Promise<boolean>` | Verify password & stamp a fresh confirmation |
|
|
971
|
+
| `Auth.hasRecentlyConfirmedPassword()` | `(timeoutSeconds?: number): boolean` | True if confirmed within the window (default 3h) |
|
|
972
|
+
| `Auth.logoutOtherDevices(pw)` | `(password: string): Promise<boolean>` | Invalidate the user's other sessions |
|
|
973
|
+
| `Auth.guard(name?)` | `(name?: string): Guard` | Access a guard (`web` default; named via viaRequest) |
|
|
974
|
+
| `Auth.viaRequest(name, fn)` | `(name: string, resolver): void` | Register a custom request guard |
|
|
975
|
+
| `Auth.hasRole(role)` | `(role: string): boolean` | True when user has the given role |
|
|
976
|
+
| `Auth.hasAnyRole(roles)` | `(roles: string[]): boolean` | True when user has at least one role |
|
|
977
|
+
| `Auth.hasAllRoles(roles)` | `(roles: string[]): boolean` | True when user has every role |
|
|
978
|
+
| `Auth.can(ability)` | `(ability: string): boolean` | True when user has the given permission |
|
|
979
|
+
| `Auth.authorize(ability)` | `(ability: string): void` | Throws `ForbiddenError` if user lacks the ability |
|
|
980
|
+
| `Auth.roles()` | `(): string[]` | Array of role names (empty for guests) |
|
|
981
|
+
|
|
982
|
+
> **Note** — The role and permission helpers (`hasRole`, `can`, `authorize`, …) require a user model that composes the relevant mixins. See [Authorization](/docs/authorization) and [Roles & 2FA](/docs/roles-and-2fa).
|
|
983
|
+
|
|
984
|
+
### Two-factor API
|
|
985
|
+
|
|
986
|
+
| Method | Signature | Description |
|
|
987
|
+
| -------------------------------------------------- | --------------------------------------------------------------------------------- | -------------------------------- |
|
|
988
|
+
| `TwoFactor.generateSecret()` | `(): string` | 20-byte random base-32 secret |
|
|
989
|
+
| `TwoFactor.getQrCodeUrl(label, secret, issuer?)` | `(label: string, secret: string, issuer?: string): string` | `otpauth://totp/…` URI |
|
|
990
|
+
| `TwoFactor.verifyCode(secret, token)` | `(secret: string, token: string): boolean` | Verify a 6-digit TOTP code |
|
|
991
|
+
| `TwoFactor.generateRecoveryCodes()` | `(): { plain: string[]; hashed: string[] }` | Generate one-time recovery codes |
|
|
992
|
+
| `TwoFactor.verifyRecoveryCode(storedHashed, code)` | `(storedHashed: string[], code: string): { valid: boolean; remaining: string[] }` | Consume a recovery code |
|
|
993
|
+
|
|
994
|
+
### Personal access tokens
|
|
995
|
+
|
|
996
|
+
| Function | Signature | Description |
|
|
997
|
+
| ------------------------ | ------------------------------------------- | ---------------------------------------------- |
|
|
998
|
+
| `createToken(options)` | `(options): Promise<NewToken>` | Generate a token; returns `{ plaintext, row }` |
|
|
999
|
+
| `hashToken(plaintext)` | `(plaintext: string): Promise<string>` | SHA-256 hex of a plain-text token |
|
|
1000
|
+
| `tokenCan(row, ability)` | `(row: TokenRow, ability: string): boolean` | True when the token grants the ability |
|