@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.
Files changed (160) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +67 -0
  4. package/api-surface.md +283 -0
  5. package/docs/about.md +676 -0
  6. package/docs/admin/actions.md +183 -0
  7. package/docs/admin/auth.md +89 -0
  8. package/docs/admin/dashboard.md +155 -0
  9. package/docs/admin/extending-ui.md +229 -0
  10. package/docs/admin/extending.md +205 -0
  11. package/docs/admin/forms.md +155 -0
  12. package/docs/admin/index.md +144 -0
  13. package/docs/admin/operations.md +224 -0
  14. package/docs/admin/references.md +174 -0
  15. package/docs/admin/resources.md +132 -0
  16. package/docs/admin/structure.md +158 -0
  17. package/docs/admin/tables.md +185 -0
  18. package/docs/admin/testing.md +138 -0
  19. package/docs/ai.md +373 -0
  20. package/docs/application.md +435 -0
  21. package/docs/arch.md +263 -0
  22. package/docs/assets.md +755 -0
  23. package/docs/audit.md +367 -0
  24. package/docs/authentication.md +1000 -0
  25. package/docs/authorization.md +529 -0
  26. package/docs/broadcasting/channels.md +170 -0
  27. package/docs/broadcasting/client.md +108 -0
  28. package/docs/broadcasting/events.md +190 -0
  29. package/docs/broadcasting/index.md +163 -0
  30. package/docs/broadcasting/references.md +84 -0
  31. package/docs/broadcasting/testing.md +132 -0
  32. package/docs/cache.md +387 -0
  33. package/docs/carbon.md +830 -0
  34. package/docs/changelog.md +465 -0
  35. package/docs/client/auth.md +113 -0
  36. package/docs/client/errors.md +139 -0
  37. package/docs/client/files.md +118 -0
  38. package/docs/client/index.md +128 -0
  39. package/docs/client/references.md +58 -0
  40. package/docs/client/requests.md +131 -0
  41. package/docs/client/resilience.md +141 -0
  42. package/docs/client/testing.md +146 -0
  43. package/docs/commands.md +450 -0
  44. package/docs/components.md +2427 -0
  45. package/docs/config-system.md +258 -0
  46. package/docs/container.md +596 -0
  47. package/docs/context.md +579 -0
  48. package/docs/contributing.md +164 -0
  49. package/docs/controllers.md +354 -0
  50. package/docs/conventions.md +466 -0
  51. package/docs/cookies.md +101 -0
  52. package/docs/csrf.md +206 -0
  53. package/docs/database.md +476 -0
  54. package/docs/deployment.md +434 -0
  55. package/docs/devtools.md +1013 -0
  56. package/docs/email-verification.md +320 -0
  57. package/docs/encryption.md +380 -0
  58. package/docs/errors.md +317 -0
  59. package/docs/events.md +420 -0
  60. package/docs/flow/components.md +846 -0
  61. package/docs/flow/decorators.md +458 -0
  62. package/docs/flow/events.md +420 -0
  63. package/docs/flow/forms.md +551 -0
  64. package/docs/flow/index.md +568 -0
  65. package/docs/flow/layouts.md +793 -0
  66. package/docs/flow/lifecycle.md +336 -0
  67. package/docs/flow/pagination.md +402 -0
  68. package/docs/flow/performance.md +262 -0
  69. package/docs/flow/references.md +317 -0
  70. package/docs/flow/routing.md +550 -0
  71. package/docs/flow/testing.md +447 -0
  72. package/docs/getting-started.md +286 -0
  73. package/docs/health.md +269 -0
  74. package/docs/helpers.md +423 -0
  75. package/docs/i18n.md +414 -0
  76. package/docs/index.md +65 -0
  77. package/docs/inertia/build.md +95 -0
  78. package/docs/inertia/devtools.md +197 -0
  79. package/docs/inertia/index.md +267 -0
  80. package/docs/inertia/middleware.md +81 -0
  81. package/docs/inertia/props.md +507 -0
  82. package/docs/inertia/references.md +144 -0
  83. package/docs/inertia/rendering.md +189 -0
  84. package/docs/inertia/ssr.md +102 -0
  85. package/docs/inspirations.md +257 -0
  86. package/docs/lifecycle.md +331 -0
  87. package/docs/lock.md +454 -0
  88. package/docs/logger.md +517 -0
  89. package/docs/media.md +582 -0
  90. package/docs/middleware.md +509 -0
  91. package/docs/migrations.md +529 -0
  92. package/docs/monitor.md +377 -0
  93. package/docs/notifications.md +848 -0
  94. package/docs/orm/casts.md +365 -0
  95. package/docs/orm/factories.md +231 -0
  96. package/docs/orm/index.md +663 -0
  97. package/docs/orm/lifecycle.md +421 -0
  98. package/docs/orm/queries.md +692 -0
  99. package/docs/orm/relationships.md +440 -0
  100. package/docs/orm/serialization.md +251 -0
  101. package/docs/package-development.md +536 -0
  102. package/docs/pagination.md +340 -0
  103. package/docs/password-reset.md +335 -0
  104. package/docs/providers.md +447 -0
  105. package/docs/query-builder.md +465 -0
  106. package/docs/queue.md +549 -0
  107. package/docs/rate-limiting.md +253 -0
  108. package/docs/responses.md +336 -0
  109. package/docs/roles-and-2fa.md +418 -0
  110. package/docs/routing.md +1008 -0
  111. package/docs/scaffolding.md +335 -0
  112. package/docs/scheduler.md +533 -0
  113. package/docs/seeding.md +229 -0
  114. package/docs/session.md +451 -0
  115. package/docs/social.md +524 -0
  116. package/docs/storage.md +622 -0
  117. package/docs/structure.md +230 -0
  118. package/docs/support-policy.md +114 -0
  119. package/docs/telemetry.md +505 -0
  120. package/docs/tenancy.md +608 -0
  121. package/docs/testing/browser.md +153 -0
  122. package/docs/testing/console.md +130 -0
  123. package/docs/testing/database.md +238 -0
  124. package/docs/testing/flow-browser.md +216 -0
  125. package/docs/testing/http.md +441 -0
  126. package/docs/testing/index.md +240 -0
  127. package/docs/testing/mocking.md +410 -0
  128. package/docs/upgrade.md +192 -0
  129. package/docs/validator.md +549 -0
  130. package/docs/view.md +453 -0
  131. package/package.json +60 -0
  132. package/src/bin/mcp.ts +73 -0
  133. package/src/config.ts +63 -0
  134. package/src/errors.ts +27 -0
  135. package/src/index.ts +56 -0
  136. package/src/install/ArchInstallCommand.ts +189 -0
  137. package/src/install/detect.ts +98 -0
  138. package/src/install/guidelines.ts +290 -0
  139. package/src/install/markers.ts +77 -0
  140. package/src/install/mcpConfig.ts +81 -0
  141. package/src/mcp/index.ts +27 -0
  142. package/src/mcp/jsonrpc.ts +133 -0
  143. package/src/mcp/server.ts +307 -0
  144. package/src/mcp/stdio.ts +111 -0
  145. package/src/mcp/types.ts +146 -0
  146. package/src/probe/ArchProbeCommand.ts +48 -0
  147. package/src/probe/sentinel.ts +13 -0
  148. package/src/probe/topics.ts +291 -0
  149. package/src/provider/ArchProvider.ts +101 -0
  150. package/src/tools/_probe.ts +164 -0
  151. package/src/tools/apiSurface.ts +241 -0
  152. package/src/tools/appInfo.ts +88 -0
  153. package/src/tools/baselines.ts +210 -0
  154. package/src/tools/context.ts +37 -0
  155. package/src/tools/doctor.ts +93 -0
  156. package/src/tools/index.ts +50 -0
  157. package/src/tools/logs.ts +265 -0
  158. package/src/tools/routes.ts +133 -0
  159. package/src/tools/schema.ts +119 -0
  160. 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 |